Implement first album functionality
This commit is contained in:
parent
499057d81e
commit
5f63f2999c
|
@ -84,7 +84,7 @@ class Abstract {
|
|||
}
|
||||
|
||||
static getCreateResource() {
|
||||
return this.getCollectionResource() + "/new";
|
||||
return this.getCollectionResource();
|
||||
}
|
||||
|
||||
static getCreateForm() {
|
||||
|
|
79
frontend/src/model/album.js
Normal file
79
frontend/src/model/album.js
Normal 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;
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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
114
internal/api/albums.go
Normal 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(¶ms); 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{})
|
||||
})
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
115
internal/forms/album_search.go
Normal file
115
internal/forms/album_search.go
Normal 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
|
||||
}
|
|
@ -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;"`
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue