Proof-of-concept for labels and search filters

This commit is contained in:
Michael Mayer 2019-06-09 04:37:02 +02:00
parent 7eccd2a440
commit 8642b6f664
26 changed files with 808 additions and 553 deletions

View file

@ -46,6 +46,7 @@ zip-nasnet:
build-js:
(cd frontend && env NODE_ENV=production npm run build)
build-go:
rm -f $(BINARY_NAME)
scripts/build.sh debug $(BINARY_NAME)
watch-js:
(cd frontend && env NODE_ENV=development npm run watch)

View file

@ -10906,10 +10906,10 @@
"resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz",
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw=="
},
"vue-truncate-filter": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/vue-truncate-filter/-/vue-truncate-filter-1.1.7.tgz",
"integrity": "sha512-kFmUDsDFIj5vArdK6hX84v80fTWJIMrsYF5keCluy9hMiaKGL8NyApnMbWavZeDod24YeYMVQ9jjd7MkRn3AAg=="
"vue2-filters": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/vue2-filters/-/vue2-filters-0.6.0.tgz",
"integrity": "sha512-l7GPoQW0aWBS0RBP5dI1udAlg8qcfd8zOHqFrQ29ZOKfAaLxGMyqsqTo1thvfaAs6w2egLPlllsaqBhmHzaV6w=="
},
"vue2-leaflet": {
"version": "2.1.1",

View file

@ -103,7 +103,7 @@
"vue-router": "^3.0.6",
"vue-style-loader": "^4.1.2",
"vue-template-compiler": "^2.6.10",
"vue-truncate-filter": "^1.1.7",
"vue2-filters": "^0.6.0",
"vue2-leaflet": "^2.1.1",
"vuelidate": "^0.7.4",
"vuetify": "^1.5.14",

View file

@ -12,10 +12,10 @@ import Alert from "common/alert";
import Viewer from "common/viewer";
import Session from "common/session";
import Event from "pubsub-js";
import Moment from "vue-moment";
import InfiniteScroll from "vue-infinite-scroll";
import VueTruncate from "vue-truncate-filter";
import VueMoment from "vue-moment";
import VueInfiniteScroll from "vue-infinite-scroll";
import VueFullscreen from "vue-fullscreen";
import VueFilters from "vue2-filters";
// Initialize helpers
const session = new Session(window.localStorage);
@ -48,10 +48,10 @@ Vue.use(Vuetify, {
});
// Register other VueJS plugins
Vue.use(Moment);
Vue.use(InfiniteScroll);
Vue.use(VueTruncate);
Vue.use(VueMoment);
Vue.use(VueInfiniteScroll);
Vue.use(VueFullscreen);
Vue.use(VueFilters);
Vue.use(Components);
Vue.use(Maps);
Vue.use(Router);

View file

@ -85,13 +85,23 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile to="/calendar" @click="" class="p-navigation-timeline">
<v-list-tile to="/events" @click="" class="p-navigation-events">
<v-list-tile-action>
<v-icon>date_range</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>Timeline</v-list-tile-title>
<v-list-tile-title>Events</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile to="/people" @click="" class="p-navigation-people">
<v-list-tile-action>
<v-icon>people</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>People</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
@ -112,18 +122,47 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile @click="">
<v-list-tile :to="{ name: 'Photos', query: { q: 'lat:52.459714999999996 long:13.321887700000001 dist:20' }}" :exact="true" @click="">
<v-list-tile-content>
<v-list-tile-title>Canon EOS 6D</v-list-tile-title>
<v-list-tile-title>Berlin</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile @click="">
<v-list-tile :to="{ name: 'Photos', query: { q: 'mono:true' }}" :exact="true" @click="">
<v-list-tile-content>
<v-list-tile-title>iPhone</v-list-tile-title>
<v-list-tile-title>Monochrome</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile :to="{ name: 'Photos', query: { q: 'label:cat' }}" :exact="true" @click="">
<v-list-tile-content>
<v-list-tile-title>Cats</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile :to="{ name: 'Photos', query: { q: 'label:computer' }}" :exact="true" @click="">
<v-list-tile-content>
<v-list-tile-title>Computers</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile :to="{ name: 'Photos', query: { q: 'color:magenta' }}" :exact="true" @click="">
<v-list-tile-content>
<v-list-tile-title>Magenta</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile :to="{ name: 'Photos', query: { q: 'color:red' }}" :exact="true" @click="">
<v-list-tile-content>
<v-list-tile-title>Red</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile :to="{ name: 'Photos', query: { q: 'chroma:4' }}" :exact="true" @click="">
<v-list-tile-content>
<v-list-tile-title>Vibrant</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</v-list-group>
<v-list-tile v-if="mini" to="/albums" @click="">
@ -145,25 +184,9 @@
<v-list-tile @click="">
<v-list-tile-content>
<v-list-tile-title>South Africa</v-list-tile-title>
<v-list-tile-title>Not implemented yet</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile @click="">
<v-list-tile-content>
<v-list-tile-title>Cats &amp; Dogs</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile @click="">
<v-list-tile-content>
<v-list-tile-title>Create album</v-list-tile-title>
</v-list-tile-content>
<v-list-tile-action>
<v-icon>add</v-icon>
</v-list-tile-action>
</v-list-tile>
</v-list-group>
<v-list-tile to="/import" @click="">
@ -204,9 +227,11 @@
export default {
name: "p-navigation",
data() {
let mini = (window.innerWidth < 1600);
return {
drawer: null,
mini: false,
mini: mini,
};
},
methods: {

View file

@ -1,5 +1,5 @@
<template>
<v-form ref="form" class="p-photo-search" lazy-validation @submit="filterChange" dense>
<v-form ref="form" class="p-photo-search" lazy-validation @submit.prevent="filterChange" dense>
<v-toolbar flat color="blue-grey lighten-4">
<v-text-field class="pt-3 pr-3"
single-line

View file

@ -4,6 +4,7 @@
@import url("maps.css");
@import url("viewer.css");
@import url("photos.css");
@import url("labels.css");
body {
background: rgb(250, 250, 250);

View file

@ -0,0 +1,4 @@
#photoprism .p-labels-details .p-label-like
{
left: 4px; bottom: 4px;
}

View file

@ -0,0 +1,79 @@
import Abstract from "model/abstract";
import Api from "common/api";
import moment from "moment";
class Label extends Abstract {
getEntityName() {
return this.LabelSlug;
}
getId() {
return this.LabelSlug;
}
getTitle() {
return this.LabelName;
}
getThumbnailUrl(type) {
return "/api/v1/labels/" + 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.LabelFavorite = !this.LabelFavorite;
if(this.LabelFavorite) {
return Api.post(this.getEntityResource() + "/like");
} else {
return Api.delete(this.getEntityResource() + "/like");
}
}
like() {
this.LabelFavorite = true;
return Api.post(this.getEntityResource() + "/like");
}
unlike() {
this.LabelFavorite = false;
return Api.delete(this.getEntityResource() + "/like");
}
static getCollectionResource() {
return "labels";
}
static getModelName() {
return "Label";
}
}
export default Label;

View file

@ -1,7 +1,7 @@
<template>
<div>
<v-toolbar flat color="blue-grey lighten-4">
<v-toolbar-title>Calendar</v-toolbar-title>
<v-toolbar-title>Events</v-toolbar-title>
<v-spacer></v-spacer>
</v-toolbar>

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-labels" v-infinite-scroll="loadMore" :infinite-scroll-disabled="scrollDisabled"
:infinite-scroll-distance="10" :infinite-scroll-listen-for-event="'scrollRefresh'">
<v-form ref="form" class="p-labels-search" lazy-validation @submit.prevent="search" dense>
<v-toolbar flat color="blue-grey lighten-4">
<h1 class="md-display-1">Labels</h1>
<v-spacer></v-spacer>
<v-text-field class="pt-3 pr-3"
single-line
label="Search"
@ -11,407 +11,124 @@
clearable
color="blue-grey"
@click:clear="clearQuery"
v-model="query.q"
@keyup.enter.native="formChange"
v-model="filter.q"
@keyup.enter.native="search"
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-spacer></v-spacer>
</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="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-flex xs12 sm6 md3 pa-2>
<v-select @change="formChange"
label="Groups"
flat solo hide-details
color="blue-grey"
v-model="query.group"
:items="options.groups">
</v-select>
</v-flex>
</v-layout>
</v-card-text>
</v-card>
</v-form>
<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-container grid-list-xs fluid class="pa-0 p-labels p-labels-details">
<v-card v-if="results.length === 0" class="p-labels-empty">
<v-card-title primary-title>
<div>
<h3 class="headline mb-3">No labels matched your search</h3>
<div>Try again using other terms.</div>
</div>
</v-card-title>
</v-card>
<v-layout row wrap>
<v-flex
v-for="(label, index) in results"
:key="index"
class="p-label"
xs12 sm6 md4 lg3 d-flex
>
<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
@click.stop="dialog2 = true"
color="yellow accent-4"
>
<v-icon>create_new_folder</v-icon>
</v-btn>
<v-hover>
<v-card tile slot-scope="{ hover }"
class="elevation-2 ma-2">
<v-img
:src="label.getThumbnailUrl('tile_500')"
aspect-ratio="1"
style="cursor: pointer"
class="grey lighten-2"
@click="openLabel(index)"
<v-btn
fab
dark
small
color="delete"
>
<v-icon>delete</v-icon>
</v-btn>
</v-speed-dial>
<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-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>Label</td>
<td>28/11/2019</td>
<td>#4</td>
<td>55</td>
<td>
<v-btn color="success"
@click.stop="dialog = true">
Edit
<v-btn v-if="hover || label.LabelFavorite" :flat="!hover" :ripple="false"
icon large absolute
class="p-label-like"
@click.stop.prevent="label.toggleLike()">
<v-icon v-if="label.LabelFavorite" color="white">favorite
</v-icon>
<v-icon v-else color="grey lighten-3">favorite_border</v-icon>
</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="/static/img/tagcloud.jpg" aspect-ratio="1.7" @click.stop="dialog = true"></v-img>
</v-img>
<v-card-title primary-title class="pa-3">
<div>
<h3 class="subheading mb-2">{{ label.LabelName | capitalize }}</h3>
</div>
</v-card-title>
</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>
<v-dialog v-model="dialog" dark persistent max-width="600px">
<v-card dark>
<v-card-title>
<span class="headline">Edit tag - Cat</span>
</v-card-title>
<v-card-text>
<template>
<form>
<b>Translate:</b>
<v-select
v-model="select"
:items="items"
label="Language"
></v-select>
<v-text-field
v-model="translation"
label="Translation"
></v-text-field>
<v-spacer></v-spacer>
<v-select
v-model="select"
:items="items"
label="Language"
></v-select>
<v-text-field
v-model="translation"
label="Translation"
></v-text-field>
<v-spacer></v-spacer>
<b>Add to group:</b>
<v-select
v-model="select"
:items="items2"
label="Select"
></v-select>
</form>
</template>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="success" flat @click="dialog = false">Cancel</v-btn>
<v-btn color="success" flat @click="dialog = false">apply</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="dialog2" dark persistent max-width="600px">
<v-card dark>
<v-card-title>
<span class="headline">Add tags to group</span>
</v-card-title>
<v-card-text>
<template>
<form>
13 tags selected <br>
<v-spacer></v-spacer>
<v-select
v-model="select"
:items="items2"
label="Group"
></v-select>
</form>
</template>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="success" flat @click="dialog2 = false">Cancel</v-btn>
<v-btn color="success" flat @click="dialog2 = false">apply</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import Photo from 'model/photo';
import Label from "model/label";
export default {
name: 'labels',
props: {},
name: 'p-page-labels',
props: {
staticFilter: Object
},
watch: {
'$route'() {
const query = this.$route.query;
this.filter.q = query['q'];
this.lastFilter = {};
this.search();
}
},
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'] : 'cloud';
const group = query['group'] ? query['group'] : '';
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: 'cloud', text: 'Cloud'},
{value: 'list', text: 'List'},
],
'groups': [
{value: 'a', text: 'Animals'},
{value: 'b', text: 'People'},
],
'sorting': [
{value: 'newest', text: 'Mostly used'},
{value: 'oldest', text: 'Rarely used'},
],
},
'listColumns': [
{text: 'Label', value: 'PhotoTitle'},
{text: 'Created At', value: 'TakenAt'},
{text: 'Id', value: 'LocCity'},
{text: 'Nr of photos', value: 'Nr'},
{text: 'Actions', value: 'Edit'},
],
'view': view,
'loadMoreDisabled': true,
'pageSize': 60,
'offset': 0,
'lastQuery': {},
'submitTimeout': false,
'selected': [],
'dialog': false,
'dialog2': false,
select: null,
items: [
'English',
'German',
'French',
'Spanish'
],
items2: [
'Holiday',
'Nature',
'Animals',
],
results: [],
scrollDisabled: true,
pageSize: 24,
offset: 0,
selection: this.$clipboard.selection,
settings: settings,
filter: filter,
lastFilter: {},
};
},
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)
openLabel(index) {
const label = this.results[index];
this.$router.push({name: 'Photos', query: {q: "label:" + label.LabelSlug}});
},
loadMore() {
if (this.loadMoreDisabled) return;
if (this.scrollDisabled) return;
this.loadMoreDisabled = true;
this.scrollDisabled = true;
this.offset += this.pageSize;
@ -420,59 +137,74 @@
offset: this.offset,
};
Object.assign(params, this.lastQuery);
Object.assign(params, this.lastFilter);
Photo.search(params).then(response => {
Label.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 + ' labels 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(this.lastQuery, this.query);
this.offset = 0;
this.$router.replace({query: this.query});
Object.assign(query, this.filter);
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;
this.updateQuery();
const params = this.searchParams();
Label.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 + ' labels found');
} else {
this.$alert.info('More than 50 photos found');
this.$alert.info('More than 50 labels found');
this.$nextTick(() => this.$emit("scrollRefresh"));
}
});
},
translation() {
return ''
}
},
beforeRouteLeave(to, from, next) {
next()
},
created() {
window.addEventListener('resize', this.handleResize);
this.handleResize();
this.search();
},
};

View file

@ -0,0 +1,29 @@
<template>
<div>
<v-toolbar flat color="blue-grey lighten-4">
<v-toolbar-title>Not implemented yet</v-toolbar-title>
<v-spacer></v-spacer>
</v-toolbar>
<v-container>
<p>
Issues labeled <a href="https://github.com/photoprism/photoprism/labels/help%20wanted">help wanted</a> /
<a href="https://github.com/photoprism/photoprism/labels/easy">easy</a> can be good (first)
contributions.
Our <a href="https://github.com/photoprism/photoprism/wiki">Developer Guide</a> contains all information
necessary to get you started.
</p>
</v-container>
</div>
</template>
<script>
export default {
name: 'people',
data() {
return {};
},
methods: {}
};
</script>

View file

@ -29,6 +29,9 @@
},
watch: {
'$route' () {
const query = this.$route.query;
this.filter.q = query['q'];
this.lastFilter = {};
this.search();
}
@ -66,6 +69,8 @@
return storedType;
} else if(window.innerWidth < 960) {
return 'mosaic';
} else if(window.innerWidth > 1600) {
return 'details';
}
return 'tiles';

View file

@ -23,7 +23,7 @@
<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-marker v-if="position" :lat-lng="position" :z-index-offset="100"></l-marker>
</l-map>
</v-container>
</template>
@ -163,8 +163,10 @@
this.results = results;
this.photos = photos;
this.$nextTick(() => {
this.center = photos[0].location;
this.bounds = [[this.maxLat, this.minLong], [this.minLat, this.maxLong]];
});
if (photos.length > 100) {
this.$alert.info('More than 100 photos found');

View file

@ -10,7 +10,8 @@ import Export from "pages/export.vue";
import Settings from "pages/settings.vue";
import Labels from "pages/labels.vue";
import Todo from "pages/todo.vue";
import Calendar from "pages/calendar.vue";
import Events from "pages/events.vue";
import People from "pages/people.vue";
export default [
{
@ -37,10 +38,28 @@ export default [
component: PlacesPage,
meta: {area: "Places"},
},
{
name: "Labels",
path: "/labels",
component: Labels,
meta: {area: "Labels"},
},
{
name: "Events",
path: "/events",
component: Events,
meta: {area: "Events"},
},
{
name: "People",
path: "/people",
component: People,
meta: {area: "People"},
},
{
name: "PhotosEdit",
path: "/photosEdit",
component: PhotosEdit,
component: Todo,
meta: {area: "Photos"},
},
{
@ -49,32 +68,13 @@ export default [
component: Todo,
meta: {area: "Filters"},
},
{
name: "Calendar",
path: "/calendar",
component: Calendar,
meta: {area: "Calendar"},
},
{
name: "Labels",
path: "/labels",
component: Labels,
meta: {area: "Labels"},
},
{
name: "Bookmarks",
path: "/bookmarks",
component: Todo,
meta: {area: "Bookmarks"},
},
{
name: "Albums",
path: "/albums",
component: Albums,
component: Todo,
meta: {area: "Albums"},
},
{
name: "Albums2",
path: "/albums2",
component: Albums2,
@ -83,7 +83,7 @@ export default [
{
name: "Import",
path: "/import",
component: Import,
component: Todo,
meta: {area: "Import"},
},
{
@ -99,13 +99,13 @@ export default [
{
name: "Export",
path: "/export",
component: Export,
component: Todo,
meta: {area: "Export"},
},
{
name: "Settings",
path: "/settings",
component: Settings,
component: Todo,
meta: {area: "Settings"},
},
{

View file

@ -15,13 +15,5 @@ test('Navigate', async t => {
await page.openNav();
await t
.click('a[href="/labels"]')
.expect(Selector('h1').innerText, {timeout: 9000}).contains('Labels');
await page.openNav();
await t
.click('a[href="/albums"]')
.expect(Selector('h1').innerText, {timeout: 9000}).contains('Albums');
await page.openNav();
await t
.click('a[href="/import"]')
.expect(Selector('h1').innerText, {timeout: 9000}).contains('Import');
.expect(Selector('main .p-page-labels').exists, {timeout: 5000}).ok();
});

View file

@ -10,7 +10,8 @@ export default class Page {
}
async setFilter(filter, option) {
await t
await t;
switch (filter) {
case 'view':
await t

View file

@ -21,7 +21,7 @@ test('Select photos', async t => {
await page.openNav();
await t
.click('a[href="/labels"]')
.expect(Selector('h1').innerText, {timeout: 5000}).contains('Labels');
.expect(Selector('main .p-page-labels').exists, {timeout: 5000}).ok();
await page.openNav();
await t
.click('a[href="/photos"]')

83
internal/api/labels.go Normal file
View file

@ -0,0 +1,83 @@
package api
import (
"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"
)
// GET /api/v1/labels
func GetLabels(router *gin.RouterGroup, conf *config.Config) {
router.GET("/labels", func(c *gin.Context) {
var form forms.LabelSearchForm
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.Labels(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)
})
}
// POST /api/v1/labels/:slug/like
//
// Parameters:
// slug: string Label slug name
func LikeLabel(router *gin.RouterGroup, conf *config.Config) {
router.POST("/labels/:slug/like", func(c *gin.Context) {
search := photoprism.NewSearch(conf.OriginalsPath(), conf.Db())
label, err := search.FindLabelBySlug(c.Param("slug"))
if err != nil {
c.AbortWithStatusJSON(404, gin.H{"error": util.UcFirst(err.Error())})
return
}
label.LabelFavorite = true
conf.Db().Save(&label)
c.JSON(http.StatusOK, http.Response{})
})
}
// DELETE /api/v1/labels/:slug/like
//
// Parameters:
// slug: string Label slug name
func DislikeLabel(router *gin.RouterGroup, conf *config.Config) {
router.DELETE("/labels/:slug/like", func(c *gin.Context) {
search := photoprism.NewSearch(conf.OriginalsPath(), conf.Db())
label, err := search.FindLabelBySlug(c.Param("slug"))
if err != nil {
c.AbortWithStatusJSON(404, gin.H{"error": util.UcFirst(err.Error())})
return
}
label.LabelFavorite = false
conf.Db().Save(&label)
c.JSON(http.StatusOK, http.Response{})
})
}

View file

@ -63,3 +63,62 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
}
})
}
// GET /api/v1/labels/:slug/thumbnail/:type
//
// Example: /api/v1/labels/cheetah/thumbnail/tile_500
//
// Parameters:
// slug: string Label slug name
// type: string Thumbnail type, see photoprism.ThumbnailTypes
func LabelThumbnail(router *gin.RouterGroup, conf *config.Config) {
router.GET("/labels/:slug/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(400, "image/svg+xml", photoIconSvg)
return
}
search := photoprism.NewSearch(conf.OriginalsPath(), conf.Db())
// log.Infof("Searching for label slug: %s", c.Param("slug"))
file, err := search.FindLabelThumbBySlug(c.Param("slug"))
// log.Infof("Label thumb file: %#v", file)
if err != nil {
c.AbortWithStatusJSON(404, 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(404, "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(400, "image/svg+xml", photoIconSvg)
}
})
}

View file

@ -0,0 +1,116 @@
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/labels
type LabelSearchForm struct {
Query string `form:"q"`
Slug string `form:"slug"`
Name string `form:"name"`
Priority int `form:"priority"`
Favorites bool `form:"favorites"`
Count int `form:"count" binding:"required"`
Offset int `form:"offset"`
Order string `form:"order"`
}
func (f *LabelSearchForm) 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 label form: %s", result)
}
return result
}

View file

@ -13,6 +13,7 @@ type Label struct {
LabelSlug string `gorm:"type:varchar(128);index;"`
LabelName string `gorm:"type:varchar(128);index;"`
LabelPriority int
LabelFavorite bool
LabelDescription string `gorm:"type:text;"`
LabelNotes string `gorm:"type:text;"`
LabelCategories []*Label `gorm:"many2many:categories;association_jointable_foreignkey:category_id"`

View file

@ -6,8 +6,8 @@ import (
// Photo labels are weighted by uncertainty (100 - confidence)
type PhotoLabel struct {
PhotoID uint `gorm:"primary_key;auto_increment:false"`
LabelID uint `gorm:"primary_key;auto_increment:false"`
PhotoID uint `gorm:"primary_key;auto_increment:false"`
LabelUncertainty int
LabelSource string
Photo *Photo
@ -15,7 +15,7 @@ type PhotoLabel struct {
}
func (PhotoLabel) TableName() string {
return "photo_labels"
return "photos_labels"
}
func NewPhotoLabel(photoId, labelId uint, uncertainty int, source string) *PhotoLabel {

View file

@ -25,71 +25,6 @@ type SearchCount struct {
Total int
}
// PhotoSearchResult is a found mediafile.
type PhotoSearchResult struct {
// Photo
ID uint
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt time.Time
TakenAt time.Time
TimeZone string
PhotoTitle string
PhotoDescription string
PhotoArtist string
PhotoKeywords string
PhotoColors string
PhotoColor string
PhotoCanonicalName string
PhotoLat float64
PhotoLong float64
PhotoFavorite bool
// Camera
CameraID uint
CameraModel string
CameraMake string
// Lens
LensID uint
LensModel string
LensMake string
// Country
CountryID string
CountryName string
// Location
LocationID uint
LocDisplayName string
LocName string
LocCity string
LocPostcode string
LocCounty string
LocState string
LocCountry string
LocCountryCode string
LocCategory string
LocType string
// File
FileID uint
FilePrimary bool
FileMissing bool
FileName string
FileHash string
FilePerceptualHash string
FileType string
FileMime string
FileWidth int
FileHeight int
FileOrientation int
FileAspectRatio float64
// List of matching labels (tags)
Labels string
}
// NewSearch returns a new Search type with a given path and db instance.
func NewSearch(originalsPath string, db *gorm.DB) *Search {
instance := &Search{
@ -128,8 +63,8 @@ func (s *Search) Photos(form forms.PhotoSearchForm) (results []PhotoSearchResult
Joins("JOIN lenses ON lenses.id = photos.lens_id").
Joins("LEFT JOIN countries ON countries.id = photos.country_id").
Joins("LEFT JOIN locations ON locations.id = photos.location_id").
Joins("LEFT JOIN photo_labels ON photo_labels.photo_id = photos.id").
Joins("LEFT JOIN labels ON photo_labels.label_id = labels.id").
Joins("LEFT JOIN photos_labels ON photos_labels.photo_id = photos.id").
Joins("LEFT JOIN labels ON photos_labels.label_id = labels.id").
Where("photos.deleted_at IS NULL AND files.file_missing = 0").
Group("photos.id, files.id")
@ -226,8 +161,10 @@ func (s *Search) Photos(form forms.PhotoSearchForm) (results []PhotoSearchResult
if form.Mono {
q = q.Where("files.file_chroma = 0")
} else if form.Chroma > 0 {
} else if form.Chroma > 3 {
q = q.Where("files.file_chroma > ?", form.Chroma)
} else if form.Chroma > 0 {
q = q.Where("files.file_chroma > 0 AND files.file_chroma <= ?", form.Chroma)
}
if form.Fmin > 0 {
@ -325,3 +262,101 @@ func (s *Search) FindPhotoByID(photoID uint64) (photo models.Photo, err error) {
return photo, nil
}
// FindLabelBySlug returns a Label based on the slug name.
func (s *Search) FindLabelBySlug(labelSlug string) (label models.Label, err error) {
if err := s.db.Where("label_slug = ?", labelSlug).First(&label).Error; err != nil {
return label, err
}
return label, nil
}
// FindLabelThumbBySlug returns a label preview file based on the slug name.
func (s *Search) FindLabelThumbBySlug(labelSlug 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 labels ON labels.label_slug = ?", labelSlug).
Joins("JOIN photos_labels ON photos_labels.label_id = labels.id AND photos_labels.photo_id = files.photo_id").
Order("photos_labels.label_uncertainty ASC").
First(&file).Error; err != nil {
return file, err
}
return file, nil
}
// Labels searches labels based on their name.
func (s *Search) Labels(form forms.LabelSearchForm) (results []LabelSearchResult, 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("labels").
Select(`labels.*, COUNT(photos_labels.label_id) AS label_count`).
Joins("JOIN photos_labels ON photos_labels.label_id = labels.id").
Where("labels.deleted_at IS NULL").
Group("labels.id")
if form.Query != "" {
var labelIds []uint
var categories []models.Category
var label models.Label
likeString := "%" + strings.ToLower(form.Query) + "%"
if result := s.db.First(&label, "LOWER(label_name) LIKE LOWER(?)", form.Query); result.Error != nil {
log.Infof("label \"%s\" not found", form.Query)
q = q.Where("LOWER(labels.label_name) LIKE ?", likeString)
} else {
labelIds = append(labelIds, label.ID)
s.db.Where("category_id = ?", label.ID).Find(&categories)
for _, category := range categories {
labelIds = append(labelIds, category.LabelID)
}
log.Infof("searching for label IDs: %#v", form.Query)
q = q.Where("labels.id IN (?) OR LOWER(labels.label_name) LIKE ?", labelIds, likeString)
}
}
if form.Favorites {
q = q.Where("labels.label_favorite = 1")
}
if form.Priority !=0 {
q = q.Where("labels.label_priority > ?", form.Priority)
} else {
q = q.Where("labels.label_priority >= -1")
}
switch form.Order {
case "slug":
q = q.Order("labels.label_favorite DESC, label_slug ASC")
default:
q = q.Order("labels.label_favorite DESC, labels.label_priority DESC, label_count DESC, labels.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

@ -0,0 +1,84 @@
package photoprism
import "time"
// PhotoSearchResult contains found photos and their main file plus other meta data.
type PhotoSearchResult struct {
// Photo
ID uint
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt time.Time
TakenAt time.Time
TimeZone string
PhotoTitle string
PhotoDescription string
PhotoArtist string
PhotoKeywords string
PhotoColors string
PhotoColor string
PhotoCanonicalName string
PhotoLat float64
PhotoLong float64
PhotoFavorite bool
// Camera
CameraID uint
CameraModel string
CameraMake string
// Lens
LensID uint
LensModel string
LensMake string
// Country
CountryID string
CountryName string
// Location
LocationID uint
LocDisplayName string
LocName string
LocCity string
LocPostcode string
LocCounty string
LocState string
LocCountry string
LocCountryCode string
LocCategory string
LocType string
// File
FileID uint
FilePrimary bool
FileMissing bool
FileName string
FileHash string
FilePerceptualHash string
FileType string
FileMime string
FileWidth int
FileHeight int
FileOrientation int
FileAspectRatio float64
// List of matching labels (tags)
Labels string
}
// LabelSearchResult contains found labels
type LabelSearchResult struct {
// Label
ID uint
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt time.Time
LabelSlug string
LabelName string
LabelPriority int
LabelCount int
LabelFavorite bool
LabelDescription string
LabelNotes string
}

View file

@ -18,11 +18,17 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
// JSON-REST API Version 1
v1 := router.Group("/api/v1")
{
api.GetPhotos(v1, conf)
api.GetThumbnail(v1, conf)
api.GetDownload(v1, conf)
api.GetPhotos(v1, conf)
api.LikePhoto(v1, conf)
api.DislikePhoto(v1, conf)
api.GetLabels(v1, conf)
api.LikeLabel(v1, conf)
api.DislikeLabel(v1, conf)
api.LabelThumbnail(v1, conf)
}
// Default HTML page (client-side routing implemented via Vue.js)