UI improvements for gallery, maps and alerts

This commit is contained in:
Michael Mayer 2019-05-08 04:37:45 +02:00
parent 827e671c84
commit 0f0a1b5f12
15 changed files with 1422 additions and 1196 deletions

View file

@ -23,7 +23,7 @@
body {
background: rgb(250, 250, 250);
color: #333333;
font-family: Helvetica, Arial, sans-serif;
font-family: Roboto, sans-serif;;
}
footer {
@ -52,3 +52,14 @@ main {
#app-navigation {
z-index: 10;
}
/* Photo Gallery */
#app .pswp__caption__center {
text-align: center;
max-width: 80%;
margin: 0 auto;
font-size: 14px;
padding: 10px;
line-height: 20px;
color: #E0E0E0;
}

View file

@ -8,6 +8,7 @@ import Api from 'common/api';
import Config from 'common/config';
import Components from 'component/components';
import Alert from 'common/alert';
import Gallery from 'common/gallery';
import Session from 'common/session';
import Event from 'pubsub-js';
import Moment from 'vue-moment';
@ -22,6 +23,7 @@ const config = new Config(window.localStorage, window.appConfig);
// Set global helpers
Vue.prototype.$event = Event;
Vue.prototype.$alert = Alert;
Vue.prototype.$gallery = new Gallery;
Vue.prototype.$session = session;
Vue.prototype.$api = Api;
Vue.prototype.$config = config;

View file

@ -11,6 +11,8 @@
<router-view></router-view>
</v-content>
</v-app>
<app-gallery></app-gallery>
</div>
</template>
@ -20,7 +22,7 @@
computed: {},
methods: {
login() {
this.$refs.loginDialog.open();
// this.$refs.loginDialog.open();
},
logout() {
@ -29,7 +31,3 @@
},
};
</script>
<style lang="css">
</style>

View file

@ -74,497 +74,496 @@
</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>
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-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-icon>create_new_folder</v-icon>
</v-btn>
<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-icon>create_new_folder</v-icon>
</v-btn>
<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'"
<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 grid-list-xs fluid class="pa-0" v-if="query.view === 'details'">
<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 md4 lg3 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-img
:src="photo.getThumbnailUrl('square', 500)"
aspect-ratio="1"
v-bind:class="{ selected: photo.selected }"
style="cursor: pointer"
class="grey lighten-2"
@click="openPhoto(index)"
>
<v-layout
slot="placeholder"
fill-height
align-center
justify-center
ma-0
>
<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-progress-circular indeterminate color="grey lighten-5"></v-progress-circular>
</v-layout>
<v-container grid-list-xs fluid class="pa-0" v-if="query.view === 'details'">
<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 md4 lg3 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-img
:src="photo.getThumbnailUrl('square', 500)"
aspect-ratio="1"
v-bind:class="{ selected: photo.selected }"
style="cursor: pointer"
class="grey lighten-2"
@click="openPhoto(index)"
<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-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-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-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>
</v-hover>
</v-flex>
</v-layout>
</v-container>
<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>
</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('square', 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"
<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('square', 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
>
{{ snackbarText }}
<v-btn
class="pr-0"
color="primary"
icon
flat
@click="clearSelection()"
>
<v-icon>close</v-icon>
</v-btn>
</v-snackbar>
</v-container>
<photoswipe :images="results" ref="gallery"></photoswipe>
</div>
<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';
export default {
name: 'browse',
props: {},
data() {
const query = this.$route.query;
const order = query['order'] ? query['order'] : 'newest';
const camera = query['camera'] ? parseInt(query['camera']) : 0;
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'));
export default {
name: 'browse',
props: {},
data() {
const query = this.$route.query;
const order = query['order'] ? query['order'] : 'newest';
const camera = query['camera'] ? parseInt(query['camera']) : 0;
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'));
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'},
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'},
],
'view': view,
'loadMoreDisabled': true,
'pageSize': 60,
'offset': 0,
'lastQuery': {},
'submitTimeout': false,
'selected': [],
'dialog': false,
};
},
destroyed() {
window.removeEventListener('resize', this.handleResize)
},
methods: {
handleResize() {
this.window.width = window.innerWidth;
this.window.height = window.innerHeight;
},
clearSelection() {
'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,
};
},
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++) {
this.selected[i].selected = false;
if (this.selected[i].id === photo.id) {
this.selected.splice(i, 1);
break;
}
}
this.selected = [];
this.updateSnackbar();
},
updateSnackbar(text) {
if (!text) text = "";
this.snackbarText = text;
photo.selected = false;
} else {
this.selected.push(photo);
photo.selected = true;
}
this.snackbarVisible = this.snackbarText !== "";
},
showSnackbar() {
this.snackbarVisible = this.snackbarText !== "";
},
hideSnackbar() {
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;
},
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.refreshList();
},
clearQuery() {
this.query.q = '';
this.refreshList();
},
openPhoto(index) {
this.$refs.gallery.openPhoto(index)
},
loadMore() {
if (this.loadMoreDisabled) return;
this.loadMoreDisabled = true;
this.offset += this.pageSize;
const params = {
count: this.pageSize,
offset: this.offset,
};
Object.assign(params, this.lastQuery);
Photo.search(params).then(response => {
console.log(response);
this.results = this.results.concat(response.models);
this.loadMoreDisabled = (response.models.length < this.pageSize);
if (this.loadMoreDisabled) {
this.$alert.info('All ' + this.results.length + ' photos loaded');
}
});
},
refreshList() {
this.loadMoreDisabled = true;
// Don't query the same data more than once:197
if (JSON.stringify(this.lastQuery) === JSON.stringify(this.query)) return;
Object.assign(this.lastQuery, this.query);
this.offset = 0;
this.$router.replace({query: this.query});
const params = {
count: this.pageSize,
offset: this.offset,
};
Object.assign(params, this.query);
Photo.search(params).then(response => {
this.results = response.models;
this.loadMoreDisabled = (response.models.length < this.pageSize);
if (this.loadMoreDisabled) {
this.$alert.info(this.results.length + ' photos found');
} else {
this.$alert.info('More than 50 photos found');
}
});
}
},
beforeRouteLeave(to, from, next) {
next()
likePhoto(photo) {
photo.PhotoFavorite = !photo.PhotoFavorite;
photo.like(photo.PhotoFavorite);
},
created() {
window.addEventListener('resize', this.handleResize);
this.handleResize();
deletePhoto(photo) {
this.$alert.success('Photo deleted');
},
formChange(event) {
this.refreshList();
},
};
clearQuery() {
this.query.q = '';
this.refreshList();
},
openPhoto(index) {
this.$gallery.show(this.results, index)
},
loadMore() {
if (this.loadMoreDisabled) return;
this.loadMoreDisabled = true;
this.offset += this.pageSize;
const params = {
count: this.pageSize,
offset: this.offset,
};
Object.assign(params, this.lastQuery);
Photo.search(params).then(response => {
console.log(response);
this.results = this.results.concat(response.models);
this.loadMoreDisabled = (response.models.length < this.pageSize);
if (this.loadMoreDisabled) {
this.$alert.info('All ' + this.results.length + ' photos loaded');
}
});
},
refreshList() {
this.loadMoreDisabled = true;
// Don't query the same data more than once:197
if (JSON.stringify(this.lastQuery) === JSON.stringify(this.query)) return;
Object.assign(this.lastQuery, this.query);
this.offset = 0;
this.$router.replace({query: this.query});
const params = {
count: this.pageSize,
offset: this.offset,
};
Object.assign(params, this.query);
Photo.search(params).then(response => {
this.results = response.models;
this.loadMoreDisabled = (response.models.length < this.pageSize);
if (this.loadMoreDisabled) {
this.$alert.info(this.results.length + ' photos found');
} else {
this.$alert.info('More than 50 photos found');
}
});
}
},
beforeRouteLeave(to, from, next) {
next()
},
created() {
window.addEventListener('resize', this.handleResize);
this.handleResize();
this.refreshList();
},
};
</script>

File diff suppressed because it is too large Load diff

View file

@ -312,7 +312,7 @@
</v-btn>
</v-snackbar>
</v-container>
<photoswipe :images="results" ref="gallery"></photoswipe>
<v-dialog v-model="dialog" dark persistent max-width="600px">
<v-card dark>
<v-card-title>
@ -663,7 +663,7 @@
this.refreshList();
},
openPhoto(index) {
this.$refs.gallery.openPhoto(index)
this.$gallery.show(this.results, index)
},
loadMore() {
if (this.loadMoreDisabled) return;
@ -693,7 +693,7 @@
refreshList() {
this.loadMoreDisabled = true;
// Don't query the same data more than once:197
// Don't query the same data more than once
if (JSON.stringify(this.lastQuery) === JSON.stringify(this.query)) return;
Object.assign(this.lastQuery, this.query);

View file

@ -329,7 +329,7 @@
this.refreshList();
},
openPhoto(index) {
this.$refs.gallery.openPhoto(index)
this.$gallery.show(this.results, index)
},
loadMore() {
if (this.loadMoreDisabled) return;

View file

@ -1,24 +1,190 @@
<template>
<v-container fluid fill-height class="pa-0 map">
<l-map :zoom="zoom" :center="center">
<l-map :zoom="zoom" :center="center" :bounds="bounds" :options="options">
<l-control position="bottomright">
<v-toolbar dense floating color="grey lighten-4" v-on:dblclick.stop v-on:click.stop>
<v-btn icon v-on:click="currentPosition()">
<v-icon>my_location</v-icon>
</v-btn>
<v-spacer></v-spacer>
<v-text-field class="pt-3 pr-3"
single-line
label="Search"
prepend-inner-icon="search"
clearable
color="blue-grey"
@click:clear="clearQuery"
v-model="query.q"
@keyup.enter.native="formChange"
></v-text-field>
</v-toolbar>
</l-control>
<l-tile-layer :url="url" :attribution="attribution"></l-tile-layer>
<l-marker :lat-lng="marker"></l-marker>
<l-marker v-for="photo in photos" v-bind:data="photo"
v-bind:key="photo.index" :lat-lng="photo.location" :icon="photo.icon"
:options="photo.options" @click="openPhoto(photo.index)"></l-marker>
<l-marker v-if="position" :lat-lng="position" z-index-offset="1"></l-marker>
</l-map>
</v-container>
</template>
<script>
import * as L from "leaflet";
import Photo from "model/photo";
export default {
name: 'places',
data() {
const query = this.$route.query;
const order = query['order'] ? query['order'] : 'newest';
const q = query['q'] ? query['q'] : '';
return {
zoom: 13,
center: L.latLng(52.5259279,13.414496),
zoom: 15,
position: null,
center: L.latLng(52.5259279, 13.414496),
url: 'https://{s}.tile.osm.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors',
marker: L.latLng(52.5259279,13.414496),
options: {
icon: {
iconSize: [40, 40]
}
},
photos: [],
results: [],
query: {
order: order,
q: q,
},
offset: 0,
pageSize: 100,
lastQuery: {},
bounds: null,
minLat: null,
maxLat: null,
minLong: null,
maxLong: null,
}
},
methods: {},
methods: {
openPhoto(index) {
this.$gallery.show(this.results, index)
},
currentPosition() {
if ("geolocation" in navigator) {
const self = this;
this.$alert.success('Finding your position...');
navigator.geolocation.getCurrentPosition(function(position) {
self.center = L.latLng(position.coords.latitude, position.coords.longitude);
self.position = L.latLng(position.coords.latitude, position.coords.longitude);
});
} else {
this.$alert.warning('Geolocation is not available');
}
},
formChange() {
this.refreshList();
},
clearQuery() {
this.query.q = '';
this.refreshList();
},
resetBoundingBox() {
this.minLat = null;
this.maxLat = null;
this.minLong = null;
this.maxLong = null;
},
fitBoundingBox(lat, long) {
if(this.maxLat === null || lat > this.maxLat) {
this.maxLat = lat;
}
if(this.minLat === null || lat < this.minLat) {
this.minLat = lat;
}
if(this.maxLong === null || long > this.maxLong) {
this.maxLong = long;
}
if(this.minLong === null || long < this.minLong) {
this.minLong = long;
}
},
updateMap() {
const photos = [];
this.resetBoundingBox();
for (let i = 0, len = this.results.length; i < len; i++) {
let result = this.results[i];
if (!result.hasLocation()) continue;
this.fitBoundingBox(result.PhotoLat, result.PhotoLong);
photos.push({
id: result.getId(),
index: i,
options: {
title: result.getTitle(),
clickable: true,
},
icon: L.icon({
iconUrl: result.getThumbnailUrl('square', 50),
iconRetinaUrl: result.getThumbnailUrl('square', 100),
iconSize: [50, 50],
}),
location: L.latLng(result.PhotoLat, result.PhotoLong),
});
}
this.center = photos[photos.length - 1].location;
this.bounds = [[this.maxLat, this.minLong], [this.minLat, this.maxLong]];
this.$alert.info(photos.length + ' photos found');
this.photos = photos;
},
refreshList() {
// Don't query the same data more than once
if (JSON.stringify(this.lastQuery) === JSON.stringify(this.query)) return;
Object.assign(this.lastQuery, this.query);
this.offset = 0;
this.$router.replace({query: this.query});
const params = {
count: this.pageSize,
offset: this.offset,
};
Object.assign(params, this.query);
Photo.search(params).then(response => {
if (!response.models.length) {
this.$alert.warning('No photos found');
return;
}
this.results = response.models;
this.updateMap();
});
},
},
created() {
this.refreshList();
},
};
/* L.icon({
html: '<div style="background-image: url(/api/v1/thumbnails/square/40/cc1a022c30fff3d5603f1c3f722ec1960e3fa95e);"></div>',
className: 'leaflet-marker-photo' }), */
</script>

View file

@ -145,16 +145,18 @@
<td>28/11/2019</td>
<td>#4</td>
<td>55</td>
<td> <v-btn color="success"
@click.stop="dialog = true">
Edit
</v-btn></td>
<td>
<v-btn color="success"
@click.stop="dialog = true">
Edit
</v-btn>
</td>
</template>
</v-data-table>
<v-container fluid v-if="query.view === 'cloud'">
<v-layout justify-space-around>
<v-flex>
<v-img src="/assets/img/tagcloud.jpg" aspect-ratio="1.7" @click.stop="dialog = true"></v-img>
<v-img src="/assets/img/tagcloud.jpg" aspect-ratio="1.7" @click.stop="dialog = true"></v-img>
</v-flex>
</v-layout>
</v-container>
@ -176,7 +178,7 @@
</v-btn>
</v-snackbar>
</v-container>
<photoswipe :images="results" ref="gallery"></photoswipe>
<v-dialog v-model="dialog" dark persistent max-width="600px">
<v-card dark>
<v-card-title>
@ -231,7 +233,7 @@
<template>
<form>
13 tags selected <br>
<v-spacer></v-spacer>
<v-spacer></v-spacer>
<v-select
v-model="select"
:items="items2"
@ -295,10 +297,10 @@
{value: 'cloud', text: 'Cloud'},
{value: 'list', text: 'List'},
],
'groups': [
'groups': [
{value: 'a', text: 'Animals'},
{value: 'b', text: 'People'},
],
],
'sorting': [
{value: 'newest', text: 'Mostly used'},
{value: 'oldest', text: 'Rarely used'},
@ -404,7 +406,7 @@
this.refreshList();
},
openPhoto(index) {
this.$refs.gallery.openPhoto(index)
this.$gallery.show(this.results, index)
},
loadMore() {
if (this.loadMoreDisabled) return;

View file

@ -0,0 +1,127 @@
import PhotoSwipe from 'photoswipe'
import PhotoSwipeUI_Default from 'photoswipe/dist/photoswipe-ui-default.js'
class Gallery {
constructor() {
this.photos = [];
this.el = null;
}
photosWithSizes() {
return this.photos.map(this.createPhotoSizes);
}
createPhotoSizes(photo) {
const createPhotoSize = height => ({
src: photo.getThumbnailUrl('fit', height),
w: photo.calculateWidth(height),
h: height,
title: photo.PhotoTitle
});
return {
xxs: createPhotoSize(320),
xs: createPhotoSize(500),
s: createPhotoSize(720),
m: createPhotoSize(1280),
l: createPhotoSize(1920),
xl: createPhotoSize(2560),
xxl: createPhotoSize(3840)
}
}
getEl() {
if(!this.el) {
const elements = document.querySelectorAll('.pswp');
if(elements.length !== 1) {
let err = "There should be only one PhotoSwipe element";
console.log(err, elements);
throw err;
}
this.el = elements[0];
}
return this.el;
}
show(photos, index = 0) {
if (!Array.isArray(photos) || photos.length === 0 || index >= photos.length) {
console.log("Array passed to gallery was empty:", photos);
return
}
this.photos = photos;
const options = {
index: index,
history: false,
preload: true,
focus: true,
modal: true,
closeEl: true,
captionEl: true,
fullscreenEl: true,
zoomEl: true,
shareEl: false,
counterEl: false,
arrowEl: true,
preloaderEl: true,
};
let photosWithSizes = this.photosWithSizes();
let gallery = new PhotoSwipe(this.getEl(), PhotoSwipeUI_Default, photosWithSizes, options);
let realViewportWidth;
let realViewportHeight;
let previousSize;
let nextSize;
let firstResize = true;
let photoSrcWillChange;
gallery.listen('beforeResize', () => {
realViewportWidth = gallery.viewportSize.x * window.devicePixelRatio;
realViewportHeight = gallery.viewportSize.y * window.devicePixelRatio;
if (!previousSize) {
previousSize = 'm'
}
nextSize = this.constructor.mapViewportToImageSize(realViewportWidth, realViewportHeight, photosWithSizes[index])
if (nextSize !== previousSize) {
photoSrcWillChange = true
}
if (photoSrcWillChange && !firstResize) {
gallery.invalidateCurrItems();
}
if (firstResize) {
firstResize = false;
}
photoSrcWillChange = false;
});
gallery.listen('gettingData', function (index, item) {
item.src = item[nextSize].src;
item.w = item[nextSize].w;
item.h = item[nextSize].h;
item.title = item[nextSize].title;
previousSize = nextSize;
});
gallery.init();
}
static mapViewportToImageSize(viewportWidth, viewportHeight, item) {
for (const [sizeKey, photo] of Object.entries(item)) {
if (photo.w > viewportWidth || photo.h > viewportHeight) {
return sizeKey
}
}
}
}
export default Gallery;

View file

@ -3,13 +3,13 @@
v-model="visible"
:color="color"
:timeout="0"
:class="textColor"
top
right
>
{{ text }}
<v-btn
class="pr-0"
:class="textColor + ' pr-0'"
icon
flat
@click="close"
@ -27,6 +27,7 @@
return {
text: '',
color: 'primary',
textColor: '',
visible: false,
messages: [],
lastMessageId: 1,
@ -63,28 +64,28 @@
},
addWarningMessage: function (message) {
this.addMessage('warning', message, 4000);
this.addMessage('warning', 'black--text', message, 4000);
},
addErrorMessage: function (message) {
this.addMessage('error', message, 8000);
this.addMessage('error', 'white--text', message, 8000);
},
addSuccessMessage: function (message) {
this.addMessage('success', message, 3000);
this.addMessage('success', 'white--text', message, 3000);
},
addInfoMessage: function (message) {
this.addMessage('info', message, 3000);
this.addMessage('info', 'white--text', message, 3000);
},
addMessage: function (color, message, delay) {
addMessage: function (color, textColor, message, delay) {
if (message === this.lastMessage) return;
this.lastMessageId++;
this.lastMessage = message;
const alert = {'id': this.lastMessageId, 'color': color, 'delay': delay, 'msg': message};
const alert = {'id': this.lastMessageId, 'color': color, 'textColor': textColor, 'delay': delay, 'msg': message};
this.messages.push(alert);
@ -103,6 +104,7 @@
if(message) {
this.text = message.msg;
this.color = message.color;
this.textColor = message.textColor;
this.visible = true;
setTimeout(() => {

View file

@ -0,0 +1,83 @@
<template>
<div>
<slot v-bind:openGallery="openGallery"></slot>
<!-- Root element of PhotoSwipe. Must have class pswp. -->
<div class="pswp" tabindex="-1" role="dialog" aria-hidden="true">
<!-- Background of PhotoSwipe.
It's a separate element as animating opacity is faster than rgba(). -->
<div class="pswp__bg"></div>
<!-- Slides wrapper with overflow:hidden. -->
<div class="pswp__scroll-wrap">
<!-- Container that holds slides.
PhotoSwipe keeps only 3 of them in the DOM to save memory.
Don't modify these 3 pswp__item elements, data is added later on. -->
<div class="pswp__container">
<div class="pswp__item"></div>
<div class="pswp__item"></div>
<div class="pswp__item"></div>
</div>
<!-- Default (PhotoSwipeUI_Default) interface on top of sliding area. Can be changed. -->
<div class="pswp__ui pswp__ui--hidden">
<div class="pswp__top-bar">
<!-- Controls are self-explanatory. Order can be changed. -->
<div class="pswp__counter"></div>
<button class="pswp__button pswp__button--close" title="Close (Esc)"></button>
<button class="pswp__button pswp__button--share" title="Share"></button>
<button class="pswp__button pswp__button--fs" title="Toggle fullscreen"></button>
<button class="pswp__button pswp__button--zoom" title="Zoom in/out"></button>
<!-- Preloader demo https://codepen.io/dimsemenov/pen/yyBWoR -->
<!-- element will get class pswp__preloader--active when preloader is running -->
<div class="pswp__preloader">
<div class="pswp__preloader__icn">
<div class="pswp__preloader__cut">
<div class="pswp__preloader__donut"></div>
</div>
</div>
</div>
</div>
<div class="pswp__share-modal pswp__share-modal--hidden pswp__single-tap">
<div class="pswp__share-tooltip"></div>
</div>
<button class="pswp__button pswp__button--arrow--left" title="Previous (arrow left)">
</button>
<button class="pswp__button pswp__button--arrow--right" title="Next (arrow right)">
</button>
<div class="pswp__caption">
<div class="pswp__caption__center"></div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import 'photoswipe/dist/photoswipe.css'
import 'photoswipe/dist/default-skin/default-skin.css'
export default {
name: "photoswipe",
methods: {
openGallery: function () {
},
}
}
</script>

View file

@ -1,21 +1,22 @@
import AppAlert from './app-alert.vue';
import AppNavigation from './app-navigation.vue';
import AppLoadingBar from './app-loading-bar.vue';
import PhotoSwipe from './photoswipe.vue';
import {LMap, LMarker, LTileLayer} from 'vue2-leaflet';
import AppGallery from './app-gallery.vue';
import {LMap, LTileLayer, LMarker, LControl} from 'vue2-leaflet';
import {Icon} from 'leaflet';
const components = {};
components.install = (Vue) => {
Vue.component('app-alert', AppAlert);
Vue.component('photoswipe', PhotoSwipe);
Vue.component('app-gallery', AppGallery);
Vue.component('app-navigation', AppNavigation);
Vue.component('app-loading-bar', AppLoadingBar);
Vue.component('l-map', LMap);
Vue.component('l-tile-layer', LTileLayer);
Vue.component('l-marker', LMarker);
Vue.component('l-control', LControl);
delete Icon.Default.prototype._getIconUrl;

View file

@ -1,175 +0,0 @@
<template>
<div>
<slot v-bind:openGallery="openGallery"></slot>
<!-- Root element of PhotoSwipe. Must have class pswp. -->
<div class="pswp" tabindex="-1" role="dialog" aria-hidden="true">
<!-- Background of PhotoSwipe.
It's a separate element as animating opacity is faster than rgba(). -->
<div class="pswp__bg"></div>
<!-- Slides wrapper with overflow:hidden. -->
<div class="pswp__scroll-wrap">
<!-- Container that holds slides.
PhotoSwipe keeps only 3 of them in the DOM to save memory.
Don't modify these 3 pswp__item elements, data is added later on. -->
<div class="pswp__container">
<div class="pswp__item"></div>
<div class="pswp__item"></div>
<div class="pswp__item"></div>
</div>
<!-- Default (PhotoSwipeUI_Default) interface on top of sliding area. Can be changed. -->
<div class="pswp__ui pswp__ui--hidden">
<div class="pswp__top-bar">
<!-- Controls are self-explanatory. Order can be changed. -->
<div class="pswp__counter"></div>
<button class="pswp__button pswp__button--close" title="Close (Esc)"></button>
<button class="pswp__button pswp__button--share" title="Share"></button>
<button class="pswp__button pswp__button--fs" title="Toggle fullscreen"></button>
<button class="pswp__button pswp__button--zoom" title="Zoom in/out"></button>
<!-- Preloader demo https://codepen.io/dimsemenov/pen/yyBWoR -->
<!-- element will get class pswp__preloader--active when preloader is running -->
<div class="pswp__preloader">
<div class="pswp__preloader__icn">
<div class="pswp__preloader__cut">
<div class="pswp__preloader__donut"></div>
</div>
</div>
</div>
</div>
<div class="pswp__share-modal pswp__share-modal--hidden pswp__single-tap">
<div class="pswp__share-tooltip"></div>
</div>
<button class="pswp__button pswp__button--arrow--left" title="Previous (arrow left)">
</button>
<button class="pswp__button pswp__button--arrow--right" title="Next (arrow right)">
</button>
<div class="pswp__caption">
<div class="pswp__caption__center"></div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import PhotoSwipe from 'photoswipe'
import PhotoSwipeUI_Default from 'photoswipe/dist/photoswipe-ui-default.js'
import 'photoswipe/dist/photoswipe.css'
import 'photoswipe/dist/default-skin/default-skin.css'
export default {
name: "photoswipe",
props: {
images: Array
},
computed: {
imagesWithSizes: function() {
return this.images.map(this.createPhotoSizes);
}
},
methods: {
createPhotoSizes(photo) {
const createPhotoSize = height => ({
src: photo.getThumbnailUrl('fit', height),
w: photo.calculateWidth(height),
h: height,
title: photo.PhotoTitle
});
return {
xxs: createPhotoSize(320),
xs: createPhotoSize(500),
s: createPhotoSize(720),
m: createPhotoSize(1280),
l: createPhotoSize(1920),
xl: createPhotoSize(2560),
xxl: createPhotoSize(3840)
}
},
mapViewportToImageSize(viewportWidth, viewportHeight, item) {
for (const [sizeKey, photo] of Object.entries(item)) {
if (photo.w > viewportWidth || photo.h > viewportHeight) {
return sizeKey
}
}
},
openGallery: function () {
},
openPhoto: function (index = 0) {
if (this.$props.images.length === 0) {
return
}
const pswpElement = document.querySelectorAll('.pswp')[0];
const options = {
index
};
let gallery = new PhotoSwipe(pswpElement, PhotoSwipeUI_Default, this.imagesWithSizes, options);
let realViewportWidth;
let realViewportHeight;
let previousSize;
let nextSize;
let firstResize = true;
let imageSrcWillChange;
gallery.listen('beforeResize', () => {
realViewportWidth = gallery.viewportSize.x * window.devicePixelRatio;
realViewportHeight = gallery.viewportSize.y * window.devicePixelRatio;
if (!previousSize) {
previousSize = 'm'
}
nextSize = this.mapViewportToImageSize(realViewportWidth, realViewportHeight, this.imagesWithSizes[index])
if (nextSize !== previousSize) {
imageSrcWillChange = true
}
if (imageSrcWillChange && !firstResize) {
gallery.invalidateCurrItems();
}
if (firstResize) {
firstResize = false;
}
imageSrcWillChange = false;
});
gallery.listen('gettingData', function (index, item) {
item.src = item[nextSize].src;
item.w = item[nextSize].w;
item.h = item[nextSize].h;
item.title = item[nextSize].title;
previousSize = nextSize;
});
gallery.init();
}
}
}
</script>

View file

@ -10,6 +10,10 @@ class Photo extends Abstract {
return this.ID;
}
getTitle() {
return this.PhotoTitle;
}
getColor() {
switch (this.PhotoColor) {
case 'brown':
@ -66,6 +70,10 @@ class Photo extends Abstract {
return result.join(', ');
}
hasLocation() {
return this.PhotoLat !== 0 || this.PhotoLong !== 0;
}
getLocation() {
const location = [];