refactor: tag frame to cluster

Signed-off-by: Varun Patil <varunpatil@ucla.edu>
pull/563/head
Varun Patil 2023-03-24 12:30:08 -07:00
parent 92781df0c1
commit 7326ee8ec0
23 changed files with 480 additions and 509 deletions

View File

@ -0,0 +1,137 @@
<template>
<div v-if="noParams" class="container" :class="{ 'icon-loading': loading }">
<TopMatter />
<EmptyContent v-if="items.length === 0 && !loading" />
<RecycleScroller
class="grid-recycler hide-scrollbar-mobile"
:class="{ empty: !items.length }"
ref="recycler"
:items="items"
:skipHover="true"
:itemSize="itemSize"
:gridItems="gridItems"
:updateInterval="100"
key-field="cluster_id"
@resize="resize"
>
<template v-slot="{ item }">
<div class="grid-item fill-block" :key="item.cluster_id">
<Cluster :data="item" @click="click(item)" :link="link" />
</div>
</template>
</RecycleScroller>
</div>
<Timeline v-else />
</template>
<script lang="ts">
import { defineComponent } from "vue";
import UserConfig from "../mixins/UserConfig";
import TopMatter from "./top-matter/TopMatter.vue";
import Cluster from "./frame/Cluster.vue";
import Timeline from "./Timeline.vue";
import EmptyContent from "./top-matter/EmptyContent.vue";
import * as dav from "../services/DavRequests";
import { ICluster } from "../types";
export default defineComponent({
name: "ClusterView",
components: {
TopMatter,
Cluster,
Timeline,
EmptyContent,
},
mixins: [UserConfig],
data: () => ({
items: [] as ICluster[],
itemSize: 200,
gridItems: 5,
loading: 0,
}),
props: {
link: {
type: Boolean,
default: true,
},
},
computed: {
noParams() {
return !this.$route.params.name && !this.$route.params.user;
},
},
mounted() {
this.routeChange(this.$route);
this.resize();
},
watch: {
async $route(to: any, from?: any) {
this.routeChange(to, from);
},
},
methods: {
async routeChange(to: any, from?: any) {
try {
this.items = [];
this.loading++;
if (to.name === "albums") {
this.items = await dav.getAlbums(3, this.config_albumListSort);
} else if (to.name === "tags") {
this.items = await dav.getTags();
} else if (to.name === "recognize" || to.name === "facerecognition") {
this.items = await dav.getFaceList(to.name);
} else if (to.name === "places") {
this.items = await dav.getPlaces();
}
} finally {
this.loading--;
}
},
click(item: ICluster) {
this.$emit("click", item);
},
resize() {
const w = (<any>this.$refs.recycler).$el.clientWidth;
this.gridItems = Math.max(Math.floor(w / 200), 3);
this.itemSize = Math.floor(w / this.gridItems);
},
},
});
</script>
<style lang="scss" scoped>
.container {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.grid-recycler {
flex: 1;
max-height: 100%;
overflow-y: scroll !important;
&.empty {
visibility: hidden;
}
}
.grid-item {
position: relative;
}
</style>

View File

@ -528,8 +528,7 @@ export default defineComponent({
selectPhoto(photo: IPhoto, val?: boolean, noUpdate?: boolean) { selectPhoto(photo: IPhoto, val?: boolean, noUpdate?: boolean) {
if ( if (
photo.flag & this.c.FLAG_PLACEHOLDER || photo.flag & this.c.FLAG_PLACEHOLDER ||
photo.flag & this.c.FLAG_IS_FOLDER || photo.flag & this.c.FLAG_IS_FOLDER
photo.flag & this.c.FLAG_IS_TAG
) { ) {
return; // ignore placeholders return; // ignore placeholders
} }

View File

@ -8,22 +8,12 @@
<TopMatter ref="topmatter" /> <TopMatter ref="topmatter" />
<!-- No content found and nothing is loading --> <!-- No content found and nothing is loading -->
<NcEmptyContent <EmptyContent v-if="loading === 0 && list.length === 0" />
title="Nothing to show here"
:description="emptyViewDescription"
v-if="loading === 0 && list.length === 0"
>
<template #icon>
<PeopleIcon v-if="routeIsPeople" />
<ArchiveIcon v-else-if="routeIsArchive" />
<ImageMultipleIcon v-else />
</template>
</NcEmptyContent>
<!-- Main recycler view for rows --> <!-- Main recycler view for rows -->
<RecycleScroller <RecycleScroller
ref="recycler" ref="recycler"
class="recycler" class="recycler hide-scrollbar"
:class="{ empty: list.length === 0 }" :class="{ empty: list.length === 0 }"
:items="list" :items="list"
:emit-update="true" :emit-update="true"
@ -83,8 +73,6 @@
> >
<Folder v-if="photo.flag & c.FLAG_IS_FOLDER" :data="photo" /> <Folder v-if="photo.flag & c.FLAG_IS_FOLDER" :data="photo" />
<Tag v-else-if="photo.flag & c.FLAG_IS_TAG" :data="photo" />
<Photo <Photo
v-else v-else
:data="photo" :data="photo"
@ -137,7 +125,6 @@ import { defineComponent } from "vue";
import axios from "@nextcloud/axios"; import axios from "@nextcloud/axios";
import { showError } from "@nextcloud/dialogs"; import { showError } from "@nextcloud/dialogs";
import { subscribe, unsubscribe } from "@nextcloud/event-bus"; import { subscribe, unsubscribe } from "@nextcloud/event-bus";
import NcEmptyContent from "@nextcloud/vue/dist/Components/NcEmptyContent";
import { getLayout } from "../services/Layout"; import { getLayout } from "../services/Layout";
import { IDay, IFolder, IHeadRow, IPhoto, IRow, IRowType } from "../types"; import { IDay, IFolder, IHeadRow, IPhoto, IRow, IRowType } from "../types";
@ -145,20 +132,19 @@ import { IDay, IFolder, IHeadRow, IPhoto, IRow, IRowType } from "../types";
import UserConfig from "../mixins/UserConfig"; import UserConfig from "../mixins/UserConfig";
import Folder from "./frame/Folder.vue"; import Folder from "./frame/Folder.vue";
import Photo from "./frame/Photo.vue"; import Photo from "./frame/Photo.vue";
import Tag from "./frame/Tag.vue";
import ScrollerManager from "./ScrollerManager.vue"; import ScrollerManager from "./ScrollerManager.vue";
import SelectionManager from "./SelectionManager.vue"; import SelectionManager from "./SelectionManager.vue";
import Viewer from "./viewer/Viewer.vue"; import Viewer from "./viewer/Viewer.vue";
import EmptyContent from "./top-matter/EmptyContent.vue";
import OnThisDay from "./top-matter/OnThisDay.vue"; import OnThisDay from "./top-matter/OnThisDay.vue";
import TopMatter from "./top-matter/TopMatter.vue"; import TopMatter from "./top-matter/TopMatter.vue";
import * as dav from "../services/DavRequests"; import * as dav from "../services/DavRequests";
import * as utils from "../services/Utils"; import * as utils from "../services/Utils";
import * as strings from "../services/strings";
import PeopleIcon from "vue-material-design-icons/AccountMultiple.vue";
import CheckCircle from "vue-material-design-icons/CheckCircle.vue"; import CheckCircle from "vue-material-design-icons/CheckCircle.vue";
import ImageMultipleIcon from "vue-material-design-icons/ImageMultiple.vue";
import ArchiveIcon from "vue-material-design-icons/PackageDown.vue";
import { API, DaysFilterType } from "../services/API"; import { API, DaysFilterType } from "../services/API";
const SCROLL_LOAD_DELAY = 100; // Delay in loading data when scrolling const SCROLL_LOAD_DELAY = 100; // Delay in loading data when scrolling
@ -170,19 +156,15 @@ export default defineComponent({
components: { components: {
Folder, Folder,
Tag,
Photo, Photo,
TopMatter, EmptyContent,
OnThisDay, OnThisDay,
TopMatter,
SelectionManager, SelectionManager,
ScrollerManager, ScrollerManager,
Viewer, Viewer,
NcEmptyContent,
CheckCircle, CheckCircle,
ArchiveIcon,
PeopleIcon,
ImageMultipleIcon,
}, },
mixins: [UserConfig], mixins: [UserConfig],
@ -279,54 +261,7 @@ export default defineComponent({
}, },
/** Get view name for dynamic top matter */ /** Get view name for dynamic top matter */
viewName(): string { viewName(): string {
switch (this.$route.name) { return strings.viewName(this.$route.name);
case "timeline":
return this.t("memories", "Your Timeline");
case "favorites":
return this.t("memories", "Favorites");
case "recognize":
case "facerecognition":
return this.t("memories", "People");
case "videos":
return this.t("memories", "Videos");
case "albums":
return this.t("memories", "Albums");
case "archive":
return this.t("memories", "Archive");
case "thisday":
return this.t("memories", "On this day");
case "tags":
return this.t("memories", "Tags");
case "places":
return this.t("memories", "Places");
default:
return "";
}
},
emptyViewDescription(): string {
switch (this.$route.name) {
case "facerecognition":
if (this.config_facerecognitionEnabled)
return this.t(
"memories",
"You will find your friends soon. Please, be patient."
);
else
return this.t(
"memories",
"Face Recognition is disabled. Enable in settings to find your friends."
);
case "timeline":
case "favorites":
case "recognize":
case "videos":
case "albums":
case "archive":
case "thisday":
case "tags":
default:
return "";
}
}, },
}, },
@ -681,7 +616,11 @@ export default defineComponent({
// Map Bounds // Map Bounds
if (this.$route.name === "map" && this.$route.query.b) { if (this.$route.name === "map" && this.$route.query.b) {
API.DAYS_FILTER(query, DaysFilterType.MAP_BOUNDS, <string>this.$route.query.b); API.DAYS_FILTER(
query,
DaysFilterType.MAP_BOUNDS,
<string>this.$route.query.b
);
} }
// Month view // Month view
@ -739,14 +678,6 @@ export default defineComponent({
let data: IDay[] = []; let data: IDay[] = [];
if (this.$route.name === "thisday") { if (this.$route.name === "thisday") {
data = await dav.getOnThisDayData(); data = await dav.getOnThisDayData();
} else if (this.$route.name === "albums" && !this.$route.params.name) {
data = await dav.getAlbumsData(3, this.config_albumListSort);
} else if (this.routeIsPeople && !this.$route.params.name) {
data = await dav.getPeopleData(this.$route.name as any);
} else if (this.$route.name === "places" && !this.$route.params.name) {
data = await dav.getPlacesData();
} else if (this.$route.name === "tags" && !this.$route.params.name) {
data = await dav.getTagsData();
} else if (dav.isSingleItem()) { } else if (dav.isSingleItem()) {
data = await dav.getSingleItemData(); data = await dav.getSingleItemData();
this.$router.replace(utils.getViewerRoute(data[0]!.detail[0])); this.$router.replace(utils.getViewerRoute(data[0]!.detail[0]));
@ -1043,9 +974,7 @@ export default defineComponent({
return { return {
width: p.w || this.rowHeight, width: p.w || this.rowHeight,
height: p.h || this.rowHeight, height: p.h || this.rowHeight,
forceSquare: Boolean( forceSquare: Boolean(p.flag & this.c.FLAG_IS_FOLDER),
(p.flag & this.c.FLAG_IS_FOLDER) | (p.flag & this.c.FLAG_IS_TAG)
),
}; };
}), }),
{ {
@ -1419,13 +1348,7 @@ export default defineComponent({
} }
} }
/** Static and dynamic top matter */ /** Dynamic top matter */
.top-matter {
padding-top: 4px;
@include phone {
padding-left: 40px;
}
}
.recycler-before { .recycler-before {
width: 100%; width: 100%;
> .text { > .text {

View File

@ -1,10 +1,10 @@
<template> <template>
<router-link <router-link
draggable="false" draggable="false"
class="tag fill-block" class="cluster fill-block"
:class="{ face, error }" :class="{ error }"
:to="target" :to="target"
@click.native="openTag(data)" @click.native="click"
> >
<div class="bbl"> <div class="bbl">
<NcCounterBubble> {{ data.count }} </NcCounterBubble> <NcCounterBubble> {{ data.count }} </NcCounterBubble>
@ -32,29 +32,30 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
import { IAlbum, ITag } from "../../types"; import { IAlbum, ICluster, IFace } from "../../types";
import { getPreviewUrl } from "../../services/FileUtils"; import { getPreviewUrl } from "../../services/FileUtils";
import { getCurrentUser } from "@nextcloud/auth"; import { getCurrentUser } from "@nextcloud/auth";
import NcCounterBubble from "@nextcloud/vue/dist/Components/NcCounterBubble"; import NcCounterBubble from "@nextcloud/vue/dist/Components/NcCounterBubble";
import { constants } from "../../services/Utils";
import { API } from "../../services/API"; import { API } from "../../services/API";
import Vue from "vue";
export default defineComponent({ export default defineComponent({
name: "Tag", name: "Cluster",
components: { components: {
NcCounterBubble, NcCounterBubble,
}, },
props: { props: {
data: { data: {
type: Object as PropType<ITag>, type: Object as PropType<ICluster>,
required: true, required: true,
}, },
noNavigate: { link: {
type: Boolean, type: Boolean,
default: false, default: true,
}, },
}, },
@ -65,15 +66,7 @@ export default defineComponent({
return getPreviewUrl(mock, true, 512); return getPreviewUrl(mock, true, 512);
} }
if (this.face) { return API.CLUSTER_PREVIEW(this.data.cluster_type, this.data.cluster_id);
return API.FACE_PREVIEW(this.faceApp, this.face.fileid);
}
if (this.place) {
return API.PLACE_PREVIEW(this.place.fileid);
}
return API.TAG_PREVIEW(this.data.name);
}, },
title() { title() {
@ -93,35 +86,28 @@ export default defineComponent({
}, },
tag() { tag() {
return !this.face && !this.place && !this.album ? this.data : null; return this.data.cluster_type === "tags" && this.data;
}, },
face() { face() {
return this.data.flag & constants.c.FLAG_IS_FACE_RECOGNIZE || return (
this.data.flag & constants.c.FLAG_IS_FACE_RECOGNITION (this.data.cluster_type === "recognize" ||
? this.data this.data.cluster_type === "facerecognition") &&
: null; (this.data as IFace)
}, );
faceApp() {
return this.data.flag & constants.c.FLAG_IS_FACE_RECOGNITION
? "facerecognition"
: "recognize";
}, },
place() { place() {
return this.data.flag & constants.c.FLAG_IS_PLACE ? this.data : null; return this.data.cluster_type === "places" && this.data;
}, },
album() { album() {
return this.data.flag & constants.c.FLAG_IS_ALBUM return this.data.cluster_type === "albums" && (this.data as IAlbum);
? <IAlbum>this.data
: null;
}, },
/** Target URL to navigate to */ /** Target URL to navigate to */
target() { target() {
if (this.noNavigate) return {}; if (!this.link) return {};
if (this.album) { if (this.album) {
const user = this.album.user; const user = this.album.user;
@ -130,13 +116,13 @@ export default defineComponent({
} }
if (this.face) { if (this.face) {
const name = this.face.name || this.face.fileid.toString(); const name = this.face.name || this.face.cluster_id.toString();
const user = this.face.user_id; const user = this.face.user_id;
return { name: this.faceApp, params: { name, user } }; return { name: this.data.cluster_type, params: { name, user } };
} }
if (this.place) { if (this.place) {
const id = this.place.fileid.toString(); const id = this.place.cluster_id;
const placeName = this.place.name || id; const placeName = this.place.name || id;
const name = `${id}-${placeName}`; const name = `${id}-${placeName}`;
return { name: "places", params: { name } }; return { name: "places", params: { name } };
@ -147,30 +133,24 @@ export default defineComponent({
error() { error() {
return ( return (
Boolean(this.data.flag & this.c.FLAG_LOAD_FAIL) || Boolean(this.data.previewError) ||
Boolean(this.album && this.album.last_added_photo <= 0) Boolean(this.album && this.album.last_added_photo <= 0)
); );
}, },
}, },
methods: { methods: {
/**
* Open tag event
* Unless noNavigate is set, the tag will be opened
*/
openTag(tag: ITag) {
this.$emit("open", tag);
},
/** Mark as loading failed */
failed() { failed() {
this.data.flag |= this.c.FLAG_LOAD_FAIL; Vue.set(this.data, "previewError", true);
},
click() {
this.$emit("click", this.data);
}, },
}, },
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.tag, .cluster,
.name, .name,
.bubble, .bubble,
img { img {
@ -178,7 +158,7 @@ img {
} }
// Get rid of color of the bubble // Get rid of color of the bubble
.tag .bbl :deep .counter-bubble__counter { .cluster .bbl :deep .counter-bubble__counter {
color: unset !important; color: unset !important;
} }
@ -201,7 +181,7 @@ img {
display: block; display: block;
} }
.tag.error > & { .cluster.error > & {
color: unset; color: unset;
} }

View File

@ -6,15 +6,14 @@
:value.sync="search" :value.sync="search"
:label="t('memories', 'Search')" :label="t('memories', 'Search')"
:placeholder="t('memories', 'Search')" :placeholder="t('memories', 'Search')"
@input="searchChanged"
> >
<Magnify :size="16" /> <Magnify :size="16" />
</NcTextField> </NcTextField>
</div> </div>
<div v-if="detail"> <div v-if="list">
<div class="photo" v-for="photo of detail" :key="photo.fileid"> <div class="photo" v-for="photo of filteredList" :key="photo.cluster_id">
<Tag :data="photo" :noNavigate="true" @open="clickFace" /> <Cluster :data="photo" :link="false" @click="clickFace" />
</div> </div>
</div> </div>
<div v-else> <div v-else>
@ -25,8 +24,8 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { IPhoto, ITag } from "../../types"; import { ICluster, IFace } from "../../types";
import Tag from "../frame/Tag.vue"; import Cluster from "../frame/Cluster.vue";
import NcTextField from "@nextcloud/vue/dist/Components/NcTextField"; import NcTextField from "@nextcloud/vue/dist/Components/NcTextField";
@ -38,7 +37,7 @@ import Magnify from "vue-material-design-icons/Magnify.vue";
export default defineComponent({ export default defineComponent({
name: "FaceList", name: "FaceList",
components: { components: {
Tag, Cluster,
NcTextField, NcTextField,
Magnify, Magnify,
}, },
@ -46,9 +45,8 @@ export default defineComponent({
data: () => ({ data: () => ({
user: "", user: "",
name: "", name: "",
fullDetail: null as ITag[] | null, list: null as ICluster[] | null,
detail: null as ITag[] | null, fuse: null as Fuse<ICluster>,
fuse: null as Fuse<ITag>,
search: "", search: "",
}), }),
@ -62,6 +60,13 @@ export default defineComponent({
this.refreshParams(); this.refreshParams();
}, },
computed: {
filteredList() {
if (!this.list || !this.search || !this.fuse) return this.list || [];
return this.fuse.search(this.search).map((r) => r.item);
},
},
methods: { methods: {
close() { close() {
this.$emit("close"); this.$emit("close");
@ -70,43 +75,22 @@ export default defineComponent({
async refreshParams() { async refreshParams() {
this.user = <string>this.$route.params.user || ""; this.user = <string>this.$route.params.user || "";
this.name = <string>this.$route.params.name || ""; this.name = <string>this.$route.params.name || "";
this.detail = null; this.list = null;
this.fullDetail = null;
this.search = ""; this.search = "";
let data = []; this.list = (await dav.getFaceList(this.$route.name as any)).filter(
let flags = this.c.FLAG_IS_TAG; (c: IFace) => {
if (this.$route.name === "recognize") { const clusterName = String(c.name || c.cluster_id);
data = await dav.getPeopleData("recognize"); return c.user_id === this.user && clusterName !== this.name;
flags |= this.c.FLAG_IS_FACE_RECOGNIZE; }
} else { );
data = await dav.getPeopleData("facerecognition");
flags |= this.c.FLAG_IS_FACE_RECOGNITION;
}
let detail = data[0].detail;
detail.forEach((photo: IPhoto) => {
photo.flag = flags;
});
detail = detail.filter((photo: ITag) => {
const pname = photo.name || photo.fileid.toString();
return photo.user_id === this.user && pname !== this.name;
});
this.detail = detail; this.fuse = new Fuse(this.list, { keys: ["name"] });
this.fullDetail = detail;
this.fuse = new Fuse(detail, { keys: ["name"] });
}, },
async clickFace(face: ITag) { async clickFace(face: IFace) {
this.$emit("select", face); this.$emit("select", face);
}, },
searchChanged() {
if (!this.detail) return;
this.detail = this.search
? this.fuse.search(this.search).map((r) => r.item)
: this.fullDetail;
},
}, },
}); });
</script> </script>

View File

@ -35,8 +35,8 @@ const NcProgressBar = () =>
import { showError } from "@nextcloud/dialogs"; import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from "@nextcloud/auth"; import { getCurrentUser } from "@nextcloud/auth";
import { IFileInfo, ITag } from "../../types"; import { IFileInfo, IFace } from "../../types";
import Tag from "../frame/Tag.vue"; import Cluster from "../frame/Cluster.vue";
import FaceList from "./FaceList.vue"; import FaceList from "./FaceList.vue";
import Modal from "./Modal.vue"; import Modal from "./Modal.vue";
@ -50,7 +50,7 @@ export default defineComponent({
NcTextField, NcTextField,
NcProgressBar, NcProgressBar,
Modal, Modal,
Tag, Cluster,
FaceList, FaceList,
}, },
@ -79,11 +79,11 @@ export default defineComponent({
this.show = true; this.show = true;
}, },
async clickFace(face: ITag) { async clickFace(face: IFace) {
const user = this.$route.params.user || ""; const user = this.$route.params.user || "";
const name = this.$route.params.name || ""; const name = this.$route.params.name || "";
const newName = face.name || face.fileid.toString(); const newName = String(face.name || face.cluster_id);
if ( if (
!confirm( !confirm(
this.t( this.t(

View File

@ -24,8 +24,8 @@ const NcTextField = () => import("@nextcloud/vue/dist/Components/NcTextField");
import { showError } from "@nextcloud/dialogs"; import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from "@nextcloud/auth"; import { getCurrentUser } from "@nextcloud/auth";
import { IPhoto, ITag } from "../../types"; import { IPhoto, IFace } from "../../types";
import Tag from "../frame/Tag.vue"; import Cluster from "../frame/Cluster.vue";
import FaceList from "./FaceList.vue"; import FaceList from "./FaceList.vue";
import Modal from "./Modal.vue"; import Modal from "./Modal.vue";
@ -38,7 +38,7 @@ export default defineComponent({
NcButton, NcButton,
NcTextField, NcTextField,
Modal, Modal,
Tag, Cluster,
FaceList, FaceList,
}, },
@ -86,11 +86,11 @@ export default defineComponent({
this.$emit("moved", list); this.$emit("moved", list);
}, },
async clickFace(face: ITag) { async clickFace(face: IFace) {
const user = this.$route.params.user || ""; const user = this.$route.params.user || "";
const name = this.$route.params.name || ""; const name = this.$route.params.name || "";
const newName = face.name || face.fileid.toString(); const newName = String(face.name || face.cluster_id);
if ( if (
!confirm( !confirm(

View File

@ -206,26 +206,3 @@ export default defineComponent({
}, },
}); });
</script> </script>
<style lang="scss" scoped>
.top-matter {
display: flex;
vertical-align: middle;
.name {
font-size: 1.3em;
font-weight: 400;
line-height: 40px;
padding-left: 3px;
flex-grow: 1;
}
.right-actions {
margin-right: 40px;
z-index: 50;
@media (max-width: 768px) {
margin-right: 10px;
}
}
}
</style>

View File

@ -1,12 +1,12 @@
<template> <template>
<div v-if="name" class="tag-top-matter"> <div class="top-matter">
<NcActions> <NcActions v-if="name">
<NcActionButton :aria-label="t('memories', 'Back')" @click="back()"> <NcActionButton :aria-label="t('memories', 'Back')" @click="back()">
{{ t("memories", "Back") }} {{ t("memories", "Back") }}
<template #icon> <BackIcon :size="20" /> </template> <template #icon> <BackIcon :size="20" /> </template>
</NcActionButton> </NcActionButton>
</NcActions> </NcActions>
<span class="name">{{ name }}</span> <span class="name">{{ name || viewname }}</span>
</div> </div>
</template> </template>
@ -15,6 +15,7 @@ import { defineComponent } from "vue";
import NcActions from "@nextcloud/vue/dist/Components/NcActions"; import NcActions from "@nextcloud/vue/dist/Components/NcActions";
import NcActionButton from "@nextcloud/vue/dist/Components/NcActionButton"; import NcActionButton from "@nextcloud/vue/dist/Components/NcActionButton";
import * as strings from "../../services/strings";
import BackIcon from "vue-material-design-icons/ArrowLeft.vue"; import BackIcon from "vue-material-design-icons/ArrowLeft.vue";
@ -26,48 +27,27 @@ export default defineComponent({
BackIcon, BackIcon,
}, },
data: () => ({ computed: {
name: "", viewname(): string {
}), return strings.viewName(this.$route.name);
watch: {
$route: function (from: any, to: any) {
this.createMatter();
}, },
},
mounted() { name(): string | null {
this.createMatter(); switch (this.$route.name) {
case "tags":
return this.$route.params.name;
case "places":
return this.$route.params.name?.split("-").slice(1).join("-");
default:
return null;
}
},
}, },
methods: { methods: {
createMatter() {
this.name = <string>this.$route.params.name || "";
if (this.$route.name === "places") {
this.name = this.name.split("-").slice(1).join("-");
}
},
back() { back() {
this.$router.push({ name: this.$route.name }); this.$router.push({ name: this.$route.name });
}, },
}, },
}); });
</script> </script>
<style lang="scss" scoped>
.tag-top-matter {
.name {
font-size: 1.3em;
font-weight: 400;
line-height: 42px;
display: inline-block;
vertical-align: top;
}
button {
display: inline-block;
}
}
</style>

View File

@ -0,0 +1,53 @@
<template>
<NcEmptyContent
:title="t('memories', 'Nothing to show here')"
:description="emptyViewDescription"
>
<template #icon>
<PeopleIcon v-if="routeIsPeople" />
<ArchiveIcon v-else-if="routeIsArchive" />
<ImageMultipleIcon v-else />
</template>
</NcEmptyContent>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import NcEmptyContent from "@nextcloud/vue/dist/Components/NcEmptyContent";
import PeopleIcon from "vue-material-design-icons/AccountMultiple.vue";
import ImageMultipleIcon from "vue-material-design-icons/ImageMultiple.vue";
import ArchiveIcon from "vue-material-design-icons/PackageDown.vue";
import * as strings from "../../services/strings";
export default defineComponent({
name: "EmptyContent",
components: {
NcEmptyContent,
PeopleIcon,
ArchiveIcon,
ImageMultipleIcon,
},
computed: {
emptyViewDescription(): string {
return strings.emptyDescription(this.$route.name);
},
routeIsPeople(): boolean {
return (
this.$route.name === "recognize" ||
this.$route.name === "facerecognition"
);
},
routeIsArchive(): boolean {
return this.$route.name === "archive";
},
},
});
</script>

View File

@ -119,26 +119,3 @@ export default defineComponent({
}, },
}); });
</script> </script>
<style lang="scss" scoped>
.face-top-matter {
display: flex;
vertical-align: middle;
.name {
font-size: 1.3em;
font-weight: 400;
line-height: 40px;
padding-left: 3px;
flex-grow: 1;
}
.right-actions {
margin-right: 40px;
z-index: 50;
@media (max-width: 768px) {
margin-right: 10px;
}
}
}
</style>

View File

@ -131,23 +131,8 @@ export default defineComponent({
<style lang="scss" scoped> <style lang="scss" scoped>
.top-matter { .top-matter {
display: flex;
vertical-align: middle;
.breadcrumb { .breadcrumb {
min-width: 0; min-width: 0;
} }
.right-actions {
margin-right: 40px;
z-index: 50;
@media (max-width: 768px) {
margin-right: 10px;
}
:deep span {
cursor: pointer;
}
}
} }
</style> </style>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="top-matter" v-if="type"> <div class="top-matter-container" v-if="type">
<FolderTopMatter v-if="type === 1" /> <FolderTopMatter v-if="type === 1" />
<TagTopMatter v-else-if="type === 2" /> <ClusterTopMatter v-else-if="type === 2" />
<FaceTopMatter v-else-if="type === 3" /> <FaceTopMatter v-else-if="type === 3" />
<AlbumTopMatter v-else-if="type === 4" /> <AlbumTopMatter v-else-if="type === 4" />
</div> </div>
@ -11,7 +11,7 @@
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import FolderTopMatter from "./FolderTopMatter.vue"; import FolderTopMatter from "./FolderTopMatter.vue";
import TagTopMatter from "./TagTopMatter.vue"; import ClusterTopMatter from "./ClusterTopMatter.vue";
import FaceTopMatter from "./FaceTopMatter.vue"; import FaceTopMatter from "./FaceTopMatter.vue";
import AlbumTopMatter from "./AlbumTopMatter.vue"; import AlbumTopMatter from "./AlbumTopMatter.vue";
@ -21,7 +21,7 @@ export default defineComponent({
name: "TopMatter", name: "TopMatter",
components: { components: {
FolderTopMatter, FolderTopMatter,
TagTopMatter, ClusterTopMatter,
FaceTopMatter, FaceTopMatter,
AlbumTopMatter, AlbumTopMatter,
}, },
@ -47,21 +47,16 @@ export default defineComponent({
switch (this.$route.name) { switch (this.$route.name) {
case "folders": case "folders":
return TopMatterType.FOLDER; return TopMatterType.FOLDER;
case "albums":
return TopMatterType.ALBUM;
case "tags": case "tags":
return this.$route.params.name case "places":
? TopMatterType.TAG return TopMatterType.CLUSTER;
: TopMatterType.NONE;
case "recognize": case "recognize":
case "facerecognition": case "facerecognition":
return this.$route.params.name return this.$route.params.name
? TopMatterType.FACE ? TopMatterType.FACE
: TopMatterType.NONE; : TopMatterType.CLUSTER;
case "albums":
return TopMatterType.ALBUM;
case "places":
return this.$route.params.name
? TopMatterType.TAG
: TopMatterType.NONE;
default: default:
return TopMatterType.NONE; return TopMatterType.NONE;
} }
@ -70,3 +65,46 @@ export default defineComponent({
}, },
}); });
</script> </script>
<style lang="scss" scoped>
.top-matter-container {
padding-top: 4px;
@media (max-width: 768px) {
padding-left: 40px;
}
> div {
display: flex;
vertical-align: middle;
}
:deep .name {
font-size: 1.3em;
font-weight: 400;
line-height: 42px;
vertical-align: top;
flex-grow: 1;
padding-left: 10px;
}
:deep button + .name {
padding-left: 0;
}
:deep .right-actions {
margin-right: 40px;
z-index: 50;
@media (max-width: 768px) {
margin-right: 10px;
}
span {
cursor: pointer;
}
}
:deep button {
display: inline-block;
}
}
</style>

View File

@ -94,13 +94,21 @@ body.has-viewer header {
} }
// Hide scrollbar // Hide scrollbar
.recycler::-webkit-scrollbar { @mixin hide-scrollbar {
display: none;
width: 0 !important;
}
.recycler {
scrollbar-width: none; scrollbar-width: none;
-ms-overflow-style: none; -ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
width: 0 !important;
}
}
.hide-scrollbar {
@include hide-scrollbar;
}
.hide-scrollbar-mobile {
@media (max-width: 768px) {
@include hide-scrollbar;
}
} }
// Make metadata tab scrollbar thin // Make metadata tab scrollbar thin

View File

@ -4,6 +4,7 @@ import Router from "vue-router";
import Vue from "vue"; import Vue from "vue";
import Timeline from "./components/Timeline.vue"; import Timeline from "./components/Timeline.vue";
import SplitTimeline from "./components/SplitTimeline.vue"; import SplitTimeline from "./components/SplitTimeline.vue";
import ClusterView from "./components/ClusterView.vue";
Vue.use(Router); Vue.use(Router);
@ -52,7 +53,7 @@ export default new Router({
{ {
path: "/albums/:user?/:name?", path: "/albums/:user?/:name?",
component: Timeline, component: ClusterView,
name: "albums", name: "albums",
props: (route) => ({ props: (route) => ({
rootTitle: t("memories", "Albums"), rootTitle: t("memories", "Albums"),
@ -79,7 +80,7 @@ export default new Router({
{ {
path: "/recognize/:user?/:name?", path: "/recognize/:user?/:name?",
component: Timeline, component: ClusterView,
name: "recognize", name: "recognize",
props: (route) => ({ props: (route) => ({
rootTitle: t("memories", "People"), rootTitle: t("memories", "People"),
@ -88,7 +89,7 @@ export default new Router({
{ {
path: "/facerecognition/:user?/:name?", path: "/facerecognition/:user?/:name?",
component: Timeline, component: ClusterView,
name: "facerecognition", name: "facerecognition",
props: (route) => ({ props: (route) => ({
rootTitle: t("memories", "People"), rootTitle: t("memories", "People"),
@ -97,7 +98,7 @@ export default new Router({
{ {
path: "/places/:name*", path: "/places/:name*",
component: Timeline, component: ClusterView,
name: "places", name: "places",
props: (route) => ({ props: (route) => ({
rootTitle: t("memories", "Places"), rootTitle: t("memories", "Places"),
@ -106,7 +107,7 @@ export default new Router({
{ {
path: "/tags/:name*", path: "/tags/:name*",
component: Timeline, component: ClusterView,
name: "tags", name: "tags",
props: (route) => ({ props: (route) => ({
rootTitle: t("memories", "Tags"), rootTitle: t("memories", "Tags"),

View File

@ -1,4 +1,5 @@
import { generateUrl } from "@nextcloud/router"; import { generateUrl } from "@nextcloud/router";
import { ClusterTypes } from "../types";
const BASE = "/apps/memories/api"; const BASE = "/apps/memories/api";
@ -65,7 +66,7 @@ export class API {
return tok(gen(`${BASE}/days/{id}`, { id })); return tok(gen(`${BASE}/days/{id}`, { id }));
} }
static DAYS_FILTER(query: any, filter: DaysFilterType, value: string = '1') { static DAYS_FILTER(query: any, filter: DaysFilterType, value: string = "1") {
query[filter] = value; query[filter] = value;
} }
@ -74,25 +75,20 @@ export class API {
} }
static ALBUM_DOWNLOAD(user: string, name: string) { static ALBUM_DOWNLOAD(user: string, name: string) {
return gen(`${BASE}/clusters/albums/download?name={user}/{name}`, { user, name }); return gen(`${BASE}/clusters/albums/download?name={user}/{name}`, {
user,
name,
});
} }
static PLACE_LIST() { static PLACE_LIST() {
return gen(`${BASE}/clusters/places`); return gen(`${BASE}/clusters/places`);
} }
static PLACE_PREVIEW(place: number | string) {
return gen(`${BASE}/clusters/places/preview/{place}`, { place });
}
static TAG_LIST() { static TAG_LIST() {
return gen(`${BASE}/clusters/tags`); return gen(`${BASE}/clusters/tags`);
} }
static TAG_PREVIEW(tag: string) {
return gen(`${BASE}/clusters/tags/preview/{tag}`, { tag });
}
static TAG_SET(fileid: string | number) { static TAG_SET(fileid: string | number) {
return gen(`${BASE}/tags/set/{fileid}`, { fileid }); return gen(`${BASE}/tags/set/{fileid}`, { fileid });
} }
@ -101,11 +97,8 @@ export class API {
return gen(`${BASE}/clusters/${app}`); return gen(`${BASE}/clusters/${app}`);
} }
static FACE_PREVIEW( static CLUSTER_PREVIEW(backend: ClusterTypes, name: string | number) {
app: "recognize" | "facerecognition", return gen(`${BASE}/clusters/${backend}/preview/{name}`, { name });
face: string | number
) {
return gen(`${BASE}/clusters/${app}/preview/{face}`, { face });
} }
static ARCHIVE(fileid: number) { static ARCHIVE(fileid: number) {

View File

@ -212,27 +212,6 @@ export function convertFlags(photo: IPhoto) {
photo.flag |= constants.c.FLAG_IS_FOLDER; photo.flag |= constants.c.FLAG_IS_FOLDER;
delete photo.isfolder; delete photo.isfolder;
} }
if (photo.isface) {
const app = photo.isface;
if (app === "recognize") {
photo.flag |= constants.c.FLAG_IS_FACE_RECOGNIZE;
} else if (app === "facerecognition") {
photo.flag |= constants.c.FLAG_IS_FACE_RECOGNITION;
}
delete photo.isface;
}
if (photo.isplace) {
photo.flag |= constants.c.FLAG_IS_PLACE;
delete photo.isplace;
}
if (photo.istag) {
photo.flag |= constants.c.FLAG_IS_TAG;
delete photo.istag;
}
if (photo.isalbum) {
photo.flag |= constants.c.FLAG_IS_ALBUM;
delete photo.isalbum;
}
} }
/** /**
@ -325,13 +304,8 @@ export const constants = {
FLAG_IS_VIDEO: 1 << 2, FLAG_IS_VIDEO: 1 << 2,
FLAG_IS_FAVORITE: 1 << 3, FLAG_IS_FAVORITE: 1 << 3,
FLAG_IS_FOLDER: 1 << 4, FLAG_IS_FOLDER: 1 << 4,
FLAG_IS_ALBUM: 1 << 5, FLAG_SELECTED: 1 << 5,
FLAG_IS_FACE_RECOGNIZE: 1 << 6, FLAG_LEAVING: 1 << 6,
FLAG_IS_FACE_RECOGNITION: 1 << 7,
FLAG_IS_PLACE: 1 << 8,
FLAG_IS_TAG: 1 << 9,
FLAG_SELECTED: 1 << 10,
FLAG_LEAVING: 1 << 11,
}, },
TagDayID: TagDayID, TagDayID: TagDayID,

View File

@ -1,9 +1,8 @@
import * as base from "./base"; import * as base from "./base";
import { getCurrentUser } from "@nextcloud/auth"; import { getCurrentUser } from "@nextcloud/auth";
import { showError } from "@nextcloud/dialogs"; import { showError } from "@nextcloud/dialogs";
import { translate as t, translatePlural as n } from "@nextcloud/l10n"; import { translate as t } from "@nextcloud/l10n";
import { IAlbum, IDay, IFileInfo, IPhoto, ITag } from "../../types"; import { IAlbum, IFileInfo, IPhoto } from "../../types";
import { constants } from "../Utils";
import { API } from "../API"; import { API } from "../API";
import axios from "@nextcloud/axios"; import axios from "@nextcloud/axios";
import client from "../DavClient"; import client from "../DavClient";
@ -22,21 +21,12 @@ export function getAlbumPath(user: string, name: string) {
} }
/** /**
* Get list of albums and convert to Days response * Get list of albums.
* @param type Type of albums to get; 1 = personal, 2 = shared, 3 = all * @param type Type of albums to get; 1 = personal, 2 = shared, 3 = all
* @param sortOrder Sort order; 1 = by date, 2 = by name * @param sortOrder Sort order; 1 = by date, 2 = by name
*/ */
export async function getAlbumsData( export async function getAlbums(type: 1 | 2 | 3, sortOrder: 1 | 2) {
type: 1 | 2 | 3, const data = (await axios.get<IAlbum[]>(API.ALBUM_LIST(type))).data;
sortOrder: 1 | 2
): Promise<IDay[]> {
let data: IAlbum[] = [];
try {
const res = await axios.get<typeof data>(API.ALBUM_LIST(type));
data = res.data;
} catch (e) {
throw e;
}
// Response is already sorted by date, sort otherwise // Response is already sorted by date, sort otherwise
if (sortOrder === 2) { if (sortOrder === 2) {
@ -45,23 +35,7 @@ export async function getAlbumsData(
); );
} }
// Convert to days response return data;
return [
{
dayid: constants.TagDayID.ALBUMS,
count: data.length,
detail: data.map(
(album) =>
({
...album,
fileid: album.album_id,
flag: constants.c.FLAG_IS_TAG & constants.c.FLAG_IS_ALBUM,
istag: true,
isalbum: true,
} as ITag)
),
},
];
} }
/** /**

View File

@ -2,47 +2,13 @@ import axios from "@nextcloud/axios";
import { showError } from "@nextcloud/dialogs"; import { showError } from "@nextcloud/dialogs";
import { translate as t } from "@nextcloud/l10n"; import { translate as t } from "@nextcloud/l10n";
import { generateUrl } from "@nextcloud/router"; import { generateUrl } from "@nextcloud/router";
import { IDay, IPhoto } from "../../types"; import { IFace, IPhoto } from "../../types";
import { API } from "../API"; import { API } from "../API";
import { constants } from "../Utils";
import client from "../DavClient"; import client from "../DavClient";
import * as base from "./base"; import * as base from "./base";
/** export async function getFaceList(app: "recognize" | "facerecognition") {
* Get list of tags and convert to Days response return (await axios.get<IFace[]>(API.FACE_LIST(app))).data;
*/
export async function getPeopleData(
app: "recognize" | "facerecognition"
): Promise<IDay[]> {
// Query for photos
let data: {
id: number;
count: number;
name: string;
}[] = [];
try {
const res = await axios.get<typeof data>(API.FACE_LIST(app));
data = res.data;
} catch (e) {
throw e;
}
// Convert to days response
return [
{
dayid: constants.TagDayID.FACES,
count: data.length,
detail: data.map(
(face) =>
({
...face,
fileid: face.id,
istag: true,
isface: app,
} as any)
),
},
];
} }
export async function updatePeopleFaceRecognition( export async function updatePeopleFaceRecognition(

View File

@ -1,41 +1,7 @@
import { IDay, IPhoto, ITag } from "../../types"; import { ICluster } from "../../types";
import { constants } from "../Utils";
import { API } from "../API"; import { API } from "../API";
import axios from "@nextcloud/axios"; import axios from "@nextcloud/axios";
/** export async function getPlaces() {
* Get list of tags and convert to Days response return (await axios.get<ICluster[]>(API.PLACE_LIST())).data;
*/
export async function getPlacesData(): Promise<IDay[]> {
// Query for photos
let data: {
osm_id: number;
count: number;
name: string;
}[] = [];
try {
const res = await axios.get<typeof data>(API.PLACE_LIST());
data = res.data;
} catch (e) {
throw e;
}
// Convert to days response
return [
{
dayid: constants.TagDayID.TAGS,
count: data.length,
detail: data.map(
(tag) =>
({
...tag,
id: tag.osm_id,
fileid: tag.osm_id,
flag: constants.c.FLAG_IS_TAG,
istag: true,
isplace: true,
} as ITag)
),
},
];
} }

View File

@ -1,39 +1,10 @@
import { IDay, IPhoto, ITag } from "../../types"; import { ICluster } from "../../types";
import { constants, hashCode } from "../Utils";
import { API } from "../API"; import { API } from "../API";
import axios from "@nextcloud/axios"; import axios from "@nextcloud/axios";
/** /**
* Get list of tags and convert to Days response * Get list of tags.
*/ */
export async function getTagsData(): Promise<IDay[]> { export async function getTags() {
// Query for photos return (await axios.get<ICluster[]>(API.TAG_LIST())).data;
let data: {
id: number;
count: number;
name: string;
}[] = [];
try {
const res = await axios.get<typeof data>(API.TAG_LIST());
data = res.data;
} catch (e) {
throw e;
}
// Convert to days response
return [
{
dayid: constants.TagDayID.TAGS,
count: data.length,
detail: data.map(
(tag) =>
({
...tag,
fileid: hashCode(tag.name),
flag: constants.c.FLAG_IS_TAG,
istag: true,
} as ITag)
),
},
];
} }

View File

@ -0,0 +1,70 @@
import { loadState } from "@nextcloud/initial-state";
import { translate as t } from "@nextcloud/l10n";
const config_facerecognitionEnabled = Boolean(
loadState("memories", "facerecognitionEnabled", <string>"")
);
export function emptyDescription(routeName: string): string {
switch (routeName) {
case "timeline":
return t(
"memories",
"Upload some photos and make sure the timeline path is configured"
);
case "favorites":
return t("memories", "Mark photos as favorite to find them easily");
case "thisday":
return t("memories", "Memories from past years will appear here");
case "facerecognition":
return config_facerecognitionEnabled
? t("memories", "You will find your friends soon. Please be patient")
: t(
"memories",
"Face Recognition is disabled. Enable in settings to find your friends"
);
case "videos":
return t("memories", "Your videos will appear here");
case "albums":
return t("memories", "Create an album to get started");
case "archive":
return t(
"memories",
"Archive photos you don't want to see in your timeline"
);
case "tags":
return t("memories", "Tag photos to find them easily");
case "recognize":
return t("memories", "Recognize is still working on your photos");
case "places":
return t("memories", "Places you have been to will appear here");
default:
return "";
}
}
export function viewName(routeName: string): string {
switch (routeName) {
case "timeline":
return t("memories", "Your Timeline");
case "favorites":
return t("memories", "Favorites");
case "recognize":
case "facerecognition":
return t("memories", "People");
case "videos":
return t("memories", "Videos");
case "albums":
return t("memories", "Albums");
case "archive":
return t("memories", "Archive");
case "thisday":
return t("memories", "On this day");
case "tags":
return t("memories", "Tags");
case "places":
return t("memories", "Places");
default:
return "";
}
}

View File

@ -117,18 +117,28 @@ export interface IFolder extends IPhoto {
name: string; name: string;
} }
export interface ITag extends IPhoto { export type ClusterTypes =
/** Name of tag */ | "tags"
name: string; | "albums"
/** Number of images in this tag */ | "places"
| "recognize"
| "facerecognition";
export interface ICluster {
/** A unique identifier for the cluster */
cluster_id: number | string;
/** Type of cluster */
cluster_type: ClusterTypes;
/** Number of images in this cluster */
count: number; count: number;
/** User for face if face */ /** Name of cluster */
user_id?: string; name: string;
/** Cache of previews */
previews?: IPhoto[]; /** Preview loading failed */
previewError?: boolean;
} }
export interface IAlbum extends ITag { export interface IAlbum extends ICluster {
/** ID of album */ /** ID of album */
album_id: number; album_id: number;
/** Owner of album */ /** Owner of album */
@ -141,6 +151,11 @@ export interface IAlbum extends ITag {
last_added_photo: number; last_added_photo: number;
} }
export interface IFace extends ICluster {
/** User for face */
user_id: string;
}
export interface IFaceRect { export interface IFaceRect {
w: number; w: number;
h: number; h: number;
@ -211,7 +226,7 @@ export type TopMatter = {
export enum TopMatterType { export enum TopMatterType {
NONE = 0, NONE = 0,
FOLDER = 1, FOLDER = 1,
TAG = 2, CLUSTER = 2,
FACE = 3, FACE = 3,
ALBUM = 4, ALBUM = 4,
} }