mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-11 16:24:11 +01:00
745 lines
20 KiB
Vue
745 lines
20 KiB
Vue
<template>
|
|
<div class="p-page p-page-faces not-selectable">
|
|
<v-form ref="form" validate-on="invalid-input" class="p-faces-search" @submit.prevent="updateQuery">
|
|
<v-toolbar density="compact" class="page-toolbar" color="secondary-light">
|
|
<v-spacer></v-spacer>
|
|
|
|
<v-btn :title="$gettext('Refresh')" icon="mdi-refresh" tabindex="2" class="action-reload" @click.stop="refresh">
|
|
</v-btn>
|
|
|
|
<v-btn
|
|
v-if="!filter.hidden"
|
|
:title="$gettext('Show hidden')"
|
|
tabindex="3"
|
|
icon="mdi-eye"
|
|
class="action-show-hidden"
|
|
@click.stop="onShowHidden"
|
|
>
|
|
</v-btn>
|
|
<v-btn
|
|
v-else
|
|
tabindex="3"
|
|
:title="$gettext('Exclude hidden')"
|
|
icon="mdi-eye-off"
|
|
class="action-exclude-hidden"
|
|
@click.stop="onExcludeHidden"
|
|
>
|
|
</v-btn>
|
|
</v-toolbar>
|
|
</v-form>
|
|
|
|
<div v-if="loading" class="p-page__loading">
|
|
<p-loading></p-loading>
|
|
</div>
|
|
<div v-else class="p-page__content">
|
|
<p-scroll
|
|
:load-more="loadMore"
|
|
:load-disabled="scrollDisabled"
|
|
:load-distance="scrollDistance"
|
|
:loading="loading"
|
|
></p-scroll>
|
|
|
|
<div v-if="results.length === 0" class="pa-3">
|
|
<v-alert color="primary" icon="mdi-check-circle-outline" class="no-results" variant="outlined">
|
|
<div class="font-weight-bold">
|
|
{{ $gettext(`No people found`) }}
|
|
</div>
|
|
<div class="mt-2">
|
|
{{ $gettext(`You may rescan your library to find additional faces.`) }}
|
|
{{ $gettext(`Recognition starts after indexing has been completed.`) }}
|
|
</div>
|
|
</v-alert>
|
|
<div class="d-flex justify-center my-8">
|
|
<v-btn color="button" rounded variant="flat" :to="{ name: 'all', query: { q: 'face:new' } }">
|
|
{{ $gettext(`Show all new faces`) }}
|
|
</v-btn>
|
|
</div>
|
|
</div>
|
|
<div v-else>
|
|
<div class="v-row search-results face-results cards-view" :class="{ 'select-results': selection.length > 0 }">
|
|
<div v-for="m in results" :key="m.ID" class="v-col-12 v-col-sm-6 v-col-md-4 v-col-lg-3 v-col-xl-2">
|
|
<div :data-id="m.ID" :class="m.classes()" class="result flex-grow-1 not-selectable">
|
|
<v-img :src="m.thumbnailUrl('tile_320')" aspect-ratio="1" class="preview" @click.stop.prevent="onView(m)">
|
|
<v-btn
|
|
:ripple="false"
|
|
class="input-hidden"
|
|
icon
|
|
variant="text"
|
|
density="comfortable"
|
|
position="absolute"
|
|
@click.stop.prevent="toggleHidden(m)"
|
|
>
|
|
<v-icon color="white" class="select-on" :title="$gettext('Show')">mdi-eye-off</v-icon>
|
|
<v-icon color="white" class="select-off" :title="$gettext('Hide')">mdi-close</v-icon>
|
|
</v-btn>
|
|
</v-img>
|
|
|
|
<v-card-actions class="meta pa-0">
|
|
<v-text-field
|
|
v-if="m.SubjUID"
|
|
v-model="m.Name"
|
|
:rules="[textRule]"
|
|
:readonly="readonly"
|
|
autocomplete="off"
|
|
hide-details
|
|
single-line
|
|
density="comfortable"
|
|
class="input-name pa-0 ma-0"
|
|
@blur="(ev) => onSetName(m, ev)"
|
|
@keyup.enter="(ev) => onSetName(m, ev)"
|
|
></v-text-field>
|
|
<v-combobox
|
|
v-else
|
|
v-model:search="m.Name"
|
|
:items="$config.values.people"
|
|
item-title="Name"
|
|
item-value="Name"
|
|
:readonly="readonly"
|
|
:menu-props="menuProps"
|
|
return-object
|
|
hide-no-data
|
|
hide-details
|
|
single-line
|
|
open-on-clear
|
|
append-icon=""
|
|
prepend-inner-icon="mdi-account-plus"
|
|
autocomplete="off"
|
|
density="comfortable"
|
|
class="input-name pa-0 ma-0 text-selectable"
|
|
@update:model-value="(person) => onSetPerson(m, person)"
|
|
@blur="(ev) => onSetName(m, ev)"
|
|
@keyup.enter="(ev) => onSetName(m, ev)"
|
|
>
|
|
</v-combobox>
|
|
</v-card-actions>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex justify-center my-8">
|
|
<v-btn color="button" rounded variant="flat" :to="{ name: 'all', query: { q: 'face:new' } }">
|
|
{{ $gettext(`Show all new faces`) }}
|
|
</v-btn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p-confirm-dialog
|
|
:visible="confirm.visible"
|
|
icon="mdi-account-plus"
|
|
:icon-size="42"
|
|
:text="confirm?.model?.Name ? $gettext('Add %{s}?', { s: confirm.model.Name }) : $gettext('Add person?')"
|
|
@close="onCancelRename"
|
|
@confirm="onConfirmRename"
|
|
></p-confirm-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import Face from "model/face";
|
|
import RestModel from "model/rest";
|
|
import { MaxItems } from "common/clipboard";
|
|
import $notify from "common/notify";
|
|
import { ClickLong, ClickShort, Input, InputInvalid } from "common/input";
|
|
import PConfirmDialog from "component/confirm/dialog.vue";
|
|
import PLoading from "component/loading.vue";
|
|
|
|
export default {
|
|
name: "PPageFaces",
|
|
components: {
|
|
PLoading,
|
|
PConfirmDialog,
|
|
},
|
|
props: {
|
|
staticFilter: {
|
|
type: Object,
|
|
default: () => {},
|
|
},
|
|
active: Boolean,
|
|
},
|
|
emits: ["updateFaceCount"],
|
|
data() {
|
|
const query = this.$route.query;
|
|
const routeName = this.$route.name;
|
|
const q = query["q"] ? query["q"] : "";
|
|
const hidden = query["hidden"] ? query["hidden"] : "";
|
|
const order = this.sortOrder();
|
|
const filter = { q, hidden, order };
|
|
const settings = {};
|
|
|
|
return {
|
|
view: "all",
|
|
config: this.$config.values,
|
|
subscriptions: [],
|
|
listen: false,
|
|
dirty: false,
|
|
results: [],
|
|
scrollDisabled: true,
|
|
scrollDistance: window.innerHeight * 2,
|
|
loading: true,
|
|
busy: false,
|
|
batchSize: 999,
|
|
offset: 0,
|
|
page: 0,
|
|
faceCount: 0,
|
|
selection: [],
|
|
settings: settings,
|
|
filter: filter,
|
|
lastFilter: {},
|
|
routeName: routeName,
|
|
titleRule: (v) => v.length <= this.$config.get("clip") || this.$gettext("Name too long"),
|
|
input: new Input(),
|
|
lastId: "",
|
|
confirm: {
|
|
visible: false,
|
|
model: new Face(),
|
|
text: this.$gettext("Add person?"),
|
|
},
|
|
menuProps: {
|
|
openOnClick: true,
|
|
openOnFocus: true,
|
|
closeOnBack: true,
|
|
closeOnContentClick: true,
|
|
persistent: false,
|
|
scrim: true,
|
|
openDelay: 0,
|
|
closeDelay: 0,
|
|
opacity: 0,
|
|
density: "compact",
|
|
maxHeight: 300,
|
|
scrollStrategy: "reposition",
|
|
},
|
|
textRule: (v) => {
|
|
if (!v || !v.length) {
|
|
return this.$gettext("Name");
|
|
}
|
|
|
|
return v.length <= this.$config.get("clip") || this.$gettext("Text too long");
|
|
},
|
|
};
|
|
},
|
|
computed: {
|
|
readonly: function () {
|
|
return this.busy || this.loading;
|
|
},
|
|
},
|
|
watch: {
|
|
$route() {
|
|
// Tab inactive?
|
|
if (!this.active) {
|
|
// Ignore event.
|
|
return;
|
|
}
|
|
|
|
const query = this.$route.query;
|
|
|
|
this.filter.q = query["q"] ? query["q"] : "";
|
|
this.filter.hidden = query["hidden"] ? query["hidden"] : "";
|
|
this.filter.order = this.sortOrder();
|
|
this.routeName = this.$route.name;
|
|
|
|
this.search();
|
|
},
|
|
},
|
|
created() {
|
|
this.search();
|
|
|
|
this.subscriptions.push(this.$event.subscribe("faces", (ev, data) => this.onUpdate(ev, data)));
|
|
this.subscriptions.push(this.$event.subscribe("touchmove.top", () => this.refresh()));
|
|
},
|
|
beforeUnmount() {
|
|
for (let i = 0; i < this.subscriptions.length; i++) {
|
|
this.$event.unsubscribe(this.subscriptions[i]);
|
|
}
|
|
},
|
|
methods: {
|
|
searchCount() {
|
|
return this.batchSize;
|
|
},
|
|
sortOrder() {
|
|
return "samples";
|
|
},
|
|
setOffset(offset) {
|
|
this.offset = offset;
|
|
},
|
|
toggleLike(ev, index) {
|
|
const inputType = this.input.eval(ev, index);
|
|
|
|
if (inputType !== ClickShort) {
|
|
return;
|
|
}
|
|
|
|
const m = this.results[index];
|
|
|
|
if (!m) {
|
|
return;
|
|
}
|
|
|
|
m.toggleLike();
|
|
},
|
|
selectRange(rangeEnd, models) {
|
|
if (!models || !models[rangeEnd] || !(models[rangeEnd] instanceof RestModel)) {
|
|
console.warn("selectRange() - invalid arguments:", rangeEnd, models);
|
|
return;
|
|
}
|
|
|
|
let rangeStart = models.findIndex((m) => m.getId() === this.lastId);
|
|
|
|
if (rangeStart === -1) {
|
|
this.toggleSelection(models[rangeEnd].getId());
|
|
return 1;
|
|
}
|
|
|
|
if (rangeStart > rangeEnd) {
|
|
const newEnd = rangeStart;
|
|
rangeStart = rangeEnd;
|
|
rangeEnd = newEnd;
|
|
}
|
|
|
|
for (let i = rangeStart; i <= rangeEnd; i++) {
|
|
this.addSelection(models[i].getId());
|
|
}
|
|
|
|
return rangeEnd - rangeStart + 1;
|
|
},
|
|
onSelect(ev, index) {
|
|
const inputType = this.input.eval(ev, index);
|
|
|
|
if (inputType !== ClickShort) {
|
|
return;
|
|
}
|
|
|
|
if (ev.shiftKey) {
|
|
this.selectRange(index, this.results);
|
|
} else {
|
|
this.toggleSelection(this.results[index].getId());
|
|
}
|
|
},
|
|
onClick(ev, index) {
|
|
const inputType = this.input.eval(ev, index);
|
|
const longClick = inputType === ClickLong;
|
|
|
|
if (inputType === InputInvalid) {
|
|
return;
|
|
}
|
|
|
|
if (longClick || this.selection.length > 0) {
|
|
if (longClick || ev.shiftKey) {
|
|
this.selectRange(index, this.results);
|
|
} else {
|
|
this.toggleSelection(this.results[index].getId());
|
|
}
|
|
} else {
|
|
this.$router.push(this.results[index].route(this.view));
|
|
}
|
|
},
|
|
onContextMenu(ev, index) {
|
|
if (this.$isMobile) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
|
|
if (this.results[index]) {
|
|
this.selectRange(index, this.results);
|
|
}
|
|
}
|
|
},
|
|
onView(model) {
|
|
if (this.loading || this.busy || !this.active) {
|
|
// Don't redirect if page is not ready or active.
|
|
return;
|
|
}
|
|
|
|
this.$router.push(model.route(this.view));
|
|
},
|
|
onUpdate(ev, data) {
|
|
if (!this.listen) {
|
|
return;
|
|
}
|
|
|
|
if (!data || !data.entities || !Array.isArray(data.entities)) {
|
|
return;
|
|
}
|
|
|
|
const type = ev.split(".")[1];
|
|
|
|
switch (type) {
|
|
case "updated":
|
|
for (let i = 0; i < data.entities.length; i++) {
|
|
const values = data.entities[i];
|
|
const model = this.results.find((m) => m.UID === values.UID);
|
|
|
|
if (model) {
|
|
for (let key in values) {
|
|
if (values.hasOwnProperty(key) && values[key] != null && typeof values[key] !== "object") {
|
|
model[key] = values[key];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
case "deleted":
|
|
this.dirty = true;
|
|
|
|
for (let i = 0; i < data.entities.length; i++) {
|
|
const uid = data.entities[i];
|
|
const index = this.results.findIndex((m) => m.UID === uid);
|
|
|
|
if (index >= 0) {
|
|
this.results.splice(index, 1);
|
|
}
|
|
|
|
this.removeSelection(uid);
|
|
}
|
|
|
|
break;
|
|
case "created":
|
|
this.dirty = true;
|
|
break;
|
|
default:
|
|
console.warn("unexpected event type", ev);
|
|
}
|
|
},
|
|
onSave(m) {
|
|
m.update();
|
|
},
|
|
onShowHidden() {
|
|
this.showHidden("yes");
|
|
},
|
|
onExcludeHidden() {
|
|
this.showHidden("");
|
|
},
|
|
showHidden(value) {
|
|
this.filter.hidden = value;
|
|
this.updateQuery();
|
|
},
|
|
clearQuery() {
|
|
this.filter.q = "";
|
|
this.updateQuery();
|
|
},
|
|
addSelection(uid) {
|
|
const pos = this.selection.indexOf(uid);
|
|
|
|
if (pos === -1) {
|
|
if (this.selection.length >= MaxItems) {
|
|
$notify.warn(this.$gettext("Can't select more items"));
|
|
return;
|
|
}
|
|
|
|
this.selection.push(uid);
|
|
this.lastId = uid;
|
|
}
|
|
},
|
|
toggleSelection(uid) {
|
|
const pos = this.selection.indexOf(uid);
|
|
|
|
if (pos !== -1) {
|
|
this.selection.splice(pos, 1);
|
|
this.lastId = "";
|
|
} else {
|
|
if (this.selection.length >= MaxItems) {
|
|
$notify.warn(this.$gettext("Can't select more items"));
|
|
return;
|
|
}
|
|
|
|
this.selection.push(uid);
|
|
this.lastId = uid;
|
|
}
|
|
},
|
|
removeSelection(uid) {
|
|
const pos = this.selection.indexOf(uid);
|
|
|
|
if (pos !== -1) {
|
|
this.selection.splice(pos, 1);
|
|
this.lastId = "";
|
|
}
|
|
},
|
|
clearSelection() {
|
|
this.selection.splice(0, this.selection.length);
|
|
this.lastId = "";
|
|
},
|
|
loadMore() {
|
|
if (this.scrollDisabled || !this.active) {
|
|
return;
|
|
}
|
|
|
|
this.scrollDisabled = true;
|
|
this.listen = false;
|
|
|
|
// Always refresh all faces for now.
|
|
this.dirty = true;
|
|
|
|
const count = this.batchSize;
|
|
const offset = 0;
|
|
|
|
const params = {
|
|
count: count,
|
|
offset: offset,
|
|
};
|
|
|
|
Object.assign(params, this.lastFilter);
|
|
|
|
if (this.staticFilter) {
|
|
Object.assign(params, this.staticFilter);
|
|
}
|
|
|
|
Face.search(params)
|
|
.then((resp) => {
|
|
this.results = this.dirty ? resp.models : this.results.concat(resp.models);
|
|
|
|
this.setFaceCount(this.results.length);
|
|
|
|
if (!this.results.length) {
|
|
this.$notify.warn(this.$gettext("No people found"));
|
|
} else if (this.results.length === 1) {
|
|
this.$notify.info(this.$gettext("One person found"));
|
|
} else {
|
|
this.$notify.info(this.$gettextInterpolate(this.$gettext("%{n} people found"), { n: this.results.length }));
|
|
}
|
|
})
|
|
.catch(() => {
|
|
this.scrollDisabled = false;
|
|
})
|
|
.finally(() => {
|
|
this.dirty = false;
|
|
this.loading = false;
|
|
this.listen = true;
|
|
});
|
|
},
|
|
updateQuery() {
|
|
if (this.loading || !this.active) {
|
|
return false;
|
|
}
|
|
|
|
this.filter.q = this.filter.q.trim();
|
|
|
|
const query = {
|
|
view: this.settings.view,
|
|
};
|
|
|
|
Object.assign(query, this.filter);
|
|
|
|
for (let key in query) {
|
|
if (query[key] === undefined || !query[key]) {
|
|
delete query[key];
|
|
}
|
|
}
|
|
|
|
if (JSON.stringify(this.$route.query) === JSON.stringify(query)) {
|
|
return false;
|
|
}
|
|
|
|
this.$router.replace({ query: query });
|
|
|
|
return true;
|
|
},
|
|
searchParams() {
|
|
const params = {
|
|
count: this.searchCount(),
|
|
offset: this.offset,
|
|
};
|
|
|
|
Object.assign(params, this.filter);
|
|
|
|
if (this.staticFilter) {
|
|
Object.assign(params, this.staticFilter);
|
|
}
|
|
|
|
return params;
|
|
},
|
|
refresh() {
|
|
if (this.loading || !this.active || !this.listen || this.busy) {
|
|
return;
|
|
}
|
|
|
|
// Make sure enough results are loaded to maintain the scroll position.
|
|
if (this.page > 2) {
|
|
this.page = this.page - 1;
|
|
} else {
|
|
this.page = 1;
|
|
}
|
|
|
|
// Flag results as dirty to force a refresh.
|
|
this.dirty = true;
|
|
|
|
// Enable infinite scrolling if it was disabled.
|
|
this.scrollDisabled = false;
|
|
|
|
this.loadMore();
|
|
},
|
|
reset() {
|
|
this.results = [];
|
|
},
|
|
search() {
|
|
this.scrollDisabled = true;
|
|
|
|
// Don't query the same data more than once.
|
|
if (JSON.stringify(this.lastFilter) === JSON.stringify(this.filter)) {
|
|
this.refresh();
|
|
return;
|
|
}
|
|
|
|
Object.assign(this.lastFilter, this.filter);
|
|
|
|
this.offset = 0;
|
|
this.page = 0;
|
|
this.loading = true;
|
|
this.listen = false;
|
|
|
|
const params = this.searchParams();
|
|
|
|
Face.search(params)
|
|
.then((resp) => {
|
|
this.offset = resp.limit;
|
|
this.results = resp.models;
|
|
|
|
this.setFaceCount(this.results.length);
|
|
|
|
if (!this.results.length) {
|
|
this.$notify.warn(this.$gettext("No people found"));
|
|
} else if (this.results.length === 1) {
|
|
this.$notify.info(this.$gettext("One person found"));
|
|
} else {
|
|
this.$notify.info(this.$gettextInterpolate(this.$gettext("%{n} people found"), { n: this.results.length }));
|
|
}
|
|
})
|
|
.catch(() => {
|
|
this.reset();
|
|
})
|
|
.finally(() => {
|
|
this.dirty = false;
|
|
this.loading = false;
|
|
this.listen = true;
|
|
});
|
|
},
|
|
onShow(model) {
|
|
if (this.busy || !model) return;
|
|
|
|
this.busy = true;
|
|
model.show().finally(() => {
|
|
this.busy = false;
|
|
this.changeFaceCount(1);
|
|
});
|
|
},
|
|
onHide(model) {
|
|
if (this.busy || !model) return;
|
|
|
|
this.busy = true;
|
|
model.hide().finally(() => {
|
|
this.busy = false;
|
|
this.changeFaceCount(-1);
|
|
});
|
|
},
|
|
toggleHidden(model) {
|
|
if (this.busy || !model) return;
|
|
|
|
this.busy = true;
|
|
|
|
model.toggleHidden().finally(() => {
|
|
this.busy = false;
|
|
|
|
if (model.Hidden) {
|
|
this.changeFaceCount(-1);
|
|
} else {
|
|
this.changeFaceCount(1);
|
|
}
|
|
});
|
|
},
|
|
onSetPerson(model, person) {
|
|
if (typeof person === "object" && model?.ID && person?.UID && person?.Name) {
|
|
model.Name = person.Name;
|
|
model.SubjUID = person.UID;
|
|
this.setName(model, person.Name);
|
|
}
|
|
|
|
return true;
|
|
},
|
|
onSetName(model, ev) {
|
|
if (this.busy || !model) {
|
|
return;
|
|
}
|
|
|
|
// If there's a pending confirmation for a different face, don't process new input
|
|
if (this.confirm.visible && this.confirm.model && this.confirm.model.ID !== model.ID) {
|
|
return;
|
|
}
|
|
|
|
const name = model?.Name;
|
|
|
|
if (!name) {
|
|
this.onCancelRename();
|
|
return;
|
|
}
|
|
|
|
this.confirm.model = model;
|
|
|
|
const people = this.$config.values?.people;
|
|
|
|
if (people) {
|
|
const found = people.find((person) => person.Name.localeCompare(name, "en", { sensitivity: "base" }) === 0);
|
|
if (found) {
|
|
model.Name = found.Name;
|
|
model.SubjUID = found.UID;
|
|
if (model.wasChanged()) {
|
|
this.setName(model, model.Name);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
model.Name = name;
|
|
model.SubjUID = "";
|
|
|
|
if (model.Name) {
|
|
if (ev && ev.key === "Enter" && !ev.isComposing && !ev.repeat) {
|
|
this.setName(model, model.Name);
|
|
} else {
|
|
this.confirm.visible = true;
|
|
}
|
|
}
|
|
},
|
|
onConfirmRename() {
|
|
if (!this.confirm?.model?.Name) {
|
|
return;
|
|
}
|
|
|
|
if (this.confirm.model.wasChanged()) {
|
|
this.setName(this.confirm.model, this.confirm?.model?.Name);
|
|
} else {
|
|
this.confirm.model = null;
|
|
this.confirm.visible = false;
|
|
}
|
|
},
|
|
onCancelRename() {
|
|
if (this.confirm && this.confirm.model) {
|
|
this.confirm.model.Name = "";
|
|
this.confirm.model.SubjUID = "";
|
|
}
|
|
this.confirm.visible = false;
|
|
},
|
|
setName(model, newName) {
|
|
if (this.busy || !model || !newName || newName.trim() === "") {
|
|
// Ignore if busy, refuse to save empty name.
|
|
return;
|
|
}
|
|
|
|
this.busy = true;
|
|
this.$notify.blockUI("busy");
|
|
|
|
return model.setName(newName).finally(() => {
|
|
this.$notify.unblockUI();
|
|
this.busy = false;
|
|
this.confirm.model = null;
|
|
this.confirm.visible = false;
|
|
this.changeFaceCount(-1);
|
|
});
|
|
},
|
|
changeFaceCount(count) {
|
|
this.faceCount = this.faceCount + count;
|
|
this.$emit("updateFaceCount", this.faceCount);
|
|
},
|
|
setFaceCount(count) {
|
|
this.faceCount = count;
|
|
this.$emit("updateFaceCount", this.faceCount);
|
|
},
|
|
},
|
|
};
|
|
</script>
|