Implement first album functionality

This commit is contained in:
Theresa Gresch 2019-06-17 21:45:06 +02:00
parent 499057d81e
commit 5f63f2999c
11 changed files with 597 additions and 472 deletions

View file

@ -84,7 +84,7 @@ class Abstract {
}
static getCreateResource() {
return this.getCollectionResource() + "/new";
return this.getCollectionResource();
}
static getCreateForm() {

View file

@ -0,0 +1,79 @@
import Abstract from "model/abstract";
import Api from "common/api";
import moment from "moment";
class Album extends Abstract {
getEntityName() {
return this.AlbumSlug;
}
getId() {
return this.AlbumUUID;
}
getTitle() {
return this.AlbumName;
}
getThumbnailUrl(type) {
return "/api/v1/albums/" + this.getId() + "/thumbnail/" + type;
}
getThumbnailSrcset() {
const result = [];
result.push(this.getThumbnailUrl("fit_720") + " 720w");
result.push(this.getThumbnailUrl("fit_1280") + " 1280w");
result.push(this.getThumbnailUrl("fit_1920") + " 1920w");
result.push(this.getThumbnailUrl("fit_2560") + " 2560w");
result.push(this.getThumbnailUrl("fit_3840") + " 3840w");
return result.join(", ");
}
getThumbnailSizes() {
const result = [];
result.push("(min-width: 2560px) 3840px");
result.push("(min-width: 1920px) 2560px");
result.push("(min-width: 1280px) 1920px");
result.push("(min-width: 720px) 1280px");
result.push("720px");
return result.join(", ");
}
getDateString() {
return moment(this.CreatedAt).format("LLL");
}
toggleLike() {
this.AlbumFavorite = !this.AlbumFavorite;
if(this.AlbumFavorite) {
return Api.post(this.getEntityResource() + "/like");
} else {
return Api.delete(this.getEntityResource() + "/like");
}
}
like() {
this.AlbumFavorite = true;
return Api.post(this.getEntityResource() + "/like");
}
unlike() {
this.AlbumFavorite = false;
return Api.delete(this.getEntityResource() + "/like");
}
static getCollectionResource() {
return "albums";
}
static getModelName() {
return "Album";
}
}
export default Album;

View file

@ -1,9 +1,9 @@
<template>
<div v-infinite-scroll="loadMore" infinite-scroll-disabled="loadMoreDisabled" infinite-scroll-distance="10">
<v-form ref="form" lazy-validation @submit="formChange" dense>
<div class="p-page p-page-albums" v-infinite-scroll="loadMore" :infinite-scroll-disabled="scrollDisabled"
:infinite-scroll-distance="10" :infinite-scroll-listen-for-event="'scrollRefresh'">
<v-form ref="form" class="p-albums-search" lazy-validation @submit.prevent="updateQuery" dense>
<v-toolbar flat color="blue-grey lighten-4">
<h1 class="md-display-1">Albums</h1>
<v-spacer></v-spacer>
<v-text-field class="pt-3 pr-3"
single-line
label="Search"
@ -11,219 +11,46 @@
clearable
color="blue-grey"
@click:clear="clearQuery"
v-model="query.q"
@keyup.enter.native="formChange"
v-model="filter.q"
@keyup.enter.native="updateQuery"
id="search"
></v-text-field>
<!-- v-btn @click="formChange" color="secondary">Create Filter</v-btn -->
<v-spacer></v-spacer>
<v-btn icon @click="advandedSearch = !advandedSearch">
<v-icon>{{ advandedSearch ? 'keyboard_arrow_up' : 'keyboard_arrow_down' }}</v-icon>
</v-btn>
</v-toolbar>
<v-card class="pt-1"
flat
color="blue-grey lighten-5"
v-show="advandedSearch">
<v-card-text>
<v-layout row wrap>
<v-flex xs12 sm6 md3 pa-2>
<v-select @change="formChange"
label="Country"
flat solo hide-details
color="blue-grey"
item-value="LocCountryCode"
item-text="LocCountry"
v-model="query.country"
:items="options.countries">
</v-select>
</v-flex>
<v-flex xs12 sm6 md3 pa-2>
<v-select @change="formChange"
label="Camera"
flat solo hide-details
color="blue-grey"
item-value="ID"
item-text="CameraModel"
v-model="query.camera"
:items="options.cameras">
</v-select>
</v-flex>
<v-flex xs12 sm6 md3 pa-2>
<v-select @change="formChange"
label="View"
flat solo hide-details
color="blue-grey"
v-model="query.view"
:items="options.views">
</v-select>
</v-flex>
<v-flex xs12 sm6 md3 pa-2>
<v-select @change="formChange"
label="Sort By"
flat solo hide-details
color="blue-grey"
v-model="query.order"
:items="options.sorting">
</v-select>
</v-flex>
</v-layout>
</v-card-text>
</v-card>
</v-form>
<v-container fluid>
<p class="md-subheading">
A user-friendly tool for importing, filtering and archiving large amounts of JPEG and RAW files
</p>
<v-btn
color="success"
dark
@click.stop="dialog = true"
>Create album
</v-btn>
</v-container>
<v-dialog v-model="dialog" dark persistent max-width="600px">
<v-card dark>
<v-card-title>
<span class="headline">Create album</span>
</v-card-title>
<v-card-text>
<v-container grid-list-md>
<v-layout wrap>
<v-flex xs12>
<v-text-field label="Album name*" required></v-text-field>
</v-flex>
<v-flex xs12>
<v-textarea label="Description"></v-textarea>
</v-flex>
</v-layout>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="success" flat @click="dialog = false">Close</v-btn>
<v-btn color="success" flat @click="dialog = false">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-container fluid>
<v-speed-dial
fixed
bottom
right
direction="top"
open-on-hover
transition="slide-y-reverse-transition"
style="right: 8px; bottom: 8px;"
>
<v-btn
slot="activator"
color="grey darken-2"
dark
fab
>
<v-icon>menu</v-icon>
</v-btn>
<v-btn
fab
dark
small
color="deep-purple lighten-2"
>
<v-icon>favorite</v-icon>
</v-btn>
<v-btn
fab
dark
small
color="cyan accent-4"
>
<v-icon>youtube_searched_for</v-icon>
</v-btn>
<v-btn
fab
dark
small
color="teal accent-4"
>
<v-icon>save</v-icon>
</v-btn>
<v-btn
fab
dark
small
color="yellow accent-4"
>
<v-btn icon @click.prevent="create">
<v-icon>create_new_folder</v-icon>
</v-btn>
</v-toolbar>
</v-form>
<v-btn
fab
dark
small
color="delete"
>
<v-icon>delete</v-icon>
</v-btn>
</v-speed-dial>
<v-data-table
:headers="listColumns"
:items="results"
hide-actions
class="elevation-1"
v-if="query.view === 'list'"
select-all
disable-initial-sort
item-key="ID"
v-model="selected"
:no-data-text="'No photos matched your search'"
>
<template slot="items" slot-scope="props">
<td>
<v-checkbox
v-model="props.selected"
primary
hide-details
></v-checkbox>
</td>
<td>Album Title</td>
<td>Some album description</td>
<td>11/01/2018 - 01/02/2018</td>
<td>London, Durban, Berlin</td>
<td>Germany, South Africa</td>
<td>Iphone SE, Canon</td>
</template>
</v-data-table>
<v-container fluid class="pa-2">
<p-scroll-top></p-scroll-top>
<v-container grid-list-xs fluid class="pa-0" v-if="query.view === 'details'">
<v-card v-if="results.length === 0">
<v-container grid-list-xs fluid class="pa-0 p-albums p-albums-details">
<v-card v-if="results.length === 0" class="p-albums-empty" flat>
<v-card-title primary-title>
<div>
<h3 class="title mb-3">No photos matched your search</h3>
<div>Try using other terms and search options such as category, country and camera.</div>
<h3 class="title mb-3">No albums matched your search</h3>
<div>Try again using a different name or <a @click.prevent="create" href="#">create a new album</a>.</div>
</div>
</v-card-title>
</v-card>
<v-layout row wrap>
<v-flex
v-for="(photo, index) in results"
:key="photo.ID"
xs12 sm6 md4 lg3 d-flex
v-for="(album, index) in results"
:key="index"
class="p-album"
xs6 sm4 md3 lg2 d-flex
>
<v-hover>
<v-card tile slot-scope="{ hover }"
:dark="photo.selected"
:class="photo.selected ? 'elevation-14 ma-1' : 'elevation-2 ma-2'">
<v-card tile class="elevation-0 ma-1 grey lighten-3">
<v-img
:src="photo.getThumbnailUrl('tile_500')"
:src="album.getThumbnailUrl('tile_500')"
aspect-ratio="1"
v-bind:class="{ selected: photo.selected }"
style="cursor: pointer"
class="grey lighten-2"
@click="openPhoto(index)"
@click.prevent="openAlbum(index)"
>
<v-layout
slot="placeholder"
@ -234,276 +61,76 @@
>
<v-progress-circular indeterminate color="grey lighten-5"></v-progress-circular>
</v-layout>
</v-img>
<v-btn v-if="hover || photo.selected" :flat="!hover" icon large absolute
:ripple="false" style="right: 4px; bottom: 4px;"
@click.stop.prevent="selectPhoto(photo)">
<v-icon v-if="photo.selected" color="white">check_box</v-icon>
<v-icon v-else color="white">check_box_outline_blank</v-icon>
</v-btn>
<v-btn v-if="hover || photo.PhotoFavorite" :flat="!hover" icon large absolute
:ripple="false" style="bottom: 4px; left: 4px"
@click.stop.prevent="likePhoto(photo)">
<v-icon v-if="photo.PhotoFavorite" color="white">favorite
<v-card-actions>
{{ album.AlbumName | capitalize }}
<v-spacer></v-spacer>
<v-btn icon @click.stop.prevent="album.toggleLike()">
<v-icon v-if="album.AlbumFavorite" color="#FFD600">star
</v-icon>
<v-icon v-else color="white">favorite_border</v-icon>
<v-icon v-else color="grey lighten-2">star</v-icon>
</v-btn>
</v-img>
<v-card-title primary-title class="pa-3">
<div>
<h3 class="subheading mb-2" :title="photo.PhotoTitle">Album Title</h3>
<div class="caption">
Some description
<br/>
<v-icon size="14">date_range</v-icon>
11/01/2018 - 01/02/2018
<br/>
<v-icon size="14">photo_camera</v-icon>
iPhone SE, Canon
<br/>
<v-icon size="14">location_on</v-icon>
South africa, Germany (Most occuring locations)
</div>
</div>
</v-card-title>
</v-card-actions>
</v-card>
</v-hover>
</v-flex>
</v-layout>
</v-container>
<v-container grid-list-xs fluid class="pa-0" v-if="query.view === 'tiles'">
<v-card v-if="results.length === 0">
<v-card-title primary-title>
<div>
<h3 class="headline mb-3">No photos matched your search</h3>
<div>Try using other terms and search options such as category, country and camera.</div>
</div>
</v-card-title>
</v-card>
<v-layout row wrap>
<v-flex
v-for="(photo, index) in results"
:key="photo.ID"
xs12 sm6 md3 lg2 d-flex
v-bind:class="{ selected: photo.selected }"
>
<v-hover>
<v-card tile slot-scope="{ hover }"
:dark="photo.selected"
:class="photo.selected ? 'elevation-14 ma-1' : hover ? 'elevation-6 ma-2' : 'elevation-2 ma-2'">
<v-img :src="photo.getThumbnailUrl('tile_500')"
aspect-ratio="1"
class="grey lighten-2"
style="cursor: pointer"
@click="openPhoto(index)"
>
<v-layout
slot="placeholder"
fill-height
align-center
justify-center
ma-0
>
<v-progress-circular indeterminate
color="grey lighten-5"></v-progress-circular>
</v-layout>
<v-btn v-if="hover || photo.selected" :flat="!hover" icon large absolute
:ripple="false" style="right: 4px; bottom: 4px;"
@click.stop.prevent="selectPhoto(photo)">
<v-icon v-if="photo.selected" color="white">check_box</v-icon>
<v-icon v-else color="white">check_box_outline_blank</v-icon>
</v-btn>
<v-btn v-if="hover || photo.PhotoFavorite" :flat="!hover" icon large absolute
:ripple="false" style="bottom: 4px; left: 4px"
@click.stop.prevent="likePhoto(photo)">
<v-icon v-if="photo.PhotoFavorite" color="white">favorite</v-icon>
<v-icon v-else color="white">favorite_border</v-icon>
</v-btn>
</v-img>
</v-card>
</v-hover>
</v-flex>
</v-layout>
</v-container>
<v-snackbar
v-model="snackbarVisible"
bottom
:timeout="0"
>
{{ snackbarText }}
<v-btn
class="pr-0"
color="primary"
icon
flat
@click="clearSelection()"
>
<v-icon>close</v-icon>
</v-btn>
</v-snackbar>
</v-container>
</div>
</template>
<script>
import Photo from 'model/photo';
import Album from "model/album";
export default {
name: 'browse',
props: {},
name: 'p-page-albums',
props: {
staticFilter: Object
},
watch: {
'$route' () {
const query = this.$route.query;
this.filter.q = query['q'];
this.lastFilter = {};
this.routeName = this.$route.name;
this.search();
}
},
data() {
const query = this.$route.query;
const order = query['order'] ? query['order'] : 'newest';
const camera = query['camera'] ? parseInt(query['camera']) : 0;
const routeName = this.$route.name;
const q = query['q'] ? query['q'] : '';
const country = query['country'] ? query['country'] : '';
const view = query['view'] ? query['view'] : 'details';
const cameras = [{ID: 0, CameraModel: 'All Cameras'}].concat(this.$config.getValue('cameras'));
const countries = [{
LocCountryCode: '',
LocCountry: 'All Countries'
}].concat(this.$config.getValue('countries'));
const filter = {q: q};
const settings = {};
return {
'snackbarVisible': false,
'snackbarText': '',
'advandedSearch': false,
'window': {
width: 0,
height: 0
},
'results': [],
'query': {
view: view,
country: country,
camera: camera,
order: order,
q: q,
},
'options': {
'categories': [
{value: '', text: 'All Categories'},
{value: 'airport', text: 'Airport'},
{value: 'amenity', text: 'Amenity'},
{value: 'building', text: 'Building'},
{value: 'historic', text: 'Historic'},
{value: 'shop', text: 'Shop'},
{value: 'tourism', text: 'Tourism'},
],
'views': [
{value: 'details', text: 'Details'},
{value: 'list', text: 'List'},
{value: 'tiles', text: 'Tiles'},
],
'countries': countries,
'cameras': cameras,
'sorting': [
{value: 'newest', text: 'Newest first'},
{value: 'oldest', text: 'Oldest first'},
{value: 'imported', text: 'Recently imported'},
],
},
'listColumns': [
{text: 'Title', value: 'PhotoTitle'},
{text: 'Description', value: 'PhotoFavorite'},
{text: 'Taken At', value: 'TakenAt'},
{text: 'City', value: 'LocCity'},
{text: 'Country', value: 'LocCountry'},
{text: 'Camera', value: 'CameraModel'},
],
'view': view,
'loadMoreDisabled': true,
'pageSize': 60,
'offset': 0,
'lastQuery': {},
'submitTimeout': false,
'selected': [],
'dialog': false,
results: [],
scrollDisabled: true,
pageSize: 24,
offset: 0,
selection: this.$clipboard.selection,
settings: settings,
filter: filter,
lastFilter: {},
routeName: routeName,
};
},
destroyed() {
window.removeEventListener('resize', this.handleResize)
},
methods: {
handleResize() {
this.window.width = window.innerWidth;
this.window.height = window.innerHeight;
},
clearSelection() {
for (let i = 0; i < this.selected.length; i++) {
this.selected[i].selected = false;
}
this.selected = [];
this.updateSnackbar();
},
updateSnackbar(text) {
if (!text) text = "";
this.snackbarText = text;
this.snackbarVisible = this.snackbarText !== "";
},
showSnackbar() {
this.snackbarVisible = this.snackbarText !== "";
},
hideSnackbar() {
this.snackbarVisible = false;
},
selectPhoto(photo, ev) {
if (photo.selected) {
for (let i = 0; i < this.selected.length; i++) {
if (this.selected[i].id === photo.id) {
this.selected.splice(i, 1);
break;
}
}
photo.selected = false;
} else {
this.selected.push(photo);
photo.selected = true;
}
if (this.selected.length > 0) {
if (this.selected.length === 1) {
this.snackbarText = 'One photo selected';
} else {
this.snackbarText = this.selected.length + ' photos selected';
}
this.snackbarVisible = true;
} else {
this.snackbarText = '';
this.snackbarVisible = false;
}
},
likePhoto(photo) {
photo.PhotoFavorite = !photo.PhotoFavorite;
photo.like(photo.PhotoFavorite);
},
deletePhoto(photo) {
this.$alert.success('Photo deleted');
},
formChange(event) {
this.search();
},
clearQuery() {
this.query.q = '';
this.filter.q = '';
this.search();
},
openPhoto(index) {
this.$viewer.show(this.results, index)
openAlbum(index) {
const album = this.results[index];
this.$router.push({name: 'Photos', query: {q: "album:" + album.AlbumSlug}});
},
loadMore() {
if (this.loadMoreDisabled) return;
if (this.scrollDisabled) return;
this.loadMoreDisabled = true;
this.scrollDisabled = true;
this.offset += this.pageSize;
@ -512,56 +139,96 @@
offset: this.offset,
};
Object.assign(params, this.lastQuery);
Object.assign(params, this.lastFilter);
Photo.search(params).then(response => {
Album.search(params).then(response => {
this.results = this.results.concat(response.models);
this.loadMoreDisabled = (response.models.length < this.pageSize);
this.scrollDisabled = (response.models.length < this.pageSize);
if (this.loadMoreDisabled) {
this.$alert.info('All ' + this.results.length + ' photos loaded');
if (this.scrollDisabled) {
this.$alert.info('All ' + this.results.length + ' albums loaded');
}
});
},
search() {
this.loadMoreDisabled = true;
updateQuery() {
const query = {
view: this.settings.view
};
// Don't query the same data more than once:197
if (JSON.stringify(this.lastQuery) === JSON.stringify(this.query)) return;
Object.assign(query, this.filter);
Object.assign(this.lastQuery, this.query);
this.offset = 0;
this.$router.replace({query: this.query});
for (let key in query) {
if (query[key] === undefined || !query[key]) {
delete query[key];
}
}
this.$router.replace({query: query});
},
searchParams() {
const params = {
count: this.pageSize,
offset: this.offset,
};
Object.assign(params, this.query);
Object.assign(params, this.filter);
Photo.search(params).then(response => {
if (this.staticFilter) {
Object.assign(params, this.staticFilter);
}
return params;
},
search() {
this.scrollDisabled = true;
// Don't query the same data more than once
if (JSON.stringify(this.lastFilter) === JSON.stringify(this.filter)) {
this.$nextTick(() => this.$emit("scrollRefresh"));
return;
}
Object.assign(this.lastFilter, this.filter);
this.offset = 0;
const params = this.searchParams();
Album.search(params).then(response => {
this.results = response.models;
this.loadMoreDisabled = (response.models.length < this.pageSize);
this.scrollDisabled = (response.models.length < this.pageSize);
if (this.loadMoreDisabled) {
this.$alert.info(this.results.length + ' photos found');
if (this.scrollDisabled) {
this.$alert.info(this.results.length + ' albums found');
} else {
this.$alert.info('More than 50 photos found');
this.$alert.info('More than 20 albums found');
this.$nextTick(() => this.$emit("scrollRefresh"));
}
});
}
},
beforeRouteLeave(to, from, next) {
next()
},
refresh() {
this.lastFilter = {};
const pageSize = this.pageSize;
this.pageSize = this.offset + pageSize;
this.search();
this.offset = this.pageSize;
this.pageSize = pageSize;
},
create() {
const name = "New Album " + Date.now();
const album = new Album({"AlbumName": name});
album.save().then(() => {
this.$alert.success(name + " created");
this.refresh();
})
},
},
created() {
window.addEventListener('resize', this.handleResize);
this.handleResize();
this.search();
},
};

View file

@ -1,4 +1,5 @@
import Photos from "pages/photos.vue";
import Albums from "pages/albums.vue";
import Places from "pages/places.vue";
import Labels from "pages/labels.vue";
import Events from "pages/events.vue";
@ -20,6 +21,12 @@ export default [
component: Photos,
meta: {area: "Photos"},
},
{
name: "Albums",
path: "/albums",
component: Albums,
meta: {area: "Albums"},
},
{
name: "Favorites",
path: "/favorites",
@ -57,12 +64,6 @@ export default [
component: Todo,
meta: {area: "Filters"},
},
{
name: "Albums",
path: "/albums",
component: Todo,
meta: {area: "Albums"},
},
{
name: "Library",
path: "/library",

114
internal/api/albums.go Normal file
View file

@ -0,0 +1,114 @@
package api
import (
"github.com/photoprism/photoprism/internal/models"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/forms"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/util"
log "github.com/sirupsen/logrus"
)
// GET /api/v1/albums
func GetAlbums(router *gin.RouterGroup, conf *config.Config) {
router.GET("/albums", func(c *gin.Context) {
var form forms.AlbumSearchForm
search := photoprism.NewSearch(conf.OriginalsPath(), conf.Db())
err := c.MustBindWith(&form, binding.Form)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": util.UcFirst(err.Error())})
return
}
result, err := search.Albums(form)
if err != nil {
c.AbortWithStatusJSON(400, gin.H{"error": util.UcFirst(err.Error())})
return
}
c.Header("x-result-count", strconv.Itoa(form.Count))
c.Header("x-result-offset", strconv.Itoa(form.Offset))
c.JSON(http.StatusOK, result)
})
}
type CreateAlbumParams struct {
AlbumName string `json:"AlbumName"`
}
// POST /api/v1/albums
func CreateAlbum(router *gin.RouterGroup, conf *config.Config) {
router.POST("/albums", func(c *gin.Context) {
var params CreateAlbumParams
if err := c.BindJSON(&params); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": util.UcFirst(err.Error())})
}
if len(params.AlbumName) == 0 {
log.Error("album name empty")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": util.UcFirst("album name empty")})
}
album := &models.Album{AlbumName: params.AlbumName}
if res := conf.Db().Create(album); res.Error != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": util.UcFirst(res.Error.Error())})
}
c.JSON(http.StatusOK, http.Response{})
})
}
// POST /api/v1/albums/:uuid/like
//
// Parameters:
// uuid: string Album UUID
func LikeAlbum(router *gin.RouterGroup, conf *config.Config) {
router.POST("/albums/:uuid/like", func(c *gin.Context) {
search := photoprism.NewSearch(conf.OriginalsPath(), conf.Db())
album, err := search.FindAlbumByUUID(c.Param("uuid"))
if err != nil {
c.AbortWithStatusJSON(404, gin.H{"error": util.UcFirst(err.Error())})
return
}
album.AlbumFavorite = true
conf.Db().Save(&album)
c.JSON(http.StatusOK, http.Response{})
})
}
// DELETE /api/v1/albums/:uuid/like
//
// Parameters:
// uuid: string Album UUID
func DislikeAlbum(router *gin.RouterGroup, conf *config.Config) {
router.DELETE("/albums/:uuid/like", func(c *gin.Context) {
search := photoprism.NewSearch(conf.OriginalsPath(), conf.Db())
album, err := search.FindAlbumByUUID(c.Param("uuid"))
if err != nil {
c.AbortWithStatusJSON(404, gin.H{"error": util.UcFirst(err.Error())})
return
}
album.AlbumFavorite = false
conf.Db().Save(&album)
c.JSON(http.StatusOK, http.Response{})
})
}

View file

@ -123,3 +123,58 @@ func LabelThumbnail(router *gin.RouterGroup, conf *config.Config) {
}
})
}
/* ********** Albums ******** */
func AlbumThumbnail(router *gin.RouterGroup, conf *config.Config) {
router.GET("/albums/:uuid/thumbnail/:type", func(c *gin.Context) {
typeName := c.Param("type")
thumbType, ok := photoprism.ThumbnailTypes[typeName]
if !ok {
log.Errorf("invalid type: %s", typeName)
c.Data(http.StatusBadRequest, "image/svg+xml", photoIconSvg)
return
}
search := photoprism.NewSearch(conf.OriginalsPath(), conf.Db())
// log.Infof("Searching for album uuid: %s", c.Param("uuid"))
file, err := search.FindAlbumThumbByUUID(c.Param("uuid"))
// log.Infof("Album thumb file: %#v", file)
if err != nil {
c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg)
// c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": util.UcFirst(err.Error())})
return
}
fileName := fmt.Sprintf("%s/%s", conf.OriginalsPath(), file.FileName)
if !util.Exists(fileName) {
log.Errorf("could not find original for thumbnail: %s", fileName)
c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore
file.FileMissing = true
conf.Db().Save(&file)
return
}
if thumbnail, err := photoprism.ThumbnailFromFile(fileName, file.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...); err == nil {
if c.Query("download") != "" {
downloadFileName := file.DownloadFileName()
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", downloadFileName))
}
c.File(thumbnail)
} else {
log.Errorf("could not create thumbnail: %s", err)
c.Data(http.StatusBadRequest, "image/svg+xml", photoIconSvg)
}
})
}

View file

@ -0,0 +1,115 @@
package forms
import (
"bytes"
"fmt"
"reflect"
"strconv"
"strings"
"time"
"unicode"
log "github.com/sirupsen/logrus"
"github.com/araddon/dateparse"
)
// Query parameters for GET /api/v1/albums
type AlbumSearchForm struct {
Query string `form:"q"`
Slug string `form:"slug"`
Name string `form:"name"`
Favorites bool `form:"favorites"`
Count int `form:"count" binding:"required"`
Offset int `form:"offset"`
Order string `form:"order"`
}
func (f *AlbumSearchForm) ParseQueryString() (result error) {
var key, value []byte
var escaped, isKeyValue bool
query := f.Query
f.Query = ""
formValues := reflect.ValueOf(f).Elem()
query = strings.TrimSpace(query) + "\n"
for _, char := range query {
if unicode.IsSpace(char) && !escaped {
if isKeyValue {
fieldName := string(bytes.Title(bytes.ToLower(key)))
field := formValues.FieldByName(fieldName)
stringValue := string(bytes.ToLower(value))
if field.CanSet() {
switch field.Interface().(type) {
case time.Time:
if timeValue, err := dateparse.ParseAny(stringValue); err != nil {
result = err
} else {
field.Set(reflect.ValueOf(timeValue))
}
case float64:
if floatValue, err := strconv.ParseFloat(stringValue, 64); err != nil {
result = err
} else {
field.SetFloat(floatValue)
}
case int, int64:
if intValue, err := strconv.Atoi(stringValue); err != nil {
result = err
} else {
field.SetInt(int64(intValue))
}
case uint, uint64:
if intValue, err := strconv.Atoi(stringValue); err != nil {
result = err
} else {
field.SetUint(uint64(intValue))
}
case string:
field.SetString(stringValue)
case bool:
if stringValue == "1" || stringValue == "true" || stringValue == "yes" {
field.SetBool(true)
} else if stringValue == "0" || stringValue == "false" || stringValue == "no" {
field.SetBool(false)
} else {
result = fmt.Errorf("not a bool value: %s", fieldName)
}
default:
result = fmt.Errorf("unsupported type: %s", fieldName)
}
} else {
result = fmt.Errorf("unknown filter: %s", fieldName)
}
} else {
f.Query = string(bytes.ToLower(key))
}
escaped = false
isKeyValue = false
key = key[:0]
value = value[:0]
} else if char == ':' {
isKeyValue = true
} else if char == '"' {
escaped = !escaped
} else if isKeyValue {
value = append(value, byte(char))
} else {
key = append(key, byte(char))
}
}
if result != nil {
log.Errorf("error while parsing album form: %s", result)
}
return result
}

View file

@ -9,12 +9,13 @@ import (
type Album struct {
Model
AlbumUUID string `gorm:"unique_index;"`
AlbumSlug string
AlbumSlug string `gorm:"unique_index;"`
AlbumName string
AlbumDescription string `gorm:"type:text;"`
AlbumNotes string `gorm:"type:text;"`
AlbumPhoto *Photo
AlbumPhotoID uint
AlbumFavorite bool
Photos []Photo `gorm:"many2many:album_photos;"`
}

View file

@ -360,3 +360,75 @@ func (s *Search) Labels(form forms.LabelSearchForm) (results []LabelSearchResult
return results, nil
}
/***************** Albums *****************/
// FindAlbumByUUID returns a Album based on the UUID.
func (s *Search) FindAlbumByUUID(albumUUID string) (album models.Album, err error) {
if err := s.db.Where("album_uuid = ?", albumUUID).First(&album).Error; err != nil {
return album, err
}
return album, nil
}
// FindAlbumThumbByUUID returns a album preview file based on the uuid.
func (s *Search) FindAlbumThumbByUUID(albumUUID string) (file models.File, err error) {
// s.db.LogMode(true)
if err := s.db.Where("files.file_primary AND files.deleted_at IS NULL").
Joins("JOIN albums ON albums.album_uuid = ?", albumUUID).
Joins("JOIN album_photos ON album_photos.album_id = albums.id AND album_photos.photo_id = files.photo_id").
First(&file).Error; err != nil {
return file, err
}
return file, nil
}
// Albums searches albums based on their name.
func (s *Search) Albums(form forms.AlbumSearchForm) (results []AlbumSearchResult, err error) {
if err := form.ParseQueryString(); err != nil {
return results, err
}
defer util.ProfileTime(time.Now(), fmt.Sprintf("search for %+v", form))
q := s.db.NewScope(nil).DB()
// q.LogMode(true)
q = q.Table("albums").
Select(`albums.*, COUNT(album_photos.album_id) AS album_count`).
Joins("LEFT JOIN album_photos ON album_photos.album_id = albums.id").
Where("albums.deleted_at IS NULL").
Group("albums.id")
if form.Query != "" {
likeString := "%" + strings.ToLower(form.Query) + "%"
q = q.Where("LOWER(albums.album_name) LIKE ?", likeString)
}
if form.Favorites {
q = q.Where("albums.album_favorite = 1")
}
switch form.Order {
case "slug":
q = q.Order("albums.album_favorite DESC, album_slug ASC")
default:
q = q.Order("albums.album_favorite DESC, album_count DESC, albums.created_at DESC")
}
if form.Count > 0 && form.Count <= 1000 {
q = q.Limit(form.Count).Offset(form.Offset)
} else {
q = q.Limit(100).Offset(0)
}
if result := q.Scan(&results); result.Error != nil {
return results, result.Error
}
return results, nil
}

View file

@ -86,3 +86,18 @@ type LabelSearchResult struct {
LabelDescription string
LabelNotes string
}
// AlbumSearchResult contains found albums
type AlbumSearchResult struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt time.Time
AlbumUUID string
AlbumSlug string
AlbumName string
AlbumCount int
AlbumFavorite bool
AlbumDescription string
AlbumNotes string
}

View file

@ -36,6 +36,12 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.BatchPhotosDelete(v1, conf)
api.BatchPhotosPrivate(v1, conf)
api.GetAlbums(v1, conf)
api.LikeAlbum(v1, conf)
api.DislikeAlbum(v1, conf)
api.AlbumThumbnail(v1, conf)
api.CreateAlbum(v1, conf)
}
// Default HTML page (client-side routing implemented via Vue.js)