Make the code prettier
parent
64b50deb40
commit
fc6a4fc244
363
src/App.vue
363
src/App.vue
|
@ -1,221 +1,254 @@
|
||||||
<template>
|
<template>
|
||||||
<FirstStart v-if="isFirstStart" />
|
<FirstStart v-if="isFirstStart" />
|
||||||
|
|
||||||
<NcContent app-name="memories" v-else :class="{
|
<NcContent
|
||||||
'remove-gap': removeOuterGap,
|
app-name="memories"
|
||||||
}">
|
v-else
|
||||||
<NcAppNavigation>
|
:class="{
|
||||||
<template id="app-memories-navigation" #list>
|
'remove-gap': removeOuterGap,
|
||||||
<NcAppNavigationItem :to="{name: 'timeline'}"
|
}"
|
||||||
:title="t('memories', 'Timeline')"
|
>
|
||||||
exact>
|
<NcAppNavigation>
|
||||||
<ImageMultiple slot="icon" :size="20" />
|
<template id="app-memories-navigation" #list>
|
||||||
</NcAppNavigationItem>
|
<NcAppNavigationItem
|
||||||
<NcAppNavigationItem :to="{name: 'folders'}"
|
:to="{ name: 'timeline' }"
|
||||||
:title="t('memories', 'Folders')">
|
:title="t('memories', 'Timeline')"
|
||||||
<FolderIcon slot="icon" :size="20" />
|
exact
|
||||||
</NcAppNavigationItem>
|
>
|
||||||
<NcAppNavigationItem :to="{name: 'favorites'}"
|
<ImageMultiple slot="icon" :size="20" />
|
||||||
:title="t('memories', 'Favorites')">
|
</NcAppNavigationItem>
|
||||||
<Star slot="icon" :size="20" />
|
<NcAppNavigationItem
|
||||||
</NcAppNavigationItem>
|
:to="{ name: 'folders' }"
|
||||||
<NcAppNavigationItem :to="{name: 'videos'}"
|
:title="t('memories', 'Folders')"
|
||||||
:title="t('memories', 'Videos')">
|
>
|
||||||
<Video slot="icon" :size="20" />
|
<FolderIcon slot="icon" :size="20" />
|
||||||
</NcAppNavigationItem>
|
</NcAppNavigationItem>
|
||||||
<NcAppNavigationItem :to="{name: 'albums'}"
|
<NcAppNavigationItem
|
||||||
:title="t('memories', 'Albums')" v-if="showAlbums">
|
:to="{ name: 'favorites' }"
|
||||||
<AlbumIcon slot="icon" :size="20" />
|
:title="t('memories', 'Favorites')"
|
||||||
</NcAppNavigationItem>
|
>
|
||||||
<NcAppNavigationItem :to="{name: 'people'}"
|
<Star slot="icon" :size="20" />
|
||||||
:title="t('memories', 'People')" v-if="showPeople">
|
</NcAppNavigationItem>
|
||||||
<PeopleIcon slot="icon" :size="20" />
|
<NcAppNavigationItem
|
||||||
</NcAppNavigationItem>
|
:to="{ name: 'videos' }"
|
||||||
<NcAppNavigationItem :to="{name: 'archive'}"
|
:title="t('memories', 'Videos')"
|
||||||
:title="t('memories', 'Archive')">
|
>
|
||||||
<ArchiveIcon slot="icon" :size="20" />
|
<Video slot="icon" :size="20" />
|
||||||
</NcAppNavigationItem>
|
</NcAppNavigationItem>
|
||||||
<NcAppNavigationItem :to="{name: 'thisday'}"
|
<NcAppNavigationItem
|
||||||
:title="t('memories', 'On this day')">
|
:to="{ name: 'albums' }"
|
||||||
<CalendarIcon slot="icon" :size="20" />
|
:title="t('memories', 'Albums')"
|
||||||
</NcAppNavigationItem>
|
v-if="showAlbums"
|
||||||
<NcAppNavigationItem :to="{name: 'tags'}" v-if="config_tagsEnabled"
|
>
|
||||||
:title="t('memories', 'Tags')">
|
<AlbumIcon slot="icon" :size="20" />
|
||||||
<TagsIcon slot="icon" :size="20" />
|
</NcAppNavigationItem>
|
||||||
</NcAppNavigationItem>
|
<NcAppNavigationItem
|
||||||
<NcAppNavigationItem :to="{name: 'maps'}" v-if="config_mapsEnabled"
|
:to="{ name: 'people' }"
|
||||||
:title="t('memories', 'Maps')">
|
:title="t('memories', 'People')"
|
||||||
<MapIcon slot="icon" :size="20" />
|
v-if="showPeople"
|
||||||
</NcAppNavigationItem>
|
>
|
||||||
</template>
|
<PeopleIcon slot="icon" :size="20" />
|
||||||
<template #footer>
|
</NcAppNavigationItem>
|
||||||
<NcAppNavigationSettings :title="t('memories', 'Settings')">
|
<NcAppNavigationItem
|
||||||
<Settings />
|
:to="{ name: 'archive' }"
|
||||||
</NcAppNavigationSettings>
|
:title="t('memories', 'Archive')"
|
||||||
</template>
|
>
|
||||||
</NcAppNavigation>
|
<ArchiveIcon slot="icon" :size="20" />
|
||||||
|
</NcAppNavigationItem>
|
||||||
|
<NcAppNavigationItem
|
||||||
|
:to="{ name: 'thisday' }"
|
||||||
|
:title="t('memories', 'On this day')"
|
||||||
|
>
|
||||||
|
<CalendarIcon slot="icon" :size="20" />
|
||||||
|
</NcAppNavigationItem>
|
||||||
|
<NcAppNavigationItem
|
||||||
|
:to="{ name: 'tags' }"
|
||||||
|
v-if="config_tagsEnabled"
|
||||||
|
:title="t('memories', 'Tags')"
|
||||||
|
>
|
||||||
|
<TagsIcon slot="icon" :size="20" />
|
||||||
|
</NcAppNavigationItem>
|
||||||
|
<NcAppNavigationItem
|
||||||
|
:to="{ name: 'maps' }"
|
||||||
|
v-if="config_mapsEnabled"
|
||||||
|
:title="t('memories', 'Maps')"
|
||||||
|
>
|
||||||
|
<MapIcon slot="icon" :size="20" />
|
||||||
|
</NcAppNavigationItem>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<NcAppNavigationSettings :title="t('memories', 'Settings')">
|
||||||
|
<Settings />
|
||||||
|
</NcAppNavigationSettings>
|
||||||
|
</template>
|
||||||
|
</NcAppNavigation>
|
||||||
|
|
||||||
<NcAppContent>
|
<NcAppContent>
|
||||||
<div class="outer">
|
<div class="outer">
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
</NcAppContent>
|
</NcAppContent>
|
||||||
</NcContent>
|
</NcContent>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Mixins } from 'vue-property-decorator';
|
import { Component, Mixins } from "vue-property-decorator";
|
||||||
import {
|
import {
|
||||||
NcContent, NcAppContent, NcAppNavigation,
|
NcContent,
|
||||||
NcAppNavigationItem, NcAppNavigationSettings,
|
NcAppContent,
|
||||||
} from '@nextcloud/vue';
|
NcAppNavigation,
|
||||||
import { generateUrl } from '@nextcloud/router';
|
NcAppNavigationItem,
|
||||||
import { getCurrentUser } from '@nextcloud/auth';
|
NcAppNavigationSettings,
|
||||||
|
} from "@nextcloud/vue";
|
||||||
|
import { generateUrl } from "@nextcloud/router";
|
||||||
|
import { getCurrentUser } from "@nextcloud/auth";
|
||||||
|
|
||||||
import Timeline from './components/Timeline.vue'
|
import Timeline from "./components/Timeline.vue";
|
||||||
import Settings from './components/Settings.vue'
|
import Settings from "./components/Settings.vue";
|
||||||
import FirstStart from './components/FirstStart.vue'
|
import FirstStart from "./components/FirstStart.vue";
|
||||||
import GlobalMixin from './mixins/GlobalMixin';
|
import GlobalMixin from "./mixins/GlobalMixin";
|
||||||
import UserConfig from './mixins/UserConfig';
|
import UserConfig from "./mixins/UserConfig";
|
||||||
|
|
||||||
import ImageMultiple from 'vue-material-design-icons/ImageMultiple.vue'
|
import ImageMultiple from "vue-material-design-icons/ImageMultiple.vue";
|
||||||
import FolderIcon from 'vue-material-design-icons/Folder.vue'
|
import FolderIcon from "vue-material-design-icons/Folder.vue";
|
||||||
import Star from 'vue-material-design-icons/Star.vue'
|
import Star from "vue-material-design-icons/Star.vue";
|
||||||
import Video from 'vue-material-design-icons/Video.vue'
|
import Video from "vue-material-design-icons/Video.vue";
|
||||||
import AlbumIcon from 'vue-material-design-icons/ImageAlbum.vue';
|
import AlbumIcon from "vue-material-design-icons/ImageAlbum.vue";
|
||||||
import ArchiveIcon from 'vue-material-design-icons/PackageDown.vue';
|
import ArchiveIcon from "vue-material-design-icons/PackageDown.vue";
|
||||||
import CalendarIcon from 'vue-material-design-icons/Calendar.vue';
|
import CalendarIcon from "vue-material-design-icons/Calendar.vue";
|
||||||
import PeopleIcon from 'vue-material-design-icons/AccountBoxMultiple.vue';
|
import PeopleIcon from "vue-material-design-icons/AccountBoxMultiple.vue";
|
||||||
import TagsIcon from 'vue-material-design-icons/Tag.vue';
|
import TagsIcon from "vue-material-design-icons/Tag.vue";
|
||||||
import MapIcon from 'vue-material-design-icons/Map.vue';
|
import MapIcon from "vue-material-design-icons/Map.vue";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
NcContent,
|
NcContent,
|
||||||
NcAppContent,
|
NcAppContent,
|
||||||
NcAppNavigation,
|
NcAppNavigation,
|
||||||
NcAppNavigationItem,
|
NcAppNavigationItem,
|
||||||
NcAppNavigationSettings,
|
NcAppNavigationSettings,
|
||||||
|
|
||||||
Timeline,
|
Timeline,
|
||||||
Settings,
|
Settings,
|
||||||
FirstStart,
|
FirstStart,
|
||||||
|
|
||||||
ImageMultiple,
|
ImageMultiple,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
Star,
|
Star,
|
||||||
Video,
|
Video,
|
||||||
AlbumIcon,
|
AlbumIcon,
|
||||||
ArchiveIcon,
|
ArchiveIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
PeopleIcon,
|
PeopleIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
MapIcon,
|
MapIcon,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class App extends Mixins(GlobalMixin, UserConfig) {
|
export default class App extends Mixins(GlobalMixin, UserConfig) {
|
||||||
// Outer element
|
// Outer element
|
||||||
|
|
||||||
get ncVersion() {
|
get ncVersion() {
|
||||||
const version = (<any>window.OC).config.version.split('.');
|
const version = (<any>window.OC).config.version.split(".");
|
||||||
return Number(version[0]);
|
return Number(version[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
get showPeople() {
|
||||||
|
return this.config_recognizeEnabled || getCurrentUser()?.isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isFirstStart() {
|
||||||
|
return this.config_timelinePath === "EMPTY";
|
||||||
|
}
|
||||||
|
|
||||||
|
get showAlbums() {
|
||||||
|
return this.config_albumsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
get removeOuterGap() {
|
||||||
|
return this.ncVersion >= 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
async beforeMount() {
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
// Use the window load event to keep the page load performant
|
||||||
|
window.addEventListener("load", async () => {
|
||||||
|
try {
|
||||||
|
const url = generateUrl("/apps/memories/service-worker.js");
|
||||||
|
const registration = await navigator.serviceWorker.register(url, {
|
||||||
|
scope: generateUrl("/apps/memories"),
|
||||||
|
});
|
||||||
|
console.log("SW registered: ", registration);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SW registration failed: ", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.debug("Service Worker is not enabled on this browser.");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
get showPeople() {
|
|
||||||
return this.config_recognizeEnabled || getCurrentUser()?.isAdmin;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isFirstStart() {
|
|
||||||
return this.config_timelinePath === 'EMPTY';
|
|
||||||
}
|
|
||||||
|
|
||||||
get showAlbums() {
|
|
||||||
return this.config_albumsEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
get removeOuterGap() {
|
|
||||||
return this.ncVersion >= 25;
|
|
||||||
}
|
|
||||||
|
|
||||||
async beforeMount() {
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
// Use the window load event to keep the page load performant
|
|
||||||
window.addEventListener('load', async () => {
|
|
||||||
try {
|
|
||||||
const url = generateUrl('/apps/memories/service-worker.js');
|
|
||||||
const registration = await navigator.serviceWorker.register(url, { scope: generateUrl('/apps/memories') });
|
|
||||||
console.log('SW registered: ', registration);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('SW registration failed: ', error);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
console.debug('Service Worker is not enabled on this browser.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.outer {
|
.outer {
|
||||||
padding: 0 0 0 44px;
|
padding: 0 0 0 44px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.outer {
|
.outer {
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
|
|
||||||
// Get rid of padding on img-outer (1px on mobile)
|
// Get rid of padding on img-outer (1px on mobile)
|
||||||
// Also need to make sure we don't end up with a scrollbar -- see below
|
// Also need to make sure we don't end up with a scrollbar -- see below
|
||||||
margin-left: -1px;
|
margin-left: -1px;
|
||||||
width: calc(100% + 3px); // 1px extra here because ... reasons
|
width: calc(100% + 3px); // 1px extra here because ... reasons
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
body {
|
body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nextcloud 25+: get rid of gap and border radius at right
|
// Nextcloud 25+: get rid of gap and border radius at right
|
||||||
#content-vue.remove-gap {
|
#content-vue.remove-gap {
|
||||||
// was var(--body-container-radius)
|
// was var(--body-container-radius)
|
||||||
border-top-right-radius: 0;
|
border-top-right-radius: 0;
|
||||||
border-bottom-right-radius: 0;
|
border-bottom-right-radius: 0;
|
||||||
|
|
||||||
width: calc(100% - var(--body-container-margin)*1); // was *2
|
width: calc(100% - var(--body-container-margin) * 1); // was *2
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent content overflow on NC <25
|
// Prevent content overflow on NC <25
|
||||||
#content-vue {
|
#content-vue {
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Patch viewer to remove the title and
|
// Patch viewer to remove the title and
|
||||||
// make the image fill the entire screen
|
// make the image fill the entire screen
|
||||||
.viewer {
|
.viewer {
|
||||||
.modal-title {
|
.modal-title {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.modal-wrapper .modal-container {
|
.modal-wrapper .modal-container {
|
||||||
top: 0 !important;
|
top: 0 !important;
|
||||||
bottom: 0 !important;
|
bottom: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide horizontal scrollbar on mobile
|
// Hide horizontal scrollbar on mobile
|
||||||
// For the padding removal above
|
// For the padding removal above
|
||||||
#app-content-vue {
|
#app-content-vue {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill all available space
|
// Fill all available space
|
||||||
.fill-block {
|
.fill-block {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,176 +1,189 @@
|
||||||
<template>
|
<template>
|
||||||
<NcContent app-name="memories">
|
<NcContent app-name="memories">
|
||||||
<NcAppContent>
|
<NcAppContent>
|
||||||
<div class="outer fill-block" :class="{ show }">
|
<div class="outer fill-block" :class="{ show }">
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<img :src="banner" />
|
<img :src="banner" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text">
|
<div class="text">
|
||||||
{{ t('memories', 'A better photos experience awaits you') }} <br/>
|
{{ t("memories", "A better photos experience awaits you") }} <br />
|
||||||
{{ t('memories', 'Choose the root folder of your timeline to begin') }}
|
{{
|
||||||
</div>
|
t("memories", "Choose the root folder of your timeline to begin")
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="admin-text" v-if="isAdmin">
|
<div class="admin-text" v-if="isAdmin">
|
||||||
{{ t('memories', 'If you just installed Memories, run:') }}
|
{{ t("memories", "If you just installed Memories, run:") }}
|
||||||
<br/>
|
<br />
|
||||||
<code>occ memories:index</code>
|
<code>occ memories:index</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="error" v-if="error">
|
<div class="error" v-if="error">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info" v-if="info">
|
<div class="info" v-if="info">
|
||||||
{{ info }} <br/>
|
{{ info }} <br />
|
||||||
|
|
||||||
<NcButton @click="finish" class="button" type="primary">
|
<NcButton @click="finish" class="button" type="primary">
|
||||||
{{ t('memories', 'Continue to Memories') }}
|
{{ t("memories", "Continue to Memories") }}
|
||||||
</NcButton>
|
</NcButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NcButton @click="begin" class="button" v-if="info">
|
<NcButton @click="begin" class="button" v-if="info">
|
||||||
{{ t('memories', 'Choose again') }}
|
{{ t("memories", "Choose again") }}
|
||||||
</NcButton>
|
</NcButton>
|
||||||
<NcButton @click="begin" class="button" type="primary" v-else>
|
<NcButton @click="begin" class="button" type="primary" v-else>
|
||||||
{{ t('memories', 'Click here to start') }}
|
{{ t("memories", "Click here to start") }}
|
||||||
</NcButton>
|
</NcButton>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
{{ t('memories', 'You can always change this later in settings') }}
|
{{ t("memories", "You can always change this later in settings") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NcAppContent>
|
</NcAppContent>
|
||||||
</NcContent>
|
</NcContent>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Mixins } from 'vue-property-decorator';
|
import { Component, Mixins } from "vue-property-decorator";
|
||||||
import { NcContent, NcAppContent, NcButton } from '@nextcloud/vue';
|
import { NcContent, NcAppContent, NcButton } from "@nextcloud/vue";
|
||||||
import { getFilePickerBuilder } from '@nextcloud/dialogs'
|
import { getFilePickerBuilder } from "@nextcloud/dialogs";
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from "@nextcloud/router";
|
||||||
import { getCurrentUser } from '@nextcloud/auth';
|
import { getCurrentUser } from "@nextcloud/auth";
|
||||||
import axios from '@nextcloud/axios'
|
import axios from "@nextcloud/axios";
|
||||||
|
|
||||||
import GlobalMixin from '../mixins/GlobalMixin';
|
import GlobalMixin from "../mixins/GlobalMixin";
|
||||||
import UserConfig from '../mixins/UserConfig';
|
import UserConfig from "../mixins/UserConfig";
|
||||||
|
|
||||||
import banner from "../assets/banner.svg";
|
import banner from "../assets/banner.svg";
|
||||||
import { IDay } from '../types';
|
import { IDay } from "../types";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
NcContent,
|
NcContent,
|
||||||
NcAppContent,
|
NcAppContent,
|
||||||
NcButton,
|
NcButton,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class FirstStart extends Mixins(GlobalMixin, UserConfig) {
|
export default class FirstStart extends Mixins(GlobalMixin, UserConfig) {
|
||||||
banner = banner;
|
banner = banner;
|
||||||
error = '';
|
error = "";
|
||||||
info = ''
|
info = "";
|
||||||
show = false;
|
show = false;
|
||||||
chosenPath = '';
|
chosenPath = "";
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
this.show = true;
|
this.show = true;
|
||||||
}, 300);
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isAdmin() {
|
||||||
|
return getCurrentUser().isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async begin() {
|
||||||
|
const path = await this.chooseFolder(
|
||||||
|
this.t("memories", "Choose the root of your timeline"),
|
||||||
|
"/"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get folder days
|
||||||
|
this.error = "";
|
||||||
|
this.info = "";
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set("timelinePath", path);
|
||||||
|
let url = generateUrl("/apps/memories/api/days?" + query.toString());
|
||||||
|
const res = await axios.get<IDay[]>(url);
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
if (res.status !== 200) {
|
||||||
|
this.error = this.t(
|
||||||
|
"memories",
|
||||||
|
"The selected folder does not seem to be valid. Try again."
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isAdmin() {
|
// Count total photos
|
||||||
return getCurrentUser().isAdmin;
|
const total = res.data.reduce((acc, day) => acc + day.count, 0);
|
||||||
}
|
this.info = this.t("memories", "Found {total} photos in {path}", {
|
||||||
|
total,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
this.chosenPath = path;
|
||||||
|
}
|
||||||
|
|
||||||
async begin() {
|
async finish() {
|
||||||
const path = await this.chooseFolder(this.t('memories', 'Choose the root of your timeline'), '/');
|
this.show = false;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
this.config_timelinePath = this.chosenPath;
|
||||||
|
await this.updateSetting("timelinePath");
|
||||||
|
}
|
||||||
|
|
||||||
// Get folder days
|
async chooseFolder(title: string, initial: string) {
|
||||||
this.error = '';
|
const picker = getFilePickerBuilder(title)
|
||||||
this.info = '';
|
.setMultiSelect(false)
|
||||||
const query = new URLSearchParams();
|
.setModal(true)
|
||||||
query.set('timelinePath', path);
|
.setType(1)
|
||||||
let url = generateUrl('/apps/memories/api/days?' + query.toString());
|
.addMimeTypeFilter("httpd/unix-directory")
|
||||||
const res = await axios.get<IDay[]>(url);
|
.allowDirectories()
|
||||||
|
.startAt(initial)
|
||||||
|
.build();
|
||||||
|
|
||||||
// Check response
|
return await picker.pick();
|
||||||
if (res.status !== 200) {
|
}
|
||||||
this.error = this.t('memories', 'The selected folder does not seem to be valid. Try again.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count total photos
|
|
||||||
const total = res.data.reduce((acc, day) => acc + day.count, 0);
|
|
||||||
this.info = this.t('memories', 'Found {total} photos in {path}', { total, path });
|
|
||||||
this.chosenPath = path;
|
|
||||||
}
|
|
||||||
|
|
||||||
async finish() {
|
|
||||||
this.show = false;
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
this.config_timelinePath = this.chosenPath;
|
|
||||||
await this.updateSetting('timelinePath');
|
|
||||||
}
|
|
||||||
|
|
||||||
async chooseFolder(title: string, initial: string) {
|
|
||||||
const picker = getFilePickerBuilder(title)
|
|
||||||
.setMultiSelect(false)
|
|
||||||
.setModal(true)
|
|
||||||
.setType(1)
|
|
||||||
.addMimeTypeFilter('httpd/unix-directory')
|
|
||||||
.allowDirectories()
|
|
||||||
.startAt(initial)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return await picker.pick();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.outer {
|
.outer {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
transition: opacity 1s ease;
|
transition: opacity 1s ease;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
&.show { opacity: 1; }
|
&.show {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 2.8em;
|
font-size: 2.8em;
|
||||||
line-height: 1.1em;
|
line-height: 1.1em;
|
||||||
font-family: cursive;
|
font-family: cursive;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
filter: var(--background-invert-if-dark);
|
filter: var(--background-invert-if-dark);
|
||||||
|
|
||||||
> img {
|
> img {
|
||||||
max-width: calc(100vw - 40px);
|
max-width: calc(100vw - 40px);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.admin-text {
|
.admin-text {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: red;
|
color: red;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 15px;
|
margin: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
File diff suppressed because it is too large
Load Diff
|
@ -1,444 +1,513 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="selection.size > 0" class="top-bar">
|
<div v-if="selection.size > 0" class="top-bar">
|
||||||
<NcActions>
|
<NcActions>
|
||||||
<NcActionButton
|
<NcActionButton
|
||||||
:aria-label="t('memories', 'Cancel')"
|
:aria-label="t('memories', 'Cancel')"
|
||||||
@click="clearSelection()">
|
@click="clearSelection()"
|
||||||
{{ t('memories', 'Cancel') }}
|
>
|
||||||
<template #icon> <CloseIcon :size="20" /> </template>
|
{{ t("memories", "Cancel") }}
|
||||||
</NcActionButton>
|
<template #icon> <CloseIcon :size="20" /> </template>
|
||||||
</NcActions>
|
</NcActionButton>
|
||||||
|
</NcActions>
|
||||||
|
|
||||||
<div class="text">
|
<div class="text">
|
||||||
{{ n("memories", "{n} selected", "{n} selected", selection.size, { n: selection.size }) }}
|
{{
|
||||||
</div>
|
n("memories", "{n} selected", "{n} selected", selection.size, {
|
||||||
|
n: selection.size,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
|
||||||
<NcActions :inline="1">
|
<NcActions :inline="1">
|
||||||
<NcActionButton v-for="action of getActions()" :key="action.name"
|
<NcActionButton
|
||||||
:aria-label="action.name" close-after-click
|
v-for="action of getActions()"
|
||||||
@click="click(action)">
|
:key="action.name"
|
||||||
{{ action.name }}
|
:aria-label="action.name"
|
||||||
<template #icon> <component :is="action.icon" :size="20" /> </template>
|
close-after-click
|
||||||
</NcActionButton>
|
@click="click(action)"
|
||||||
</NcActions>
|
>
|
||||||
</div>
|
{{ action.name }}
|
||||||
|
<template #icon>
|
||||||
<!-- Selection Modals -->
|
<component :is="action.icon" :size="20" />
|
||||||
<EditDate ref="editDate" @refresh="refresh" />
|
</template>
|
||||||
<FaceMoveModal ref="faceMoveModal" @moved="deletePhotos" :updateLoading="updateLoading" />
|
</NcActionButton>
|
||||||
<AddToAlbumModal ref="addToAlbumModal" @added="clearSelection" />
|
</NcActions>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Selection Modals -->
|
||||||
|
<EditDate ref="editDate" @refresh="refresh" />
|
||||||
|
<FaceMoveModal
|
||||||
|
ref="faceMoveModal"
|
||||||
|
@moved="deletePhotos"
|
||||||
|
:updateLoading="updateLoading"
|
||||||
|
/>
|
||||||
|
<AddToAlbumModal ref="addToAlbumModal" @added="clearSelection" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator';
|
import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
|
||||||
import GlobalMixin from '../mixins/GlobalMixin';
|
import GlobalMixin from "../mixins/GlobalMixin";
|
||||||
import UserConfig from '../mixins/UserConfig';
|
import UserConfig from "../mixins/UserConfig";
|
||||||
|
|
||||||
import { showError } from '@nextcloud/dialogs'
|
import { showError } from "@nextcloud/dialogs";
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from "@nextcloud/router";
|
||||||
import { NcActions, NcActionButton } from '@nextcloud/vue';
|
import { NcActions, NcActionButton } from "@nextcloud/vue";
|
||||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
|
||||||
import { IHeadRow, IPhoto, ISelectionAction } from '../types';
|
import { IHeadRow, IPhoto, ISelectionAction } from "../types";
|
||||||
import { getCurrentUser } from '@nextcloud/auth';
|
import { getCurrentUser } from "@nextcloud/auth";
|
||||||
|
|
||||||
import * as dav from "../services/DavRequests";
|
import * as dav from "../services/DavRequests";
|
||||||
import EditDate from "./modal/EditDate.vue"
|
import EditDate from "./modal/EditDate.vue";
|
||||||
import FaceMoveModal from "./modal/FaceMoveModal.vue"
|
import FaceMoveModal from "./modal/FaceMoveModal.vue";
|
||||||
import AddToAlbumModal from "./modal/AddToAlbumModal.vue"
|
import AddToAlbumModal from "./modal/AddToAlbumModal.vue";
|
||||||
|
|
||||||
import StarIcon from 'vue-material-design-icons/Star.vue';
|
import StarIcon from "vue-material-design-icons/Star.vue";
|
||||||
import DownloadIcon from 'vue-material-design-icons/Download.vue';
|
import DownloadIcon from "vue-material-design-icons/Download.vue";
|
||||||
import DeleteIcon from 'vue-material-design-icons/Delete.vue';
|
import DeleteIcon from "vue-material-design-icons/Delete.vue";
|
||||||
import EditIcon from 'vue-material-design-icons/ClockEdit.vue';
|
import EditIcon from "vue-material-design-icons/ClockEdit.vue";
|
||||||
import ArchiveIcon from 'vue-material-design-icons/PackageDown.vue';
|
import ArchiveIcon from "vue-material-design-icons/PackageDown.vue";
|
||||||
import UnarchiveIcon from 'vue-material-design-icons/PackageUp.vue';
|
import UnarchiveIcon from "vue-material-design-icons/PackageUp.vue";
|
||||||
import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue';
|
import OpenInNewIcon from "vue-material-design-icons/OpenInNew.vue";
|
||||||
import CloseIcon from 'vue-material-design-icons/Close.vue';
|
import CloseIcon from "vue-material-design-icons/Close.vue";
|
||||||
import MoveIcon from 'vue-material-design-icons/ImageMove.vue';
|
import MoveIcon from "vue-material-design-icons/ImageMove.vue";
|
||||||
import AlbumsIcon from 'vue-material-design-icons/ImageAlbum.vue';
|
import AlbumsIcon from "vue-material-design-icons/ImageAlbum.vue";
|
||||||
import AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue';
|
import AlbumRemoveIcon from "vue-material-design-icons/BookRemove.vue";
|
||||||
|
|
||||||
type Selection = Map<number, IPhoto>;
|
type Selection = Map<number, IPhoto>;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
NcActions,
|
NcActions,
|
||||||
NcActionButton,
|
NcActionButton,
|
||||||
EditDate,
|
EditDate,
|
||||||
FaceMoveModal,
|
FaceMoveModal,
|
||||||
AddToAlbumModal,
|
AddToAlbumModal,
|
||||||
|
|
||||||
CloseIcon,
|
CloseIcon,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class SelectionHandler extends Mixins(GlobalMixin, UserConfig) {
|
export default class SelectionHandler extends Mixins(GlobalMixin, UserConfig) {
|
||||||
@Prop() public selection: Selection;
|
@Prop() public selection: Selection;
|
||||||
@Prop() public heads: { [dayid: number]: IHeadRow };
|
@Prop() public heads: { [dayid: number]: IHeadRow };
|
||||||
|
|
||||||
private readonly defaultActions: ISelectionAction[];
|
private readonly defaultActions: ISelectionAction[];
|
||||||
|
|
||||||
@Emit('refresh')
|
@Emit("refresh")
|
||||||
refresh() {}
|
refresh() {}
|
||||||
|
|
||||||
@Emit('delete')
|
@Emit("delete")
|
||||||
deletePhotos(photos: IPhoto[]) {}
|
deletePhotos(photos: IPhoto[]) {}
|
||||||
|
|
||||||
@Emit('updateLoading')
|
@Emit("updateLoading")
|
||||||
updateLoading(delta: number) {}
|
updateLoading(delta: number) {}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
// Make default actions
|
// Make default actions
|
||||||
this.defaultActions = [
|
this.defaultActions = [
|
||||||
{ // This is at the top because otherwise it is confusing
|
{
|
||||||
name: t('memories', 'Remove from album'),
|
// This is at the top because otherwise it is confusing
|
||||||
icon: AlbumRemoveIcon,
|
name: t("memories", "Remove from album"),
|
||||||
callback: this.removeFromAlbum.bind(this),
|
icon: AlbumRemoveIcon,
|
||||||
if: () => this.$route.name === 'albums',
|
callback: this.removeFromAlbum.bind(this),
|
||||||
},
|
if: () => this.$route.name === "albums",
|
||||||
{
|
},
|
||||||
name: t('memories', 'Delete'),
|
{
|
||||||
icon: DeleteIcon,
|
name: t("memories", "Delete"),
|
||||||
callback: this.deleteSelection.bind(this),
|
icon: DeleteIcon,
|
||||||
},
|
callback: this.deleteSelection.bind(this),
|
||||||
{
|
},
|
||||||
name: t('memories', 'Download'),
|
{
|
||||||
icon: DownloadIcon,
|
name: t("memories", "Download"),
|
||||||
callback: this.downloadSelection.bind(this),
|
icon: DownloadIcon,
|
||||||
},
|
callback: this.downloadSelection.bind(this),
|
||||||
{
|
},
|
||||||
name: t('memories', 'Favorite'),
|
{
|
||||||
icon: StarIcon,
|
name: t("memories", "Favorite"),
|
||||||
callback: this.favoriteSelection.bind(this),
|
icon: StarIcon,
|
||||||
},
|
callback: this.favoriteSelection.bind(this),
|
||||||
{
|
},
|
||||||
name: t('memories', 'Archive'),
|
{
|
||||||
icon: ArchiveIcon,
|
name: t("memories", "Archive"),
|
||||||
callback: this.archiveSelection.bind(this),
|
icon: ArchiveIcon,
|
||||||
if: () => this.allowArchive() && !this.routeIsArchive(),
|
callback: this.archiveSelection.bind(this),
|
||||||
},
|
if: () => this.allowArchive() && !this.routeIsArchive(),
|
||||||
{
|
},
|
||||||
name: t('memories', 'Unarchive'),
|
{
|
||||||
icon: UnarchiveIcon,
|
name: t("memories", "Unarchive"),
|
||||||
callback: this.archiveSelection.bind(this),
|
icon: UnarchiveIcon,
|
||||||
if: () => this.allowArchive() && this.routeIsArchive(),
|
callback: this.archiveSelection.bind(this),
|
||||||
},
|
if: () => this.allowArchive() && this.routeIsArchive(),
|
||||||
{
|
},
|
||||||
name: t('memories', 'Edit Date/Time'),
|
{
|
||||||
icon: EditIcon,
|
name: t("memories", "Edit Date/Time"),
|
||||||
callback: this.editDateSelection.bind(this),
|
icon: EditIcon,
|
||||||
},
|
callback: this.editDateSelection.bind(this),
|
||||||
{
|
},
|
||||||
name: t('memories', 'View in folder'),
|
{
|
||||||
icon: OpenInNewIcon,
|
name: t("memories", "View in folder"),
|
||||||
callback: this.viewInFolder.bind(this),
|
icon: OpenInNewIcon,
|
||||||
if: () => this.selection.size === 1,
|
callback: this.viewInFolder.bind(this),
|
||||||
},
|
if: () => this.selection.size === 1,
|
||||||
{
|
},
|
||||||
name: t('memories', 'Add to album'),
|
{
|
||||||
icon: AlbumsIcon,
|
name: t("memories", "Add to album"),
|
||||||
callback: this.addToAlbum.bind(this),
|
icon: AlbumsIcon,
|
||||||
if: (self: any) => self.config_albumsEnabled,
|
callback: this.addToAlbum.bind(this),
|
||||||
},
|
if: (self: any) => self.config_albumsEnabled,
|
||||||
{
|
},
|
||||||
name: t('memories', 'Move to another person'),
|
{
|
||||||
icon: MoveIcon,
|
name: t("memories", "Move to another person"),
|
||||||
callback: this.moveSelectionToPerson.bind(this),
|
icon: MoveIcon,
|
||||||
if: () => this.$route.name === 'people',
|
callback: this.moveSelectionToPerson.bind(this),
|
||||||
},
|
if: () => this.$route.name === "people",
|
||||||
{
|
},
|
||||||
name: t('memories', 'Remove from person'),
|
{
|
||||||
icon: CloseIcon,
|
name: t("memories", "Remove from person"),
|
||||||
callback: this.removeSelectionFromPerson.bind(this),
|
icon: CloseIcon,
|
||||||
if: () => this.$route.name === 'people',
|
callback: this.removeSelectionFromPerson.bind(this),
|
||||||
},
|
if: () => this.$route.name === "people",
|
||||||
];
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click on an action */
|
||||||
|
private async click(action: ISelectionAction) {
|
||||||
|
try {
|
||||||
|
this.updateLoading(1);
|
||||||
|
await action.callback(this.selection);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
this.updateLoading(-1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Click on an action */
|
/** Get the actions list */
|
||||||
private async click(action: ISelectionAction) {
|
private getActions(): ISelectionAction[] {
|
||||||
try {
|
return this.defaultActions.filter((a) => !a.if || a.if(this));
|
||||||
this.updateLoading(1);
|
}
|
||||||
await action.callback(this.selection);
|
|
||||||
} catch (error) {
|
/** Clear all selected photos */
|
||||||
console.error(error);
|
public clearSelection(only?: IPhoto[]) {
|
||||||
} finally {
|
const heads = new Set<IHeadRow>();
|
||||||
this.updateLoading(-1);
|
const toClear = only || this.selection.values();
|
||||||
|
Array.from(toClear).forEach((photo: IPhoto) => {
|
||||||
|
photo.flag &= ~this.c.FLAG_SELECTED;
|
||||||
|
heads.add(this.heads[photo.d.dayid]);
|
||||||
|
this.selection.delete(photo.fileid);
|
||||||
|
});
|
||||||
|
heads.forEach(this.updateHeadSelected);
|
||||||
|
this.$forceUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if the day for a photo is selected entirely */
|
||||||
|
private updateHeadSelected(head: IHeadRow) {
|
||||||
|
let selected = true;
|
||||||
|
|
||||||
|
// Check if all photos are selected
|
||||||
|
for (const row of head.day.rows) {
|
||||||
|
for (const photo of row.photos) {
|
||||||
|
if (!(photo.flag & this.c.FLAG_SELECTED)) {
|
||||||
|
selected = false;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the actions list */
|
// Update head
|
||||||
private getActions(): ISelectionAction[] {
|
head.selected = selected;
|
||||||
return this.defaultActions.filter(a => !a.if || a.if(this));
|
}
|
||||||
|
|
||||||
|
/** Add a photo to selection list */
|
||||||
|
public selectPhoto(photo: IPhoto, val?: boolean, noUpdate?: boolean) {
|
||||||
|
if (
|
||||||
|
photo.flag & this.c.FLAG_PLACEHOLDER ||
|
||||||
|
photo.flag & this.c.FLAG_IS_FOLDER ||
|
||||||
|
photo.flag & this.c.FLAG_IS_TAG
|
||||||
|
) {
|
||||||
|
return; // ignore placeholders
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear all selected photos */
|
const nval = val ?? !this.selection.has(photo.fileid);
|
||||||
public clearSelection(only?: IPhoto[]) {
|
if (nval) {
|
||||||
const heads = new Set<IHeadRow>();
|
photo.flag |= this.c.FLAG_SELECTED;
|
||||||
const toClear = only || this.selection.values();
|
this.selection.set(photo.fileid, photo);
|
||||||
Array.from(toClear).forEach((photo: IPhoto) => {
|
} else {
|
||||||
photo.flag &= ~this.c.FLAG_SELECTED;
|
photo.flag &= ~this.c.FLAG_SELECTED;
|
||||||
heads.add(this.heads[photo.d.dayid]);
|
this.selection.delete(photo.fileid);
|
||||||
this.selection.delete(photo.fileid);
|
|
||||||
});
|
|
||||||
heads.forEach(this.updateHeadSelected);
|
|
||||||
this.$forceUpdate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if the day for a photo is selected entirely */
|
if (!noUpdate) {
|
||||||
private updateHeadSelected(head: IHeadRow) {
|
this.updateHeadSelected(this.heads[photo.d.dayid]);
|
||||||
let selected = true;
|
this.$forceUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if all photos are selected
|
/** Select or deselect all photos in a head */
|
||||||
for (const row of head.day.rows) {
|
public selectHead(head: IHeadRow) {
|
||||||
for (const photo of row.photos) {
|
head.selected = !head.selected;
|
||||||
if (!(photo.flag & this.c.FLAG_SELECTED)) {
|
for (const row of head.day.rows) {
|
||||||
selected = false;
|
for (const photo of row.photos) {
|
||||||
break;
|
this.selectPhoto(photo, head.selected, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.$forceUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the currently selected files
|
||||||
|
*/
|
||||||
|
private async downloadSelection(selection: Selection) {
|
||||||
|
if (selection.size >= 100) {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
this.t(
|
||||||
|
"memories",
|
||||||
|
"You are about to download a large number of files. Are you sure?"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await dav.downloadFilesByIds(Array.from(selection.keys()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if all files selected currently are favorites
|
||||||
|
*/
|
||||||
|
private allSelectedFavorites(selection: Selection) {
|
||||||
|
return Array.from(selection.values()).every(
|
||||||
|
(p) => p.flag & this.c.FLAG_IS_FAVORITE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Favorite the currently selected photos
|
||||||
|
*/
|
||||||
|
private async favoriteSelection(selection: Selection) {
|
||||||
|
const val = !this.allSelectedFavorites(selection);
|
||||||
|
for await (const favIds of dav.favoriteFilesByIds(
|
||||||
|
Array.from(selection.keys()),
|
||||||
|
val
|
||||||
|
)) {
|
||||||
|
favIds.forEach((id) => {
|
||||||
|
const photo = selection.get(id);
|
||||||
|
if (!photo) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update head
|
if (val) {
|
||||||
head.selected = selected;
|
photo.flag |= this.c.FLAG_IS_FAVORITE;
|
||||||
}
|
|
||||||
|
|
||||||
/** Add a photo to selection list */
|
|
||||||
public selectPhoto(photo: IPhoto, val?: boolean, noUpdate?: boolean) {
|
|
||||||
if (photo.flag & this.c.FLAG_PLACEHOLDER ||
|
|
||||||
photo.flag & this.c.FLAG_IS_FOLDER ||
|
|
||||||
photo.flag & this.c.FLAG_IS_TAG
|
|
||||||
) {
|
|
||||||
return; // ignore placeholders
|
|
||||||
}
|
|
||||||
|
|
||||||
const nval = val ?? !this.selection.has(photo.fileid);
|
|
||||||
if (nval) {
|
|
||||||
photo.flag |= this.c.FLAG_SELECTED;
|
|
||||||
this.selection.set(photo.fileid, photo);
|
|
||||||
} else {
|
} else {
|
||||||
photo.flag &= ~this.c.FLAG_SELECTED;
|
photo.flag &= ~this.c.FLAG_IS_FAVORITE;
|
||||||
this.selection.delete(photo.fileid);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
if (!noUpdate) {
|
/**
|
||||||
this.updateHeadSelected(this.heads[photo.d.dayid]);
|
* Delete the currently selected photos
|
||||||
this.$forceUpdate();
|
*/
|
||||||
}
|
private async deleteSelection(selection: Selection) {
|
||||||
|
if (selection.size >= 100) {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
this.t(
|
||||||
|
"memories",
|
||||||
|
"You are about to delete a large number of files. Are you sure?"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Select or deselect all photos in a head */
|
for await (const delIds of dav.deleteFilesByIds(
|
||||||
public selectHead(head: IHeadRow) {
|
Array.from(selection.keys())
|
||||||
head.selected = !head.selected;
|
)) {
|
||||||
for (const row of head.day.rows) {
|
const delPhotos = delIds.map((id) => selection.get(id));
|
||||||
for (const photo of row.photos) {
|
this.deletePhotos(delPhotos);
|
||||||
this.selectPhoto(photo, head.selected, true);
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
this.$forceUpdate();
|
/**
|
||||||
|
* Open the edit date dialog
|
||||||
|
*/
|
||||||
|
private async editDateSelection(selection: Selection) {
|
||||||
|
(<any>this.$refs.editDate).open(Array.from(selection.values()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the files app with the selected file (one)
|
||||||
|
* Opens a new window.
|
||||||
|
*/
|
||||||
|
private async viewInFolder(selection: Selection) {
|
||||||
|
if (selection.size !== 1) return;
|
||||||
|
|
||||||
|
const photo: IPhoto = selection.values().next().value;
|
||||||
|
const f = await dav.getFiles([photo.fileid]);
|
||||||
|
if (f.length === 0) return;
|
||||||
|
|
||||||
|
const file = f[0];
|
||||||
|
const dirPath = file.filename.split("/").slice(0, -1).join("/");
|
||||||
|
const url = generateUrl(
|
||||||
|
`/apps/files/?dir=${dirPath}&scrollto=${file.fileid}&openfile=${file.fileid}`
|
||||||
|
);
|
||||||
|
window.open(url, "_blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive the currently selected photos
|
||||||
|
*/
|
||||||
|
private async archiveSelection(selection: Selection) {
|
||||||
|
if (selection.size >= 100) {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
this.t(
|
||||||
|
"memories",
|
||||||
|
"You are about to touch a large number of files. Are you sure?"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
for await (let delIds of dav.archiveFilesByIds(
|
||||||
* Download the currently selected files
|
Array.from(selection.keys()),
|
||||||
*/
|
!this.routeIsArchive()
|
||||||
private async downloadSelection(selection: Selection) {
|
)) {
|
||||||
if (selection.size >= 100) {
|
delIds = delIds.filter((x) => x);
|
||||||
if (!confirm(this.t("memories", "You are about to download a large number of files. Are you sure?"))) {
|
if (delIds.length === 0) {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
const delPhotos = delIds.map((id) => selection.get(id));
|
||||||
await dav.downloadFilesByIds(Array.from(selection.keys()));
|
this.deletePhotos(delPhotos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Archive is not allowed only on folder routes */
|
||||||
|
private allowArchive() {
|
||||||
|
return this.$route.name !== "folders";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is archive route */
|
||||||
|
private routeIsArchive() {
|
||||||
|
return this.$route.name === "archive";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move selected photos to album
|
||||||
|
*/
|
||||||
|
private async addToAlbum(selection: Selection) {
|
||||||
|
(<any>this.$refs.addToAlbumModal).open(Array.from(selection.values()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove selected photos from album
|
||||||
|
*/
|
||||||
|
private async removeFromAlbum(selection: Selection) {
|
||||||
|
try {
|
||||||
|
this.updateLoading(1);
|
||||||
|
const user = this.$route.params.user;
|
||||||
|
const name = this.$route.params.name;
|
||||||
|
const gen = dav.removeFromAlbum(user, name, Array.from(selection.keys()));
|
||||||
|
for await (const delIds of gen) {
|
||||||
|
const delPhotos = delIds
|
||||||
|
.filter((p) => p)
|
||||||
|
.map((id) => selection.get(id));
|
||||||
|
this.deletePhotos(delPhotos);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showError(
|
||||||
|
e?.message || this.t("memories", "Could not remove photos from album")
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.updateLoading(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move selected photos to another person
|
||||||
|
*/
|
||||||
|
private async moveSelectionToPerson(selection: Selection) {
|
||||||
|
if (!this.config_showFaceRect) {
|
||||||
|
showError(
|
||||||
|
this.t(
|
||||||
|
"memories",
|
||||||
|
'You must enable "Mark person in preview" to use this feature'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(<any>this.$refs.faceMoveModal).open(Array.from(selection.values()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove currently selected photos from person
|
||||||
|
*/
|
||||||
|
private async removeSelectionFromPerson(selection: Selection) {
|
||||||
|
// Make sure route is valid
|
||||||
|
const { user, name } = this.$route.params;
|
||||||
|
if (this.$route.name !== "people" || !user || !name) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Check photo ownership
|
||||||
* Check if all files selected currently are favorites
|
if (this.$route.params.user !== getCurrentUser().uid) {
|
||||||
*/
|
showError(
|
||||||
private allSelectedFavorites(selection: Selection) {
|
this.t("memories", 'Only user "{user}" can update this person', {
|
||||||
return Array.from(selection.values()).every(p => p.flag & this.c.FLAG_IS_FAVORITE);
|
user,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Run query
|
||||||
* Favorite the currently selected photos
|
for await (let delIds of dav.removeFaceImages(
|
||||||
*/
|
user,
|
||||||
private async favoriteSelection(selection: Selection) {
|
name,
|
||||||
const val = !this.allSelectedFavorites(selection);
|
Array.from(selection.keys())
|
||||||
for await (const favIds of dav.favoriteFilesByIds(Array.from(selection.keys()), val)) {
|
)) {
|
||||||
favIds.forEach(id => {
|
const delPhotos = delIds.filter((x) => x).map((id) => selection.get(id));
|
||||||
const photo = selection.get(id);
|
this.deletePhotos(delPhotos);
|
||||||
if (!photo) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (val) {
|
|
||||||
photo.flag |= this.c.FLAG_IS_FAVORITE;
|
|
||||||
} else {
|
|
||||||
photo.flag &= ~this.c.FLAG_IS_FAVORITE;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.clearSelection();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete the currently selected photos
|
|
||||||
*/
|
|
||||||
private async deleteSelection(selection: Selection) {
|
|
||||||
if (selection.size >= 100) {
|
|
||||||
if (!confirm(this.t("memories", "You are about to delete a large number of files. Are you sure?"))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for await (const delIds of dav.deleteFilesByIds(Array.from(selection.keys()))) {
|
|
||||||
const delPhotos = delIds.map(id => selection.get(id));
|
|
||||||
this.deletePhotos(delPhotos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open the edit date dialog
|
|
||||||
*/
|
|
||||||
private async editDateSelection(selection: Selection) {
|
|
||||||
(<any>this.$refs.editDate).open(Array.from(selection.values()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open the files app with the selected file (one)
|
|
||||||
* Opens a new window.
|
|
||||||
*/
|
|
||||||
private async viewInFolder(selection: Selection) {
|
|
||||||
if (selection.size !== 1) return;
|
|
||||||
|
|
||||||
const photo: IPhoto = selection.values().next().value;
|
|
||||||
const f = await dav.getFiles([photo.fileid]);
|
|
||||||
if (f.length === 0) return;
|
|
||||||
|
|
||||||
const file = f[0];
|
|
||||||
const dirPath = file.filename.split('/').slice(0, -1).join('/')
|
|
||||||
const url = generateUrl(`/apps/files/?dir=${dirPath}&scrollto=${file.fileid}&openfile=${file.fileid}`);
|
|
||||||
window.open(url, '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Archive the currently selected photos
|
|
||||||
*/
|
|
||||||
private async archiveSelection(selection: Selection) {
|
|
||||||
if (selection.size >= 100) {
|
|
||||||
if (!confirm(this.t("memories", "You are about to touch a large number of files. Are you sure?"))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for await (let delIds of dav.archiveFilesByIds(Array.from(selection.keys()), !this.routeIsArchive())) {
|
|
||||||
delIds = delIds.filter(x => x);
|
|
||||||
if (delIds.length === 0) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const delPhotos = delIds.map(id => selection.get(id));
|
|
||||||
this.deletePhotos(delPhotos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Archive is not allowed only on folder routes */
|
|
||||||
private allowArchive() {
|
|
||||||
return this.$route.name !== 'folders';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Is archive route */
|
|
||||||
private routeIsArchive() {
|
|
||||||
return this.$route.name === 'archive';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move selected photos to album
|
|
||||||
*/
|
|
||||||
private async addToAlbum(selection: Selection) {
|
|
||||||
(<any>this.$refs.addToAlbumModal).open(Array.from(selection.values()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove selected photos from album
|
|
||||||
*/
|
|
||||||
private async removeFromAlbum(selection: Selection) {
|
|
||||||
try {
|
|
||||||
this.updateLoading(1);
|
|
||||||
const user = this.$route.params.user;
|
|
||||||
const name = this.$route.params.name;
|
|
||||||
const gen = dav.removeFromAlbum(user, name, Array.from(selection.keys()));
|
|
||||||
for await (const delIds of gen) {
|
|
||||||
const delPhotos = delIds.filter(p => p).map(id => selection.get(id));
|
|
||||||
this.deletePhotos(delPhotos);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
showError(e?.message || this.t("memories", "Could not remove photos from album"));
|
|
||||||
} finally {
|
|
||||||
this.updateLoading(-1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move selected photos to another person
|
|
||||||
*/
|
|
||||||
private async moveSelectionToPerson(selection: Selection) {
|
|
||||||
if (!this.config_showFaceRect) {
|
|
||||||
showError(this.t('memories', 'You must enable "Mark person in preview" to use this feature'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
(<any>this.$refs.faceMoveModal).open(Array.from(selection.values()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove currently selected photos from person
|
|
||||||
*/
|
|
||||||
private async removeSelectionFromPerson(selection: Selection) {
|
|
||||||
// Make sure route is valid
|
|
||||||
const { user, name } = this.$route.params;
|
|
||||||
if (this.$route.name !== "people" || !user || !name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check photo ownership
|
|
||||||
if (this.$route.params.user !== getCurrentUser().uid) {
|
|
||||||
showError(this.t('memories', 'Only user "{user}" can update this person', { user }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run query
|
|
||||||
for await (let delIds of dav.removeFaceImages(user, name, Array.from(selection.keys()))) {
|
|
||||||
const delPhotos = delIds.filter(x => x).map(id => selection.get(id));
|
|
||||||
this.deletePhotos(delPhotos);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.top-bar {
|
.top-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px; right: 60px;
|
top: 10px;
|
||||||
padding: 8px;
|
right: 60px;
|
||||||
width: 400px;
|
padding: 8px;
|
||||||
max-width: calc(100vw - 30px);
|
width: 400px;
|
||||||
background-color: var(--color-main-background);
|
max-width: calc(100vw - 30px);
|
||||||
box-shadow: 0 0 2px gray;
|
background-color: var(--color-main-background);
|
||||||
border-radius: 10px;
|
box-shadow: 0 0 2px gray;
|
||||||
opacity: 0.95;
|
border-radius: 10px;
|
||||||
display: flex;
|
opacity: 0.95;
|
||||||
vertical-align: middle;
|
display: flex;
|
||||||
z-index: 100;
|
vertical-align: middle;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
> .text {
|
> .text {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
line-height: 40px;
|
line-height: 40px;
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
top: 35px; right: 15px;
|
top: 35px;
|
||||||
}
|
right: 15px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -21,89 +21,103 @@
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<label for="timeline-path">{{ t('memories', 'Timeline Path') }}</label>
|
<label for="timeline-path">{{ t("memories", "Timeline Path") }}</label>
|
||||||
<input id="timeline-path"
|
<input
|
||||||
@click="chooseTimelinePath"
|
id="timeline-path"
|
||||||
v-model="config_timelinePath"
|
@click="chooseTimelinePath"
|
||||||
type="text">
|
v-model="config_timelinePath"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
|
||||||
<label for="folders-path">{{ t('memories', 'Folders Path') }}</label>
|
<label for="folders-path">{{ t("memories", "Folders Path") }}</label>
|
||||||
<input id="folders-path"
|
<input
|
||||||
@click="chooseFoldersPath"
|
id="folders-path"
|
||||||
v-model="config_foldersPath"
|
@click="chooseFoldersPath"
|
||||||
type="text">
|
v-model="config_foldersPath"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
|
||||||
<NcCheckboxRadioSwitch :checked.sync="config_showHidden"
|
<NcCheckboxRadioSwitch
|
||||||
@update:checked="updateShowHidden"
|
:checked.sync="config_showHidden"
|
||||||
type="switch">
|
@update:checked="updateShowHidden"
|
||||||
{{ t('memories', 'Show hidden folders') }}
|
type="switch"
|
||||||
</NcCheckboxRadioSwitch>
|
>
|
||||||
|
{{ t("memories", "Show hidden folders") }}
|
||||||
|
</NcCheckboxRadioSwitch>
|
||||||
|
|
||||||
<NcCheckboxRadioSwitch :checked.sync="config_squareThumbs"
|
<NcCheckboxRadioSwitch
|
||||||
@update:checked="updateSquareThumbs"
|
:checked.sync="config_squareThumbs"
|
||||||
type="switch">
|
@update:checked="updateSquareThumbs"
|
||||||
{{ t('memories', 'Square grid mode') }}
|
type="switch"
|
||||||
</NcCheckboxRadioSwitch>
|
>
|
||||||
</div>
|
{{ t("memories", "Square grid mode") }}
|
||||||
|
</NcCheckboxRadioSwitch>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
input[type=text] {
|
input[type="text"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Mixins } from 'vue-property-decorator';
|
import { Component, Mixins } from "vue-property-decorator";
|
||||||
import GlobalMixin from '../mixins/GlobalMixin';
|
import GlobalMixin from "../mixins/GlobalMixin";
|
||||||
|
|
||||||
import { getFilePickerBuilder } from '@nextcloud/dialogs'
|
import { getFilePickerBuilder } from "@nextcloud/dialogs";
|
||||||
import UserConfig from '../mixins/UserConfig'
|
import UserConfig from "../mixins/UserConfig";
|
||||||
|
|
||||||
import { NcCheckboxRadioSwitch } from '@nextcloud/vue'
|
import { NcCheckboxRadioSwitch } from "@nextcloud/vue";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
NcCheckboxRadioSwitch,
|
NcCheckboxRadioSwitch,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class Settings extends Mixins(UserConfig, GlobalMixin) {
|
export default class Settings extends Mixins(UserConfig, GlobalMixin) {
|
||||||
async chooseFolder(title: string, initial: string) {
|
async chooseFolder(title: string, initial: string) {
|
||||||
const picker = getFilePickerBuilder(title)
|
const picker = getFilePickerBuilder(title)
|
||||||
.setMultiSelect(false)
|
.setMultiSelect(false)
|
||||||
.setModal(true)
|
.setModal(true)
|
||||||
.setType(1)
|
.setType(1)
|
||||||
.addMimeTypeFilter('httpd/unix-directory')
|
.addMimeTypeFilter("httpd/unix-directory")
|
||||||
.allowDirectories()
|
.allowDirectories()
|
||||||
.startAt(initial)
|
.startAt(initial)
|
||||||
.build()
|
.build();
|
||||||
|
|
||||||
return await picker.pick();
|
return await picker.pick();
|
||||||
}
|
}
|
||||||
|
|
||||||
async chooseTimelinePath() {
|
async chooseTimelinePath() {
|
||||||
const newPath = await this.chooseFolder(this.t('memories', 'Choose the root of your timeline'), this.config_timelinePath);
|
const newPath = await this.chooseFolder(
|
||||||
if (newPath !== this.config_timelinePath) {
|
this.t("memories", "Choose the root of your timeline"),
|
||||||
this.config_timelinePath = newPath;
|
this.config_timelinePath
|
||||||
await this.updateSetting('timelinePath');
|
);
|
||||||
}
|
if (newPath !== this.config_timelinePath) {
|
||||||
|
this.config_timelinePath = newPath;
|
||||||
|
await this.updateSetting("timelinePath");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async chooseFoldersPath() {
|
async chooseFoldersPath() {
|
||||||
const newPath = await this.chooseFolder(this.t('memories', 'Choose the root for the folders view'), this.config_foldersPath);
|
const newPath = await this.chooseFolder(
|
||||||
if (newPath !== this.config_foldersPath) {
|
this.t("memories", "Choose the root for the folders view"),
|
||||||
this.config_foldersPath = newPath;
|
this.config_foldersPath
|
||||||
await this.updateSetting('foldersPath');
|
);
|
||||||
}
|
if (newPath !== this.config_foldersPath) {
|
||||||
|
this.config_foldersPath = newPath;
|
||||||
|
await this.updateSetting("foldersPath");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async updateSquareThumbs() {
|
async updateSquareThumbs() {
|
||||||
await this.updateSetting('squareThumbs');
|
await this.updateSetting("squareThumbs");
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateShowHidden() {
|
async updateShowHidden() {
|
||||||
await this.updateSetting('showHidden');
|
await this.updateSetting("showHidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
File diff suppressed because it is too large
Load Diff
|
@ -1,202 +1,238 @@
|
||||||
<template>
|
<template>
|
||||||
<router-link class="folder fill-block" :class="{
|
<router-link
|
||||||
hasPreview: previewFileInfos.length > 0,
|
class="folder fill-block"
|
||||||
onePreview: previewFileInfos.length === 1,
|
:class="{
|
||||||
hasError: error,
|
hasPreview: previewFileInfos.length > 0,
|
||||||
|
onePreview: previewFileInfos.length === 1,
|
||||||
|
hasError: error,
|
||||||
}"
|
}"
|
||||||
:to="target">
|
:to="target"
|
||||||
<div class="big-icon fill-block">
|
>
|
||||||
<FolderIcon class="icon" />
|
<div class="big-icon fill-block">
|
||||||
<div class="name">{{ data.name }}</div>
|
<FolderIcon class="icon" />
|
||||||
</div>
|
<div class="name">{{ data.name }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="previews fill-block">
|
<div class="previews fill-block">
|
||||||
<div class="img-outer" v-for="info of previewFileInfos" :key="info.fileid">
|
<div
|
||||||
<img
|
class="img-outer"
|
||||||
class="fill-block"
|
v-for="info of previewFileInfos"
|
||||||
:class="{ 'error': info.flag & c.FLAG_LOAD_FAIL }"
|
:key="info.fileid"
|
||||||
:key="'fpreview-' + info.fileid"
|
>
|
||||||
:src="getPreviewUrl(info.fileid, info.etag, true, 256)"
|
<img
|
||||||
@error="info.flag |= c.FLAG_LOAD_FAIL" />
|
class="fill-block"
|
||||||
</div>
|
:class="{ error: info.flag & c.FLAG_LOAD_FAIL }"
|
||||||
</div>
|
:key="'fpreview-' + info.fileid"
|
||||||
</router-link>
|
:src="getPreviewUrl(info.fileid, info.etag, true, 256)"
|
||||||
|
@error="info.flag |= c.FLAG_LOAD_FAIL"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Prop, Watch, Mixins } from 'vue-property-decorator';
|
import { Component, Prop, Watch, Mixins } from "vue-property-decorator";
|
||||||
import { IFileInfo, IFolder } from '../../types';
|
import { IFileInfo, IFolder } from "../../types";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
import UserConfig from '../../mixins/UserConfig';
|
import UserConfig from "../../mixins/UserConfig";
|
||||||
|
|
||||||
import * as dav from "../../services/DavRequests";
|
import * as dav from "../../services/DavRequests";
|
||||||
import { getPreviewUrl } from "../../services/FileUtils";
|
import { getPreviewUrl } from "../../services/FileUtils";
|
||||||
|
|
||||||
import FolderIcon from 'vue-material-design-icons/Folder.vue';
|
import FolderIcon from "vue-material-design-icons/Folder.vue";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class Folder extends Mixins(GlobalMixin, UserConfig) {
|
export default class Folder extends Mixins(GlobalMixin, UserConfig) {
|
||||||
@Prop() data: IFolder;
|
@Prop() data: IFolder;
|
||||||
|
|
||||||
// Separate property because the one on data isn't reactive
|
// Separate property because the one on data isn't reactive
|
||||||
private previewFileInfos: IFileInfo[] = [];
|
private previewFileInfos: IFileInfo[] = [];
|
||||||
|
|
||||||
// Error occured fetching thumbs
|
// Error occured fetching thumbs
|
||||||
private error = false;
|
private error = false;
|
||||||
|
|
||||||
/** Passthrough */
|
/** Passthrough */
|
||||||
private getPreviewUrl = getPreviewUrl;
|
private getPreviewUrl = getPreviewUrl;
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refreshPreviews();
|
this.refreshPreviews();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Watch("data")
|
||||||
|
dataChanged() {
|
||||||
|
this.refreshPreviews();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh previews */
|
||||||
|
refreshPreviews() {
|
||||||
|
// Reset state
|
||||||
|
this.error = false;
|
||||||
|
|
||||||
|
// Check if valid path present
|
||||||
|
if (!this.data.path) {
|
||||||
|
this.error = true;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Watch('data')
|
// Get preview infos
|
||||||
dataChanged() {
|
if (!this.data.previewFileInfos) {
|
||||||
this.refreshPreviews();
|
const folderPath = this.data.path.split("/").slice(3).join("/");
|
||||||
|
dav
|
||||||
|
.getFolderPreviewFileIds(folderPath, 4)
|
||||||
|
.then((fileInfos) => {
|
||||||
|
fileInfos = fileInfos.filter((f) => f.hasPreview);
|
||||||
|
fileInfos.forEach((f) => (f.flag = 0));
|
||||||
|
if (fileInfos.length > 0 && fileInfos.length < 4) {
|
||||||
|
fileInfos = [fileInfos[0]];
|
||||||
|
}
|
||||||
|
this.data.previewFileInfos = fileInfos;
|
||||||
|
this.previewFileInfos = fileInfos;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.data.previewFileInfos = [];
|
||||||
|
this.previewFileInfos = [];
|
||||||
|
|
||||||
|
// Something is wrong with the folder
|
||||||
|
// e.g. external storage not available
|
||||||
|
this.error = true;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.previewFileInfos = this.data.previewFileInfos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open folder */
|
||||||
|
get target() {
|
||||||
|
const path = this.data.path
|
||||||
|
.split("/")
|
||||||
|
.filter((x) => x)
|
||||||
|
.slice(2) as string[];
|
||||||
|
|
||||||
|
// Remove base path if present
|
||||||
|
const basePath = this.config_foldersPath.split("/").filter((x) => x);
|
||||||
|
if (
|
||||||
|
path.length >= basePath.length &&
|
||||||
|
path.slice(0, basePath.length).every((x, i) => x === basePath[i])
|
||||||
|
) {
|
||||||
|
path.splice(0, basePath.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Refresh previews */
|
return { name: "folders", params: { path: path as any } };
|
||||||
refreshPreviews() {
|
}
|
||||||
// Reset state
|
|
||||||
this.error = false;
|
|
||||||
|
|
||||||
// Check if valid path present
|
|
||||||
if (!this.data.path) {
|
|
||||||
this.error = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get preview infos
|
|
||||||
if (!this.data.previewFileInfos) {
|
|
||||||
const folderPath = this.data.path.split('/').slice(3).join('/');
|
|
||||||
dav.getFolderPreviewFileIds(folderPath, 4).then(fileInfos => {
|
|
||||||
fileInfos = fileInfos.filter(f => f.hasPreview);
|
|
||||||
fileInfos.forEach(f => f.flag = 0);
|
|
||||||
if (fileInfos.length > 0 && fileInfos.length < 4) {
|
|
||||||
fileInfos = [fileInfos[0]];
|
|
||||||
}
|
|
||||||
this.data.previewFileInfos = fileInfos;
|
|
||||||
this.previewFileInfos = fileInfos;
|
|
||||||
}).catch(() => {
|
|
||||||
this.data.previewFileInfos = [];
|
|
||||||
this.previewFileInfos = [];
|
|
||||||
|
|
||||||
// Something is wrong with the folder
|
|
||||||
// e.g. external storage not available
|
|
||||||
this.error = true;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.previewFileInfos = this.data.previewFileInfos;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open folder */
|
|
||||||
get target() {
|
|
||||||
const path = this.data.path.split('/').filter(x => x).slice(2) as string[];
|
|
||||||
|
|
||||||
// Remove base path if present
|
|
||||||
const basePath = this.config_foldersPath.split('/').filter(x => x);
|
|
||||||
if (path.length >= basePath.length && path.slice(0, basePath.length).every((x, i) => x === basePath[i])) {
|
|
||||||
path.splice(0, basePath.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { name: 'folders', params: { path: path as any }};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.folder {
|
.folder {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.big-icon {
|
.big-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 100;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
|
||||||
|
:deep .material-design-icon__svg {
|
||||||
|
width: 50%;
|
||||||
|
height: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .name {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 100;
|
width: 100%;
|
||||||
|
padding: 0 5%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.08em;
|
||||||
|
word-wrap: break-word;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-height: 35%;
|
||||||
|
line-height: 1em;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; left: 0;
|
top: 65%;
|
||||||
transition: opacity 0.2s ease-in-out;
|
}
|
||||||
|
|
||||||
:deep .material-design-icon__svg {
|
// Make it white if there is a preview
|
||||||
width: 50%; height: 50%;
|
.folder.hasPreview > & {
|
||||||
|
.folder-icon {
|
||||||
|
opacity: 1;
|
||||||
|
filter: invert(1) brightness(100);
|
||||||
}
|
}
|
||||||
|
.name {
|
||||||
> .name {
|
color: white;
|
||||||
cursor: pointer;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0 5%;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1.08em;
|
|
||||||
word-wrap: break-word;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-height: 35%;
|
|
||||||
line-height: 1em;
|
|
||||||
position: absolute;
|
|
||||||
top: 65%;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Make it white if there is a preview
|
// Show it on hover if not a preview
|
||||||
.folder.hasPreview > & {
|
.folder:hover > & > .folder-icon {
|
||||||
.folder-icon {
|
opacity: 0.8;
|
||||||
opacity: 1;
|
}
|
||||||
filter: invert(1) brightness(100);
|
.folder.hasPreview:hover > & {
|
||||||
}
|
opacity: 0;
|
||||||
.name { color: white; }
|
}
|
||||||
|
|
||||||
|
// Make it red if has an error
|
||||||
|
.folder.hasError > & {
|
||||||
|
.folder-icon {
|
||||||
|
filter: invert(12%) sepia(62%) saturate(5862%) hue-rotate(8deg)
|
||||||
|
brightness(103%) contrast(128%);
|
||||||
}
|
}
|
||||||
|
.name {
|
||||||
// Show it on hover if not a preview
|
color: #bb0000;
|
||||||
.folder:hover > & > .folder-icon { opacity: 0.8; }
|
|
||||||
.folder.hasPreview:hover > & { opacity: 0; }
|
|
||||||
|
|
||||||
// Make it red if has an error
|
|
||||||
.folder.hasError > & {
|
|
||||||
.folder-icon {
|
|
||||||
filter: invert(12%) sepia(62%) saturate(5862%) hue-rotate(8deg) brightness(103%) contrast(128%);
|
|
||||||
}
|
|
||||||
.name { color: #bb0000; }
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
> .folder-icon {
|
> .folder-icon {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
height: 90%; width: 100%;
|
height: 90%;
|
||||||
opacity: 0.3;
|
width: 100%;
|
||||||
}
|
opacity: 0.3;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.previews {
|
.previews {
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@media (max-width: 768px) { padding: 1px; }
|
@media (max-width: 768px) {
|
||||||
|
padding: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
> .img-outer {
|
> .img-outer {
|
||||||
background-color: var(--color-background-dark);
|
background-color: var(--color-background-dark);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
width: 50%;
|
width: 50%;
|
||||||
height: 50%;
|
height: 50%;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
||||||
.folder.onePreview > & {
|
.folder.onePreview > & {
|
||||||
width: 100%; height: 100%;
|
width: 100%;
|
||||||
}
|
height: 100%;
|
||||||
|
|
||||||
> img {
|
|
||||||
object-fit: cover;
|
|
||||||
padding: 0;
|
|
||||||
filter: brightness(50%);
|
|
||||||
transition: filter 0.2s ease-in-out;
|
|
||||||
|
|
||||||
&.error { display: none; }
|
|
||||||
.folder:hover & { filter: brightness(100%); }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> img {
|
||||||
|
object-fit: cover;
|
||||||
|
padding: 0;
|
||||||
|
filter: brightness(50%);
|
||||||
|
transition: filter 0.2s ease-in-out;
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.folder:hover & {
|
||||||
|
filter: brightness(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,194 +1,210 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="p-outer fill-block"
|
<div
|
||||||
:class="{
|
class="p-outer fill-block"
|
||||||
'selected': (data.flag & c.FLAG_SELECTED),
|
:class="{
|
||||||
'placeholder': (data.flag & c.FLAG_PLACEHOLDER),
|
selected: data.flag & c.FLAG_SELECTED,
|
||||||
'leaving': (data.flag & c.FLAG_LEAVING),
|
placeholder: data.flag & c.FLAG_PLACEHOLDER,
|
||||||
'error': (data.flag & c.FLAG_LOAD_FAIL),
|
leaving: data.flag & c.FLAG_LEAVING,
|
||||||
}">
|
error: data.flag & c.FLAG_LOAD_FAIL,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
:size="15"
|
||||||
|
class="select"
|
||||||
|
v-if="!(data.flag & c.FLAG_PLACEHOLDER)"
|
||||||
|
@click="toggleSelect"
|
||||||
|
/>
|
||||||
|
|
||||||
<Check :size="15" class="select"
|
<Video :size="20" v-if="data.flag & c.FLAG_IS_VIDEO" />
|
||||||
v-if="!(data.flag & c.FLAG_PLACEHOLDER)"
|
<Star :size="20" v-if="data.flag & c.FLAG_IS_FAVORITE" />
|
||||||
@click="toggleSelect" />
|
|
||||||
|
|
||||||
<Video :size="20" v-if="data.flag & c.FLAG_IS_VIDEO" />
|
<div
|
||||||
<Star :size="20" v-if="data.flag & c.FLAG_IS_FAVORITE" />
|
class="img-outer fill-block"
|
||||||
|
@click="emitClick"
|
||||||
<div class="img-outer fill-block"
|
@contextmenu="contextmenu"
|
||||||
@click="emitClick"
|
@touchstart="touchstart"
|
||||||
@contextmenu="contextmenu"
|
@touchmove="touchend"
|
||||||
@touchstart="touchstart"
|
@touchend="touchend"
|
||||||
@touchmove="touchend"
|
@touchcancel="touchend"
|
||||||
@touchend="touchend"
|
>
|
||||||
@touchcancel="touchend" >
|
<img
|
||||||
<img
|
ref="img"
|
||||||
ref="img"
|
class="fill-block"
|
||||||
class="fill-block"
|
:src="src"
|
||||||
:src="src"
|
:key="data.fileid"
|
||||||
:key="data.fileid"
|
@load="load"
|
||||||
@load="load"
|
@error="error"
|
||||||
@error="error" />
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Prop, Emit, Mixins, Watch } from 'vue-property-decorator';
|
import { Component, Prop, Emit, Mixins, Watch } from "vue-property-decorator";
|
||||||
import { IDay, IPhoto } from "../../types";
|
import { IDay, IPhoto } from "../../types";
|
||||||
|
|
||||||
import { getPreviewUrl } from "../../services/FileUtils";
|
import { getPreviewUrl } from "../../services/FileUtils";
|
||||||
import errorsvg from "../../assets/error.svg";
|
import errorsvg from "../../assets/error.svg";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
|
|
||||||
import Check from 'vue-material-design-icons/Check.vue';
|
import Check from "vue-material-design-icons/Check.vue";
|
||||||
import Video from 'vue-material-design-icons/Video.vue';
|
import Video from "vue-material-design-icons/Video.vue";
|
||||||
import Star from 'vue-material-design-icons/Star.vue';
|
import Star from "vue-material-design-icons/Star.vue";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
Check,
|
Check,
|
||||||
Video,
|
Video,
|
||||||
Star,
|
Star,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class Photo extends Mixins(GlobalMixin) {
|
export default class Photo extends Mixins(GlobalMixin) {
|
||||||
private touchTimer = 0;
|
private touchTimer = 0;
|
||||||
private src = null;
|
private src = null;
|
||||||
private hasFaceRect = false;
|
private hasFaceRect = false;
|
||||||
|
|
||||||
@Prop() data: IPhoto;
|
@Prop() data: IPhoto;
|
||||||
@Prop() day: IDay;
|
@Prop() day: IDay;
|
||||||
|
|
||||||
@Emit('select') emitSelect(data: IPhoto) {}
|
@Emit("select") emitSelect(data: IPhoto) {}
|
||||||
@Emit('click') emitClick() {}
|
@Emit("click") emitClick() {}
|
||||||
|
|
||||||
@Watch('data')
|
@Watch("data")
|
||||||
onDataChange(newData: IPhoto, oldData: IPhoto) {
|
onDataChange(newData: IPhoto, oldData: IPhoto) {
|
||||||
// Copy flags relevant to this component
|
// Copy flags relevant to this component
|
||||||
if (oldData && newData) {
|
if (oldData && newData) {
|
||||||
newData.flag |= oldData.flag & (this.c.FLAG_SELECTED | this.c.FLAG_LOAD_FAIL);
|
newData.flag |=
|
||||||
}
|
oldData.flag & (this.c.FLAG_SELECTED | this.c.FLAG_LOAD_FAIL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.hasFaceRect = false;
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
this.src = await this.getSrc();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get src for image to show */
|
||||||
|
async getSrc() {
|
||||||
|
if (this.data.flag & this.c.FLAG_PLACEHOLDER) {
|
||||||
|
return null;
|
||||||
|
} else if (this.data.flag & this.c.FLAG_LOAD_FAIL) {
|
||||||
|
return errorsvg;
|
||||||
|
} else {
|
||||||
|
return this.url();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get url of the photo */
|
||||||
|
url() {
|
||||||
|
let base = 256;
|
||||||
|
|
||||||
|
// Check if displayed size is larger than the image
|
||||||
|
if (this.data.dispH > base * 0.9 && this.data.dispW > base * 0.9) {
|
||||||
|
// Get a bigger image
|
||||||
|
// 1. No trickery here, just get one size bigger. This is to
|
||||||
|
// ensure that the images can be cached even after reflow.
|
||||||
|
// 2. Nextcloud only allows 4**x sized images, so technically
|
||||||
|
// this ends up being equivalent to 1024x1024.
|
||||||
|
base = 512;
|
||||||
}
|
}
|
||||||
|
|
||||||
mounted() {
|
// Make the shorter dimension equal to base
|
||||||
this.hasFaceRect = false;
|
let size = base;
|
||||||
this.refresh();
|
if (this.data.w && this.data.h) {
|
||||||
|
size =
|
||||||
|
Math.floor(
|
||||||
|
(base * Math.max(this.data.w, this.data.h)) /
|
||||||
|
Math.min(this.data.w, this.data.h)
|
||||||
|
) - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
async refresh() {
|
return getPreviewUrl(this.data.fileid, this.data.etag, false, size);
|
||||||
this.src = await this.getSrc();
|
}
|
||||||
}
|
|
||||||
|
/** Set src with overlay face rect */
|
||||||
/** Get src for image to show */
|
async addFaceRect() {
|
||||||
async getSrc() {
|
if (!this.data.facerect || this.hasFaceRect) return;
|
||||||
if (this.data.flag & this.c.FLAG_PLACEHOLDER) {
|
this.hasFaceRect = true;
|
||||||
return null;
|
|
||||||
} else if (this.data.flag & this.c.FLAG_LOAD_FAIL) {
|
const canvas = document.createElement("canvas");
|
||||||
return errorsvg;
|
const context = canvas.getContext("2d");
|
||||||
} else {
|
const img = this.$refs.img as HTMLImageElement;
|
||||||
return this.url();
|
|
||||||
}
|
canvas.width = img.naturalWidth;
|
||||||
}
|
canvas.height = img.naturalHeight;
|
||||||
|
context.drawImage(img, 0, 0);
|
||||||
/** Get url of the photo */
|
context.strokeStyle = "#00ff00";
|
||||||
url() {
|
context.lineWidth = 2;
|
||||||
let base = 256;
|
context.strokeRect(
|
||||||
|
this.data.facerect.x * img.naturalWidth,
|
||||||
// Check if displayed size is larger than the image
|
this.data.facerect.y * img.naturalHeight,
|
||||||
if (this.data.dispH > base * 0.9 && this.data.dispW > base * 0.9) {
|
this.data.facerect.w * img.naturalWidth,
|
||||||
// Get a bigger image
|
this.data.facerect.h * img.naturalHeight
|
||||||
// 1. No trickery here, just get one size bigger. This is to
|
);
|
||||||
// ensure that the images can be cached even after reflow.
|
|
||||||
// 2. Nextcloud only allows 4**x sized images, so technically
|
canvas.toBlob(
|
||||||
// this ends up being equivalent to 1024x1024.
|
(blob) => {
|
||||||
base = 512;
|
this.src = URL.createObjectURL(blob);
|
||||||
}
|
},
|
||||||
|
"image/jpeg",
|
||||||
// Make the shorter dimension equal to base
|
0.95
|
||||||
let size = base;
|
);
|
||||||
if (this.data.w && this.data.h) {
|
}
|
||||||
size = Math.floor(base * Math.max(this.data.w, this.data.h) / Math.min(this.data.w, this.data.h)) - 1;
|
|
||||||
}
|
/** Post load tasks */
|
||||||
|
load() {
|
||||||
return getPreviewUrl(this.data.fileid, this.data.etag, false, size)
|
this.addFaceRect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set src with overlay face rect */
|
/** Error in loading image */
|
||||||
async addFaceRect() {
|
error(e: any) {
|
||||||
if (!this.data.facerect || this.hasFaceRect) return;
|
this.data.flag |= this.c.FLAG_LOAD_FAIL;
|
||||||
this.hasFaceRect = true;
|
this.refresh();
|
||||||
|
}
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
const context = canvas.getContext('2d');
|
/** Clear timers */
|
||||||
const img = this.$refs.img as HTMLImageElement;
|
beforeUnmount() {
|
||||||
|
clearTimeout(this.touchTimer);
|
||||||
canvas.width = img.naturalWidth;
|
}
|
||||||
canvas.height = img.naturalHeight;
|
|
||||||
context.drawImage(img, 0, 0);
|
toggleSelect() {
|
||||||
context.strokeStyle = '#00ff00';
|
if (this.data.flag & this.c.FLAG_PLACEHOLDER) return;
|
||||||
context.lineWidth = 2;
|
this.emitSelect(this.data);
|
||||||
context.strokeRect(
|
}
|
||||||
this.data.facerect.x * img.naturalWidth,
|
|
||||||
this.data.facerect.y * img.naturalHeight,
|
touchstart() {
|
||||||
this.data.facerect.w * img.naturalWidth,
|
this.touchTimer = window.setTimeout(() => {
|
||||||
this.data.facerect.h * img.naturalHeight,
|
this.toggleSelect();
|
||||||
);
|
this.touchTimer = 0;
|
||||||
|
}, 600);
|
||||||
canvas.toBlob((blob) => {
|
}
|
||||||
this.src = URL.createObjectURL(blob);
|
|
||||||
}, 'image/jpeg', 0.95)
|
contextmenu(e: Event) {
|
||||||
}
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
/** Post load tasks */
|
}
|
||||||
load() {
|
|
||||||
this.addFaceRect();
|
touchend() {
|
||||||
}
|
if (this.touchTimer) {
|
||||||
|
clearTimeout(this.touchTimer);
|
||||||
/** Error in loading image */
|
this.touchTimer = 0;
|
||||||
error(e: any) {
|
|
||||||
this.data.flag |= this.c.FLAG_LOAD_FAIL;
|
|
||||||
this.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clear timers */
|
|
||||||
beforeUnmount() {
|
|
||||||
clearTimeout(this.touchTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleSelect() {
|
|
||||||
if (this.data.flag & this.c.FLAG_PLACEHOLDER) return;
|
|
||||||
this.emitSelect(this.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
touchstart() {
|
|
||||||
this.touchTimer = window.setTimeout(() => {
|
|
||||||
this.toggleSelect();
|
|
||||||
this.touchTimer = 0;
|
|
||||||
}, 600);
|
|
||||||
}
|
|
||||||
|
|
||||||
contextmenu(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
touchend() {
|
|
||||||
if (this.touchTimer) {
|
|
||||||
clearTimeout(this.touchTimer);
|
|
||||||
this.touchTimer = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
/* Container and selection */
|
/* Container and selection */
|
||||||
.p-outer {
|
.p-outer {
|
||||||
&.leaving {
|
&.leaving {
|
||||||
transition: all 0.2s ease-in;
|
transition: all 0.2s ease-in;
|
||||||
transform: scale(0.9);
|
transform: scale(0.9);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Distance of icon from border
|
// Distance of icon from border
|
||||||
|
@ -196,56 +212,75 @@ $icon-dist: min(10px, 6%);
|
||||||
|
|
||||||
/* Extra icons */
|
/* Extra icons */
|
||||||
.check-icon.select {
|
.check-icon.select {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: $icon-dist; left: $icon-dist;
|
top: $icon-dist;
|
||||||
z-index: 100;
|
left: $icon-dist;
|
||||||
background-color: var(--color-main-background);
|
z-index: 100;
|
||||||
border-radius: 50%;
|
background-color: var(--color-main-background);
|
||||||
cursor: pointer;
|
border-radius: 50%;
|
||||||
display: none;
|
cursor: pointer;
|
||||||
|
display: none;
|
||||||
|
|
||||||
.p-outer:hover > & { display: flex; }
|
.p-outer:hover > & {
|
||||||
.selected > & { display: flex; filter: invert(1); }
|
display: flex;
|
||||||
|
}
|
||||||
|
.selected > & {
|
||||||
|
display: flex;
|
||||||
|
filter: invert(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.video-icon, .star-icon {
|
.video-icon,
|
||||||
position: absolute;
|
.star-icon {
|
||||||
z-index: 100;
|
position: absolute;
|
||||||
pointer-events: none;
|
z-index: 100;
|
||||||
filter: invert(1) brightness(100);
|
pointer-events: none;
|
||||||
|
filter: invert(1) brightness(100);
|
||||||
}
|
}
|
||||||
.video-icon {
|
.video-icon {
|
||||||
top: $icon-dist; right: $icon-dist;
|
top: $icon-dist;
|
||||||
|
right: $icon-dist;
|
||||||
}
|
}
|
||||||
.star-icon {
|
.star-icon {
|
||||||
bottom: $icon-dist; left: $icon-dist;
|
bottom: $icon-dist;
|
||||||
|
left: $icon-dist;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Actual image */
|
/* Actual image */
|
||||||
div.img-outer {
|
div.img-outer {
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@media (max-width: 768px) { padding: 1px; }
|
@media (max-width: 768px) {
|
||||||
|
padding: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
transition: padding 0.1s ease;
|
transition: padding 0.1s ease;
|
||||||
background-clip: content-box, padding-box;
|
background-clip: content-box, padding-box;
|
||||||
background-color: var(--color-background-dark);
|
background-color: var(--color-background-dark);
|
||||||
|
|
||||||
.selected > & { padding: calc($icon-dist - 2px); }
|
.selected > & {
|
||||||
|
padding: calc($icon-dist - 2px);
|
||||||
|
}
|
||||||
|
|
||||||
> img {
|
> img {
|
||||||
filter: contrast(1.05); // most real world images are a bit overexposed
|
filter: contrast(1.05); // most real world images are a bit overexposed
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: box-shadow 0.1s ease;
|
transition: box-shadow 0.1s ease;
|
||||||
|
|
||||||
.selected > & { box-shadow: 0 0 4px 2px var(--color-primary); }
|
.selected > & {
|
||||||
.p-outer.placeholder > & { display: none; }
|
box-shadow: 0 0 4px 2px var(--color-primary);
|
||||||
.p-outer.error & { object-fit: contain; }
|
|
||||||
}
|
}
|
||||||
|
.p-outer.placeholder > & {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.p-outer.error & {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,255 +1,276 @@
|
||||||
<template>
|
<template>
|
||||||
<router-link class="tag fill-block" :class="{
|
<router-link
|
||||||
hasPreview: previews.length > 0,
|
class="tag fill-block"
|
||||||
onePreview: previews.length === 1,
|
:class="{
|
||||||
hasError: error,
|
hasPreview: previews.length > 0,
|
||||||
isFace: isFace,
|
onePreview: previews.length === 1,
|
||||||
|
hasError: error,
|
||||||
|
isFace: isFace,
|
||||||
}"
|
}"
|
||||||
:to="target"
|
:to="target"
|
||||||
@click.native="openTag(data)">
|
@click.native="openTag(data)"
|
||||||
|
>
|
||||||
|
<div class="bbl">
|
||||||
|
<NcCounterBubble> {{ data.count }} </NcCounterBubble>
|
||||||
|
</div>
|
||||||
|
<div class="name">
|
||||||
|
{{ data.name }}
|
||||||
|
<span class="subtitle" v-if="subtitle"> {{ subtitle }} </span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="bbl"> <NcCounterBubble> {{ data.count }} </NcCounterBubble> </div>
|
<div class="previews fill-block" ref="previews">
|
||||||
<div class="name">
|
<div class="img-outer" v-for="info of previews" :key="info.fileid">
|
||||||
{{ data.name }}
|
<img
|
||||||
<span class="subtitle" v-if="subtitle"> {{ subtitle }} </span>
|
class="fill-block"
|
||||||
</div>
|
:class="{ error: info.flag & c.FLAG_LOAD_FAIL }"
|
||||||
|
:key="'fpreview-' + info.fileid"
|
||||||
<div class="previews fill-block" ref="previews">
|
:src="getPreviewUrl(info.fileid, info.etag)"
|
||||||
<div class="img-outer" v-for="info of previews" :key="info.fileid">
|
@error="info.flag |= c.FLAG_LOAD_FAIL"
|
||||||
<img
|
/>
|
||||||
class="fill-block"
|
</div>
|
||||||
:class="{ 'error': info.flag & c.FLAG_LOAD_FAIL }"
|
</div>
|
||||||
:key="'fpreview-' + info.fileid"
|
</router-link>
|
||||||
:src="getPreviewUrl(info.fileid, info.etag)"
|
|
||||||
@error="info.flag |= c.FLAG_LOAD_FAIL" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</router-link>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Prop, Watch, Mixins, Emit } from 'vue-property-decorator';
|
import { Component, Prop, Watch, Mixins, Emit } from "vue-property-decorator";
|
||||||
import { IAlbum, IPhoto, ITag } from '../../types';
|
import { IAlbum, IPhoto, ITag } from "../../types";
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from "@nextcloud/router";
|
||||||
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';
|
import { NcCounterBubble } from "@nextcloud/vue";
|
||||||
import axios from '@nextcloud/axios';
|
import axios from "@nextcloud/axios";
|
||||||
import * as utils from "../../services/Utils";
|
import * as utils from "../../services/Utils";
|
||||||
|
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
import { constants } from '../../services/Utils';
|
import { constants } from "../../services/Utils";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
NcCounterBubble,
|
NcCounterBubble,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class Tag extends Mixins(GlobalMixin) {
|
export default class Tag extends Mixins(GlobalMixin) {
|
||||||
@Prop() data: ITag;
|
@Prop() data: ITag;
|
||||||
@Prop() noNavigate: boolean;
|
@Prop() noNavigate: boolean;
|
||||||
|
|
||||||
// Separate property because the one on data isn't reactive
|
// Separate property because the one on data isn't reactive
|
||||||
private previews: IPhoto[] = [];
|
private previews: IPhoto[] = [];
|
||||||
|
|
||||||
// Error occured fetching thumbs
|
// Error occured fetching thumbs
|
||||||
private error = false;
|
private error = false;
|
||||||
|
|
||||||
// Smaller subtitle
|
// Smaller subtitle
|
||||||
private subtitle = '';
|
private subtitle = "";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open tag event
|
* Open tag event
|
||||||
* Unless noNavigate is set, the tag will be opened
|
* Unless noNavigate is set, the tag will be opened
|
||||||
*/
|
*/
|
||||||
@Emit('open')
|
@Emit("open")
|
||||||
openTag(tag: ITag) {}
|
openTag(tag: ITag) {}
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refreshPreviews();
|
this.refreshPreviews();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Watch("data")
|
||||||
|
dataChanged() {
|
||||||
|
this.refreshPreviews();
|
||||||
|
}
|
||||||
|
|
||||||
|
getPreviewUrl(fileid: number, etag: string) {
|
||||||
|
if (this.isFace) {
|
||||||
|
return generateUrl(
|
||||||
|
"/apps/memories/api/faces/preview/" + this.data.fileid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return getPreviewUrl(fileid, etag, true, 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isFace() {
|
||||||
|
return this.data.flag & constants.c.FLAG_IS_FACE;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isAlbum() {
|
||||||
|
return this.data.flag & constants.c.FLAG_IS_ALBUM;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshPreviews() {
|
||||||
|
// Reset state
|
||||||
|
this.error = false;
|
||||||
|
this.subtitle = "";
|
||||||
|
|
||||||
|
// Add dummy preview if face
|
||||||
|
if (this.isFace) {
|
||||||
|
this.previews = [{ fileid: 0, etag: "", flag: 0 }];
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Watch('data')
|
// Add preview from last photo if album
|
||||||
dataChanged() {
|
if (this.isAlbum) {
|
||||||
this.refreshPreviews();
|
const album = this.data as IAlbum;
|
||||||
|
if (album.last_added_photo > 0) {
|
||||||
|
this.previews = [{ fileid: album.last_added_photo, etag: "", flag: 0 }];
|
||||||
|
}
|
||||||
|
if (album.user !== getCurrentUser()?.uid) {
|
||||||
|
this.subtitle = `(${album.user})`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
getPreviewUrl(fileid: number, etag: string) {
|
// Look for previews
|
||||||
if (this.isFace) {
|
if (!this.data.previews) {
|
||||||
return generateUrl('/apps/memories/api/faces/preview/' + this.data.fileid);
|
try {
|
||||||
|
const todayDayId = utils.dateToDayId(new Date());
|
||||||
|
const url = generateUrl(
|
||||||
|
`/apps/memories/api/tag-previews?tag=${this.data.name}`
|
||||||
|
);
|
||||||
|
const cacheUrl = `${url}&today=${Math.floor(todayDayId / 10)}`;
|
||||||
|
const cache = await utils.getCachedData(cacheUrl);
|
||||||
|
if (cache) {
|
||||||
|
this.data.previews = cache as any;
|
||||||
|
} else {
|
||||||
|
const res = await axios.get(url);
|
||||||
|
this.data.previews = res.data;
|
||||||
|
|
||||||
|
// Cache only if >= 4 previews
|
||||||
|
if (this.data.previews.length >= 4) {
|
||||||
|
utils.cacheData(cacheUrl, res.data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return getPreviewUrl(fileid, etag, true, 256);
|
} catch (e) {
|
||||||
|
this.error = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get isFace() {
|
// Reset flag
|
||||||
return this.data.flag & constants.c.FLAG_IS_FACE;
|
this.data.previews.forEach((p) => (p.flag = 0));
|
||||||
|
|
||||||
|
// Get 4 or 1 preview(s)
|
||||||
|
let data = this.data.previews;
|
||||||
|
if (data.length < 4) {
|
||||||
|
data = data.slice(0, 1);
|
||||||
|
}
|
||||||
|
this.previews = data;
|
||||||
|
|
||||||
|
this.error = this.previews.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Target URL to navigate to */
|
||||||
|
get target() {
|
||||||
|
if (this.noNavigate) return {};
|
||||||
|
|
||||||
|
if (this.isFace) {
|
||||||
|
const name = this.data.name || this.data.fileid.toString();
|
||||||
|
const user = this.data.user_id;
|
||||||
|
return { name: "people", params: { name, user } };
|
||||||
}
|
}
|
||||||
|
|
||||||
get isAlbum() {
|
if (this.isAlbum) {
|
||||||
return this.data.flag & constants.c.FLAG_IS_ALBUM;
|
const user = (<IAlbum>this.data).user;
|
||||||
|
const name = this.data.name;
|
||||||
|
return { name: "albums", params: { user, name } };
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshPreviews() {
|
return { name: "tags", params: { name: this.data.name } };
|
||||||
// Reset state
|
}
|
||||||
this.error = false;
|
|
||||||
this.subtitle = '';
|
|
||||||
|
|
||||||
// Add dummy preview if face
|
|
||||||
if (this.isFace) {
|
|
||||||
this.previews = [{ fileid: 0, etag: '', flag: 0 }];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add preview from last photo if album
|
|
||||||
if (this.isAlbum) {
|
|
||||||
const album = this.data as IAlbum;
|
|
||||||
if (album.last_added_photo > 0) {
|
|
||||||
this.previews = [{ fileid: album.last_added_photo, etag: '', flag: 0 }];
|
|
||||||
}
|
|
||||||
if (album.user !== getCurrentUser()?.uid) {
|
|
||||||
this.subtitle = `(${album.user})`;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look for previews
|
|
||||||
if (!this.data.previews) {
|
|
||||||
try {
|
|
||||||
const todayDayId = utils.dateToDayId(new Date());
|
|
||||||
const url = generateUrl(`/apps/memories/api/tag-previews?tag=${this.data.name}`);
|
|
||||||
const cacheUrl = `${url}&today=${Math.floor(todayDayId / 10)}`;
|
|
||||||
const cache = await utils.getCachedData(cacheUrl);
|
|
||||||
if (cache) {
|
|
||||||
this.data.previews = cache as any;
|
|
||||||
} else {
|
|
||||||
const res = await axios.get(url);
|
|
||||||
this.data.previews = res.data;
|
|
||||||
|
|
||||||
// Cache only if >= 4 previews
|
|
||||||
if (this.data.previews.length >= 4) {
|
|
||||||
utils.cacheData(cacheUrl, res.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
this.error = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset flag
|
|
||||||
this.data.previews.forEach((p) => p.flag = 0);
|
|
||||||
|
|
||||||
// Get 4 or 1 preview(s)
|
|
||||||
let data = this.data.previews;
|
|
||||||
if (data.length < 4) {
|
|
||||||
data = data.slice(0, 1);
|
|
||||||
}
|
|
||||||
this.previews = data;
|
|
||||||
|
|
||||||
this.error = this.previews.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Target URL to navigate to */
|
|
||||||
get target() {
|
|
||||||
if (this.noNavigate) return {};
|
|
||||||
|
|
||||||
if (this.isFace) {
|
|
||||||
const name = this.data.name || this.data.fileid.toString();
|
|
||||||
const user = this.data.user_id;
|
|
||||||
return { name: 'people', params: { name, user }};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isAlbum) {
|
|
||||||
const user = (<IAlbum>this.data).user;
|
|
||||||
const name = this.data.name;
|
|
||||||
return { name: 'albums', params: { user, name }};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { name: 'tags', params: { name: this.data.name }};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.tag, .name, .bubble, img {
|
.tag,
|
||||||
cursor: pointer;
|
.name,
|
||||||
|
.bubble,
|
||||||
|
img {
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get rid of color of the bubble
|
// Get rid of color of the bubble
|
||||||
.tag .bbl :deep .counter-bubble__counter {
|
.tag .bbl :deep .counter-bubble__counter {
|
||||||
color: unset !important;
|
color: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%; width: 100%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
width: 100%;
|
||||||
color: white;
|
transform: translateY(-50%);
|
||||||
padding: 0 5%;
|
color: white;
|
||||||
text-align: center;
|
padding: 0 5%;
|
||||||
font-size: 1.2em;
|
text-align: center;
|
||||||
word-wrap: break-word;
|
font-size: 1.2em;
|
||||||
text-overflow: ellipsis;
|
word-wrap: break-word;
|
||||||
line-height: 1em;
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1em;
|
||||||
|
|
||||||
> .subtitle {
|
> .subtitle {
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.isFace > & {
|
.isFace > & {
|
||||||
top: unset;
|
top: unset;
|
||||||
bottom: 10%;
|
bottom: 10%;
|
||||||
transform: unset;
|
transform: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bbl {
|
.bbl {
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px; right: 5px;
|
top: 6px;
|
||||||
|
right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.previews {
|
.previews {
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@media (max-width: 768px) { padding: 1px; }
|
@media (max-width: 768px) {
|
||||||
|
padding: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.tag:not(.hasPreview) & {
|
.tag:not(.hasPreview) & {
|
||||||
background-color: #444;
|
background-color: #444;
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .img-outer {
|
||||||
|
background-color: var(--color-background-dark);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 50%;
|
||||||
|
height: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.tag.onePreview > & {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .img-outer {
|
> img {
|
||||||
background-color: var(--color-background-dark);
|
object-fit: cover;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
filter: brightness(60%);
|
||||||
width: 50%;
|
cursor: pointer;
|
||||||
height: 50%;
|
transition: filter 0.2s ease-in-out;
|
||||||
overflow: hidden;
|
|
||||||
display: inline-block;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
.tag.onePreview > & {
|
&.error {
|
||||||
width: 100%; height: 100%;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.tag:hover & {
|
||||||
> img {
|
filter: brightness(100%);
|
||||||
object-fit: cover;
|
}
|
||||||
padding: 0;
|
|
||||||
filter: brightness(60%);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: filter 0.2s ease-in-out;
|
|
||||||
|
|
||||||
&.error { display: none; }
|
|
||||||
.tag:hover & { filter: brightness(100%); }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,84 +1,92 @@
|
||||||
<template>
|
<template>
|
||||||
<Modal @close="close" size="normal" v-if="show">
|
<Modal @close="close" size="normal" v-if="show">
|
||||||
<template #title>
|
<template #title>
|
||||||
{{ t('memories', 'Add to album') }}
|
{{ t("memories", "Add to album") }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="outer">
|
<div class="outer">
|
||||||
<AlbumPicker @select="selectAlbum" />
|
<AlbumPicker @select="selectAlbum" />
|
||||||
|
|
||||||
<div v-if="processing" class="info-pad">
|
<div v-if="processing" class="info-pad">
|
||||||
{{ t('memories', 'Processing … {n}/{m}', {
|
{{
|
||||||
n: photosDone,
|
t("memories", "Processing … {n}/{m}", {
|
||||||
m: photos.length,
|
n: photosDone,
|
||||||
}) }}
|
m: photos.length,
|
||||||
</div>
|
})
|
||||||
</div>
|
}}
|
||||||
</Modal>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Emit, Mixins } from 'vue-property-decorator';
|
import { Component, Emit, Mixins } from "vue-property-decorator";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
|
|
||||||
import * as dav from '../../services/DavRequests';
|
import * as dav from "../../services/DavRequests";
|
||||||
import { showInfo } from '@nextcloud/dialogs';
|
import { showInfo } from "@nextcloud/dialogs";
|
||||||
import { IAlbum, IPhoto } from '../../types';
|
import { IAlbum, IPhoto } from "../../types";
|
||||||
|
|
||||||
import AlbumPicker from './AlbumPicker.vue';
|
import AlbumPicker from "./AlbumPicker.vue";
|
||||||
import Modal from './Modal.vue';
|
import Modal from "./Modal.vue";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
Modal,
|
Modal,
|
||||||
AlbumPicker,
|
AlbumPicker,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
export default class AddToAlbumModal extends Mixins(GlobalMixin) {
|
export default class AddToAlbumModal extends Mixins(GlobalMixin) {
|
||||||
private show = false;
|
private show = false;
|
||||||
private photos: IPhoto[] = [];
|
private photos: IPhoto[] = [];
|
||||||
private photosDone: number = 0;
|
private photosDone: number = 0;
|
||||||
private processing: boolean = false;
|
private processing: boolean = false;
|
||||||
|
|
||||||
public open(photos: IPhoto[]) {
|
public open(photos: IPhoto[]) {
|
||||||
this.photosDone = 0;
|
this.photosDone = 0;
|
||||||
this.processing = false;
|
this.processing = false;
|
||||||
this.show = true;
|
this.show = true;
|
||||||
this.photos = photos;
|
this.photos = photos;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Emit("added")
|
||||||
|
public added(photos: IPhoto[]) {}
|
||||||
|
|
||||||
|
@Emit("close")
|
||||||
|
public close() {
|
||||||
|
this.photos = [];
|
||||||
|
this.processing = false;
|
||||||
|
this.show = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async selectAlbum(album: IAlbum) {
|
||||||
|
const name = album.name || album.album_id.toString();
|
||||||
|
const gen = dav.addToAlbum(
|
||||||
|
album.user,
|
||||||
|
name,
|
||||||
|
this.photos.map((p) => p.fileid)
|
||||||
|
);
|
||||||
|
this.processing = true;
|
||||||
|
|
||||||
|
for await (const fids of gen) {
|
||||||
|
this.photosDone += fids.filter((f) => f).length;
|
||||||
|
this.added(this.photos.filter((p) => fids.includes(p.fileid)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Emit('added')
|
showInfo(
|
||||||
public added(photos: IPhoto[]) {}
|
this.t("memories", "{n} photos added to album", { n: this.photosDone })
|
||||||
|
);
|
||||||
@Emit('close')
|
this.close();
|
||||||
public close() {
|
}
|
||||||
this.photos = [];
|
|
||||||
this.processing = false;
|
|
||||||
this.show = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async selectAlbum(album: IAlbum) {
|
|
||||||
const name = album.name || album.album_id.toString();
|
|
||||||
const gen = dav.addToAlbum(album.user, name, this.photos.map(p => p.fileid));
|
|
||||||
this.processing = true;
|
|
||||||
|
|
||||||
for await (const fids of gen) {
|
|
||||||
this.photosDone += fids.filter(f => f).length;
|
|
||||||
this.added(this.photos.filter(p => fids.includes(p.fileid)));
|
|
||||||
}
|
|
||||||
|
|
||||||
showInfo(this.t('memories', '{n} photos added to album', { n: this.photosDone }));
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.outer {
|
.outer {
|
||||||
margin-top: 15px;
|
margin-top: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-pad {
|
.info-pad {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -20,452 +20,533 @@
|
||||||
-
|
-
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<div class="manage-collaborators">
|
<div class="manage-collaborators">
|
||||||
<div class="manage-collaborators__subtitle">
|
<div class="manage-collaborators__subtitle">
|
||||||
{{ t('photos', 'Add people or groups who can edit your album') }}
|
{{ t("photos", "Add people or groups who can edit your album") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="manage-collaborators__form" @submit.prevent>
|
<form class="manage-collaborators__form" @submit.prevent>
|
||||||
<NcPopover ref="popover"
|
<NcPopover ref="popover" :auto-size="true" :distance="0">
|
||||||
:auto-size="true"
|
<label slot="trigger" class="manage-collaborators__form__input">
|
||||||
:distance="0">
|
<NcTextField
|
||||||
<label slot="trigger" class="manage-collaborators__form__input">
|
:value.sync="searchText"
|
||||||
<NcTextField :value.sync="searchText"
|
autocomplete="off"
|
||||||
autocomplete="off"
|
type="search"
|
||||||
type="search"
|
name="search"
|
||||||
name="search"
|
:aria-label="t('photos', 'Search for collaborators')"
|
||||||
:aria-label="t('photos', 'Search for collaborators')"
|
aria-autocomplete="list"
|
||||||
aria-autocomplete="list"
|
:aria-controls="`manage-collaborators__form__selection-${randomId} manage-collaborators__form__list-${randomId}`"
|
||||||
:aria-controls="`manage-collaborators__form__selection-${randomId} manage-collaborators__form__list-${randomId}`"
|
:placeholder="t('photos', 'Search people or groups')"
|
||||||
:placeholder="t('photos', 'Search people or groups')"
|
@input="searchCollaborators"
|
||||||
@input="searchCollaborators">
|
>
|
||||||
<Magnify :size="16" />
|
<Magnify :size="16" />
|
||||||
</NcTextField>
|
</NcTextField>
|
||||||
<NcLoadingIcon v-if="loadingCollaborators" />
|
<NcLoadingIcon v-if="loadingCollaborators" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<ul v-if="searchResults.length !== 0" :id="`manage-collaborators__form__list-${randomId}`" class="manage-collaborators__form__list">
|
<ul
|
||||||
<li v-for="collaboratorKey of searchResults" :key="collaboratorKey">
|
v-if="searchResults.length !== 0"
|
||||||
<NcListItemIcon :id="availableCollaborators[collaboratorKey].id"
|
:id="`manage-collaborators__form__list-${randomId}`"
|
||||||
class="manage-collaborators__form__list__result"
|
class="manage-collaborators__form__list"
|
||||||
:title="availableCollaborators[collaboratorKey].id"
|
>
|
||||||
:search="searchText"
|
<li v-for="collaboratorKey of searchResults" :key="collaboratorKey">
|
||||||
:user="availableCollaborators[collaboratorKey].id"
|
<NcListItemIcon
|
||||||
:display-name="availableCollaborators[collaboratorKey].label"
|
:id="availableCollaborators[collaboratorKey].id"
|
||||||
:aria-label="t('photos', 'Add {collaboratorLabel} to the collaborators list', {collaboratorLabel: availableCollaborators[collaboratorKey].label})"
|
class="manage-collaborators__form__list__result"
|
||||||
@click="selectEntity(collaboratorKey)" />
|
:title="availableCollaborators[collaboratorKey].id"
|
||||||
</li>
|
:search="searchText"
|
||||||
</ul>
|
:user="availableCollaborators[collaboratorKey].id"
|
||||||
<NcEmptyContent v-else
|
:display-name="availableCollaborators[collaboratorKey].label"
|
||||||
key="emptycontent"
|
:aria-label="
|
||||||
class="manage-collaborators__form__list--empty"
|
t(
|
||||||
:title="t('photos', 'No collaborators available')">
|
'photos',
|
||||||
<AccountGroup slot="icon" />
|
'Add {collaboratorLabel} to the collaborators list',
|
||||||
</NcEmptyContent>
|
{
|
||||||
</NcPopover>
|
collaboratorLabel:
|
||||||
</form>
|
availableCollaborators[collaboratorKey].label,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="selectEntity(collaboratorKey)"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<NcEmptyContent
|
||||||
|
v-else
|
||||||
|
key="emptycontent"
|
||||||
|
class="manage-collaborators__form__list--empty"
|
||||||
|
:title="t('photos', 'No collaborators available')"
|
||||||
|
>
|
||||||
|
<AccountGroup slot="icon" />
|
||||||
|
</NcEmptyContent>
|
||||||
|
</NcPopover>
|
||||||
|
</form>
|
||||||
|
|
||||||
<ul class="manage-collaborators__selection">
|
<ul class="manage-collaborators__selection">
|
||||||
<li v-for="collaboratorKey of listableSelectedCollaboratorsKeys"
|
<li
|
||||||
:key="collaboratorKey"
|
v-for="collaboratorKey of listableSelectedCollaboratorsKeys"
|
||||||
class="manage-collaborators__selection__item">
|
:key="collaboratorKey"
|
||||||
<NcListItemIcon :id="availableCollaborators[collaboratorKey].id"
|
class="manage-collaborators__selection__item"
|
||||||
:display-name="availableCollaborators[collaboratorKey].label"
|
>
|
||||||
:title="availableCollaborators[collaboratorKey].id"
|
<NcListItemIcon
|
||||||
:user="availableCollaborators[collaboratorKey].id">
|
:id="availableCollaborators[collaboratorKey].id"
|
||||||
<NcButton type="tertiary"
|
:display-name="availableCollaborators[collaboratorKey].label"
|
||||||
:aria-label="t('photos', 'Remove {collaboratorLabel} from the collaborators list', {collaboratorLabel: availableCollaborators[collaboratorKey].label})"
|
:title="availableCollaborators[collaboratorKey].id"
|
||||||
@click="unselectEntity(collaboratorKey)">
|
:user="availableCollaborators[collaboratorKey].id"
|
||||||
<Close slot="icon" :size="20" />
|
>
|
||||||
</NcButton>
|
<NcButton
|
||||||
</NcListItemIcon>
|
type="tertiary"
|
||||||
</li>
|
:aria-label="
|
||||||
</ul>
|
t(
|
||||||
|
'photos',
|
||||||
|
'Remove {collaboratorLabel} from the collaborators list',
|
||||||
|
{
|
||||||
|
collaboratorLabel:
|
||||||
|
availableCollaborators[collaboratorKey].label,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="unselectEntity(collaboratorKey)"
|
||||||
|
>
|
||||||
|
<Close slot="icon" :size="20" />
|
||||||
|
</NcButton>
|
||||||
|
</NcListItemIcon>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<div v-if="allowPublicLink" class="actions__public-link">
|
<div v-if="allowPublicLink" class="actions__public-link">
|
||||||
<template v-if="isPublicLinkSelected">
|
<template v-if="isPublicLinkSelected">
|
||||||
<NcButton class="manage-collaborators__public-link-button"
|
<NcButton
|
||||||
:aria-label="t('photos', 'Copy the public link')"
|
class="manage-collaborators__public-link-button"
|
||||||
:disabled="publicLink.id === ''"
|
:aria-label="t('photos', 'Copy the public link')"
|
||||||
@click="copyPublicLink">
|
:disabled="publicLink.id === ''"
|
||||||
<template v-if="publicLinkCopied">
|
@click="copyPublicLink"
|
||||||
{{ t('photos', 'Public link copied!') }}
|
>
|
||||||
</template>
|
<template v-if="publicLinkCopied">
|
||||||
<template v-else>
|
{{ t("photos", "Public link copied!") }}
|
||||||
{{ t('photos', 'Copy public link') }}
|
</template>
|
||||||
</template>
|
<template v-else>
|
||||||
<template #icon>
|
{{ t("photos", "Copy public link") }}
|
||||||
<Check v-if="publicLinkCopied" />
|
</template>
|
||||||
<ContentCopy v-else />
|
<template #icon>
|
||||||
</template>
|
<Check v-if="publicLinkCopied" />
|
||||||
</NcButton>
|
<ContentCopy v-else />
|
||||||
<NcButton type="tertiary"
|
</template>
|
||||||
:aria-label="t('photos', 'Delete the public link')"
|
</NcButton>
|
||||||
:disabled="publicLink.id === ''"
|
<NcButton
|
||||||
@click="deletePublicLink">
|
type="tertiary"
|
||||||
<NcLoadingIcon v-if="publicLink.id === ''" slot="icon" />
|
:aria-label="t('photos', 'Delete the public link')"
|
||||||
<Close v-else slot="icon" />
|
:disabled="publicLink.id === ''"
|
||||||
</NcButton>
|
@click="deletePublicLink"
|
||||||
</template>
|
>
|
||||||
<NcButton v-else
|
<NcLoadingIcon v-if="publicLink.id === ''" slot="icon" />
|
||||||
class="manage-collaborators__public-link-button"
|
<Close v-else slot="icon" />
|
||||||
@click="createPublicLinkForAlbum">
|
</NcButton>
|
||||||
<Earth slot="icon" />
|
</template>
|
||||||
{{ t('photos', 'Share via public link') }}
|
<NcButton
|
||||||
</NcButton>
|
v-else
|
||||||
</div>
|
class="manage-collaborators__public-link-button"
|
||||||
|
@click="createPublicLinkForAlbum"
|
||||||
|
>
|
||||||
|
<Earth slot="icon" />
|
||||||
|
{{ t("photos", "Share via public link") }}
|
||||||
|
</NcButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="actions__slot">
|
<div class="actions__slot">
|
||||||
<slot :collaborators="selectedCollaborators" />
|
<slot :collaborators="selectedCollaborators" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Mixins, Prop, Watch } from 'vue-property-decorator';
|
import { Component, Mixins, Prop, Watch } from "vue-property-decorator";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
|
|
||||||
import Magnify from 'vue-material-design-icons/Magnify.vue'
|
import Magnify from "vue-material-design-icons/Magnify.vue";
|
||||||
import Close from 'vue-material-design-icons/Close.vue'
|
import Close from "vue-material-design-icons/Close.vue";
|
||||||
import Check from 'vue-material-design-icons/Check.vue'
|
import Check from "vue-material-design-icons/Check.vue";
|
||||||
import ContentCopy from 'vue-material-design-icons/ContentCopy.vue'
|
import ContentCopy from "vue-material-design-icons/ContentCopy.vue";
|
||||||
import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
|
import AccountGroup from "vue-material-design-icons/AccountGroup.vue";
|
||||||
import Earth from 'vue-material-design-icons/Earth.vue'
|
import Earth from "vue-material-design-icons/Earth.vue";
|
||||||
|
|
||||||
import axios from '@nextcloud/axios'
|
import axios from "@nextcloud/axios";
|
||||||
import * as dav from '../../services/DavRequests';
|
import * as dav from "../../services/DavRequests";
|
||||||
import { showError } from '@nextcloud/dialogs'
|
import { showError } from "@nextcloud/dialogs";
|
||||||
import { getCurrentUser } from '@nextcloud/auth'
|
import { getCurrentUser } from "@nextcloud/auth";
|
||||||
import { generateOcsUrl, generateUrl } from '@nextcloud/router'
|
import { generateOcsUrl, generateUrl } from "@nextcloud/router";
|
||||||
import { NcButton, NcListItemIcon, NcLoadingIcon, NcPopover, NcTextField, NcEmptyContent } from '@nextcloud/vue'
|
import {
|
||||||
|
NcButton,
|
||||||
|
NcListItemIcon,
|
||||||
|
NcLoadingIcon,
|
||||||
|
NcPopover,
|
||||||
|
NcTextField,
|
||||||
|
NcEmptyContent,
|
||||||
|
} from "@nextcloud/vue";
|
||||||
import { Type } from "@nextcloud/sharing";
|
import { Type } from "@nextcloud/sharing";
|
||||||
|
|
||||||
type Collaborator = {
|
type Collaborator = {
|
||||||
id: string,
|
id: string;
|
||||||
label: string,
|
label: string;
|
||||||
type: Type,
|
type: Type;
|
||||||
}
|
};
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
Magnify,
|
Magnify,
|
||||||
Close,
|
Close,
|
||||||
AccountGroup,
|
AccountGroup,
|
||||||
ContentCopy,
|
ContentCopy,
|
||||||
Check,
|
Check,
|
||||||
Earth,
|
Earth,
|
||||||
NcLoadingIcon,
|
NcLoadingIcon,
|
||||||
NcButton,
|
NcButton,
|
||||||
NcListItemIcon,
|
NcListItemIcon,
|
||||||
NcTextField,
|
NcTextField,
|
||||||
NcPopover,
|
NcPopover,
|
||||||
NcEmptyContent,
|
NcEmptyContent,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
export default class AddToAlbumModal extends Mixins(GlobalMixin) {
|
export default class AddToAlbumModal extends Mixins(GlobalMixin) {
|
||||||
|
@Prop() private albumName: string;
|
||||||
|
@Prop() collaborators: Collaborator[];
|
||||||
|
@Prop() allowPublicLink: boolean;
|
||||||
|
|
||||||
@Prop() private albumName: string;
|
private searchText = "";
|
||||||
@Prop() collaborators: Collaborator[];
|
private availableCollaborators: { [key: string]: Collaborator } = {};
|
||||||
@Prop() allowPublicLink: boolean;
|
private selectedCollaboratorsKeys: string[] = [];
|
||||||
|
private currentSearchResults = [];
|
||||||
|
private loadingAlbum = false;
|
||||||
|
private errorFetchingAlbum = null;
|
||||||
|
private loadingCollaborators = false;
|
||||||
|
private errorFetchingCollaborators = null;
|
||||||
|
private randomId = Math.random().toString().substring(2, 10);
|
||||||
|
private publicLinkCopied = false;
|
||||||
|
private config = {
|
||||||
|
minSearchStringLength:
|
||||||
|
parseInt(window.OC.config["sharing.minSearchStringLength"], 10) || 0,
|
||||||
|
};
|
||||||
|
|
||||||
private searchText = '';
|
get searchResults(): string[] {
|
||||||
private availableCollaborators: { [key: string]: Collaborator } = {};
|
return this.currentSearchResults
|
||||||
private selectedCollaboratorsKeys: string[] = [];
|
.filter(({ id }) => id !== getCurrentUser().uid)
|
||||||
private currentSearchResults = [];
|
.map(({ type, id }) => `${type}:${id}`)
|
||||||
private loadingAlbum = false;
|
.filter(
|
||||||
private errorFetchingAlbum = null;
|
(collaboratorKey) =>
|
||||||
private loadingCollaborators = false;
|
!this.selectedCollaboratorsKeys.includes(collaboratorKey)
|
||||||
private errorFetchingCollaborators = null;
|
);
|
||||||
private randomId = Math.random().toString().substring(2, 10);
|
}
|
||||||
private publicLinkCopied = false;
|
|
||||||
private config = {
|
get listableSelectedCollaboratorsKeys(): string[] {
|
||||||
minSearchStringLength: parseInt(window.OC.config['sharing.minSearchStringLength'], 10) || 0,
|
return this.selectedCollaboratorsKeys.filter(
|
||||||
|
(collaboratorKey) =>
|
||||||
|
this.availableCollaborators[collaboratorKey].type !==
|
||||||
|
Type.SHARE_TYPE_LINK
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedCollaborators(): Collaborator[] {
|
||||||
|
return this.selectedCollaboratorsKeys.map(
|
||||||
|
(collaboratorKey) => this.availableCollaborators[collaboratorKey]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPublicLinkSelected(): boolean {
|
||||||
|
return this.selectedCollaboratorsKeys.includes(`${Type.SHARE_TYPE_LINK}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
get publicLink(): Collaborator {
|
||||||
|
return this.availableCollaborators[Type.SHARE_TYPE_LINK];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Watch("collaborators")
|
||||||
|
collaboratorsChanged(collaborators) {
|
||||||
|
this.populateCollaborators(collaborators);
|
||||||
|
}
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.searchCollaborators();
|
||||||
|
this.populateCollaborators(this.collaborators);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch possible collaborators.
|
||||||
|
*/
|
||||||
|
async searchCollaborators() {
|
||||||
|
if (this.searchText.length >= 1) {
|
||||||
|
(<any>this.$refs.popover).$refs.popover.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.searchText.length < this.config.minSearchStringLength) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadingCollaborators = true;
|
||||||
|
const response = await axios.get(
|
||||||
|
generateOcsUrl("core/autocomplete/get"),
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
search: this.searchText,
|
||||||
|
itemType: "share-recipients",
|
||||||
|
shareTypes: [Type.SHARE_TYPE_USER, Type.SHARE_TYPE_GROUP],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.currentSearchResults = response.data.ocs.data.map((collaborator) => {
|
||||||
|
switch (collaborator.source) {
|
||||||
|
case "users":
|
||||||
|
return {
|
||||||
|
id: collaborator.id,
|
||||||
|
label: collaborator.label,
|
||||||
|
type: Type.SHARE_TYPE_USER,
|
||||||
|
};
|
||||||
|
case "groups":
|
||||||
|
return {
|
||||||
|
id: collaborator.id,
|
||||||
|
label: collaborator.label,
|
||||||
|
type: Type.SHARE_TYPE_GROUP,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`Invalid collaborator source ${collaborator.source}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.availableCollaborators = {
|
||||||
|
...this.availableCollaborators,
|
||||||
|
...this.currentSearchResults.reduce(this.indexCollaborators, {}),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.errorFetchingCollaborators = error;
|
||||||
|
showError(this.t("photos", "Failed to fetch collaborators list."));
|
||||||
|
} finally {
|
||||||
|
this.loadingCollaborators = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate selectedCollaboratorsKeys and availableCollaborators.
|
||||||
|
*/
|
||||||
|
populateCollaborators(collaborators: Collaborator[]) {
|
||||||
|
const initialCollaborators = collaborators.reduce(
|
||||||
|
this.indexCollaborators,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
this.selectedCollaboratorsKeys = Object.keys(initialCollaborators);
|
||||||
|
this.availableCollaborators = {
|
||||||
|
3: {
|
||||||
|
id: "",
|
||||||
|
label: this.t("photos", "Public link"),
|
||||||
|
type: Type.SHARE_TYPE_LINK,
|
||||||
|
},
|
||||||
|
...this.availableCollaborators,
|
||||||
|
...initialCollaborators,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
get searchResults(): string[] {
|
/**
|
||||||
return this.currentSearchResults
|
* @param {Object<string, Collaborator>} collaborators - Index of collaborators
|
||||||
.filter(({ id }) => id !== getCurrentUser().uid)
|
* @param {Collaborator} collaborator - A collaborator
|
||||||
.map(({ type, id }) => `${type}:${id}`)
|
*/
|
||||||
.filter(collaboratorKey => !this.selectedCollaboratorsKeys.includes(collaboratorKey))
|
indexCollaborators(
|
||||||
}
|
collaborators: { [s: string]: Collaborator },
|
||||||
|
collaborator: Collaborator
|
||||||
get listableSelectedCollaboratorsKeys(): string[] {
|
) {
|
||||||
return this.selectedCollaboratorsKeys
|
return {
|
||||||
.filter(collaboratorKey => this.availableCollaborators[collaboratorKey].type !== Type.SHARE_TYPE_LINK)
|
...collaborators,
|
||||||
}
|
[`${collaborator.type}${
|
||||||
|
collaborator.type === Type.SHARE_TYPE_LINK ? "" : ":"
|
||||||
get selectedCollaborators(): Collaborator[] {
|
}${collaborator.type === Type.SHARE_TYPE_LINK ? "" : collaborator.id}`]:
|
||||||
return this.selectedCollaboratorsKeys
|
collaborator,
|
||||||
.map((collaboratorKey) => this.availableCollaborators[collaboratorKey])
|
|
||||||
}
|
|
||||||
|
|
||||||
get isPublicLinkSelected(): boolean {
|
|
||||||
return this.selectedCollaboratorsKeys.includes(`${Type.SHARE_TYPE_LINK}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
get publicLink(): Collaborator {
|
|
||||||
return this.availableCollaborators[Type.SHARE_TYPE_LINK]
|
|
||||||
}
|
|
||||||
|
|
||||||
@Watch('collaborators')
|
|
||||||
collaboratorsChanged(collaborators) {
|
|
||||||
this.populateCollaborators(collaborators)
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
mounted() {
|
async createPublicLinkForAlbum() {
|
||||||
this.searchCollaborators()
|
this.selectEntity(`${Type.SHARE_TYPE_LINK}`);
|
||||||
this.populateCollaborators(this.collaborators)
|
await this.updateAlbumCollaborators();
|
||||||
}
|
try {
|
||||||
|
this.loadingAlbum = true;
|
||||||
|
this.errorFetchingAlbum = null;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
this.errorFetchingAlbum = 404;
|
||||||
|
} else {
|
||||||
|
this.errorFetchingAlbum = error;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
showError(this.t("photos", "Failed to fetch album."));
|
||||||
* Fetch possible collaborators.
|
} finally {
|
||||||
*/
|
this.loadingAlbum = false;
|
||||||
async searchCollaborators() {
|
}
|
||||||
if (this.searchText.length >= 1) {
|
}
|
||||||
(<any>this.$refs.popover).$refs.popover.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
async deletePublicLink() {
|
||||||
if (this.searchText.length < this.config.minSearchStringLength) {
|
this.unselectEntity(`${Type.SHARE_TYPE_LINK}`);
|
||||||
return
|
this.availableCollaborators[3] = {
|
||||||
}
|
id: "",
|
||||||
|
label: this.t("photos", "Public link"),
|
||||||
|
type: Type.SHARE_TYPE_LINK,
|
||||||
|
};
|
||||||
|
this.publicLinkCopied = false;
|
||||||
|
await this.updateAlbumCollaborators();
|
||||||
|
}
|
||||||
|
|
||||||
this.loadingCollaborators = true
|
async updateAlbumCollaborators() {
|
||||||
const response = await axios.get(generateOcsUrl('core/autocomplete/get'), {
|
try {
|
||||||
params: {
|
const album = await dav.getAlbum(
|
||||||
search: this.searchText,
|
getCurrentUser()?.uid.toString(),
|
||||||
itemType: 'share-recipients',
|
this.albumName
|
||||||
shareTypes: [
|
);
|
||||||
Type.SHARE_TYPE_USER,
|
await dav.updateAlbum(album, {
|
||||||
Type.SHARE_TYPE_GROUP,
|
albumName: this.albumName,
|
||||||
],
|
properties: {
|
||||||
},
|
collaborators: this.selectedCollaborators,
|
||||||
})
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
showError(this.t("photos", "Failed to update album."));
|
||||||
|
} finally {
|
||||||
|
this.loadingAlbum = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.currentSearchResults = response.data.ocs.data
|
async copyPublicLink() {
|
||||||
.map(collaborator => {
|
await navigator.clipboard.writeText(
|
||||||
switch (collaborator.source) {
|
`${window.location.protocol}//${window.location.host}${generateUrl(
|
||||||
case 'users':
|
`apps/photos/public/${this.publicLink.id}`
|
||||||
return { id: collaborator.id, label: collaborator.label, type: Type.SHARE_TYPE_USER }
|
)}`
|
||||||
case 'groups':
|
);
|
||||||
return { id: collaborator.id, label: collaborator.label, type: Type.SHARE_TYPE_GROUP }
|
this.publicLinkCopied = true;
|
||||||
default:
|
setTimeout(() => {
|
||||||
throw new Error(`Invalid collaborator source ${collaborator.source}`)
|
this.publicLinkCopied = false;
|
||||||
}
|
}, 10000);
|
||||||
})
|
}
|
||||||
|
|
||||||
this.availableCollaborators = {
|
selectEntity(collaboratorKey) {
|
||||||
...this.availableCollaborators,
|
if (this.selectedCollaboratorsKeys.includes(collaboratorKey)) {
|
||||||
...this.currentSearchResults.reduce(this.indexCollaborators, {}),
|
return;
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.errorFetchingCollaborators = error
|
|
||||||
showError(this.t('photos', 'Failed to fetch collaborators list.'))
|
|
||||||
} finally {
|
|
||||||
this.loadingCollaborators = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
(<any>this.$refs.popover).$refs.popover.hide();
|
||||||
* Populate selectedCollaboratorsKeys and availableCollaborators.
|
this.selectedCollaboratorsKeys.push(collaboratorKey);
|
||||||
*/
|
}
|
||||||
populateCollaborators(collaborators: Collaborator[]) {
|
|
||||||
const initialCollaborators = collaborators.reduce(this.indexCollaborators, {})
|
unselectEntity(collaboratorKey) {
|
||||||
this.selectedCollaboratorsKeys = Object.keys(initialCollaborators)
|
const index = this.selectedCollaboratorsKeys.indexOf(collaboratorKey);
|
||||||
this.availableCollaborators = {
|
|
||||||
3: {
|
if (index === -1) {
|
||||||
id: '',
|
return;
|
||||||
label: this.t('photos', 'Public link'),
|
|
||||||
type: Type.SHARE_TYPE_LINK,
|
|
||||||
},
|
|
||||||
...this.availableCollaborators,
|
|
||||||
...initialCollaborators,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
this.selectedCollaboratorsKeys.splice(index, 1);
|
||||||
* @param {Object<string, Collaborator>} collaborators - Index of collaborators
|
}
|
||||||
* @param {Collaborator} collaborator - A collaborator
|
|
||||||
*/
|
|
||||||
indexCollaborators(collaborators: { [s: string]: Collaborator; }, collaborator: Collaborator) {
|
|
||||||
return { ...collaborators, [`${collaborator.type}${collaborator.type === Type.SHARE_TYPE_LINK ? '' : ':'}${collaborator.type === Type.SHARE_TYPE_LINK ? '' : collaborator.id}`]: collaborator }
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPublicLinkForAlbum() {
|
|
||||||
this.selectEntity(`${Type.SHARE_TYPE_LINK}`)
|
|
||||||
await this.updateAlbumCollaborators()
|
|
||||||
try {
|
|
||||||
this.loadingAlbum = true
|
|
||||||
this.errorFetchingAlbum = null
|
|
||||||
} catch (error) {
|
|
||||||
if (error.response?.status === 404) {
|
|
||||||
this.errorFetchingAlbum = 404
|
|
||||||
} else {
|
|
||||||
this.errorFetchingAlbum = error
|
|
||||||
}
|
|
||||||
|
|
||||||
showError(this.t('photos', 'Failed to fetch album.'))
|
|
||||||
} finally {
|
|
||||||
this.loadingAlbum = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async deletePublicLink() {
|
|
||||||
this.unselectEntity(`${Type.SHARE_TYPE_LINK}`)
|
|
||||||
this.availableCollaborators[3] = {
|
|
||||||
id: '',
|
|
||||||
label: this.t('photos', 'Public link'),
|
|
||||||
type: Type.SHARE_TYPE_LINK,
|
|
||||||
}
|
|
||||||
this.publicLinkCopied = false
|
|
||||||
await this.updateAlbumCollaborators()
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateAlbumCollaborators() {
|
|
||||||
try {
|
|
||||||
const album = await dav.getAlbum(getCurrentUser()?.uid.toString(), this.albumName);
|
|
||||||
await dav.updateAlbum(album, {
|
|
||||||
albumName: this.albumName,
|
|
||||||
properties: {
|
|
||||||
collaborators: this.selectedCollaborators,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
showError(this.t('photos', 'Failed to update album.'))
|
|
||||||
} finally {
|
|
||||||
this.loadingAlbum = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async copyPublicLink() {
|
|
||||||
await navigator.clipboard.writeText(`${window.location.protocol}//${window.location.host}${generateUrl(`apps/photos/public/${this.publicLink.id}`)}`)
|
|
||||||
this.publicLinkCopied = true
|
|
||||||
setTimeout(() => {
|
|
||||||
this.publicLinkCopied = false
|
|
||||||
}, 10000)
|
|
||||||
}
|
|
||||||
|
|
||||||
selectEntity(collaboratorKey) {
|
|
||||||
if (this.selectedCollaboratorsKeys.includes(collaboratorKey)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
(<any>this.$refs.popover).$refs.popover.hide()
|
|
||||||
this.selectedCollaboratorsKeys.push(collaboratorKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
unselectEntity(collaboratorKey) {
|
|
||||||
const index = this.selectedCollaboratorsKeys.indexOf(collaboratorKey)
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.selectedCollaboratorsKeys.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.manage-collaborators {
|
.manage-collaborators {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 500px;
|
height: 500px;
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__subtitle {
|
&__subtitle {
|
||||||
color: var(--color-text-lighter);
|
color: var(--color-text-lighter);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__public-link-button {
|
&__public-link-button {
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__form {
|
&__form {
|
||||||
margin-top: 4px 0;
|
margin-top: 4px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
&__input {
|
&__input {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-left: 34px;
|
padding-left: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-icon {
|
.loading-icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(36px / 2 - 20px / 2);
|
top: calc(36px / 2 - 20px / 2);
|
||||||
right: 8px;
|
right: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__list {
|
&__list {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
height: 350px;
|
height: 350px;
|
||||||
overflow: scroll;
|
overflow: scroll;
|
||||||
|
|
||||||
&__result {
|
&__result {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
&, & * {
|
&,
|
||||||
cursor: pointer !important;
|
& * {
|
||||||
}
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-background-dark);
|
background: var(--color-background-dark);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--empty {
|
&--empty {
|
||||||
margin: 100px 0;
|
margin: 100px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__selection {
|
&__selection {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|
||||||
&__item {
|
&__item {
|
||||||
border-radius: var(--border-radius-pill);
|
border-radius: var(--border-radius-pill);
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-background-dark);
|
background: var(--color-background-dark);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
|
||||||
&__public-link {
|
&__public-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
button {
|
button {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__slot {
|
&__slot {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,87 +1,90 @@
|
||||||
<template>
|
<template>
|
||||||
<Modal @close="close" size="normal" v-if="show">
|
<Modal @close="close" size="normal" v-if="show">
|
||||||
<template #title>
|
<template #title>
|
||||||
<template v-if="!album">
|
<template v-if="!album">
|
||||||
{{ t('memories', 'Create new album') }}
|
{{ t("memories", "Create new album") }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
{{ t('memories', 'Edit album details') }}
|
{{ t("memories", "Edit album details") }}
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="outer">
|
<div class="outer">
|
||||||
<AlbumForm
|
<AlbumForm
|
||||||
:album="album"
|
:album="album"
|
||||||
:display-back-button="false"
|
:display-back-button="false"
|
||||||
:title="t('photos', 'New album')"
|
:title="t('photos', 'New album')"
|
||||||
@done="done" />
|
@done="done"
|
||||||
</div>
|
/>
|
||||||
</Modal>
|
</div>
|
||||||
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Emit, Mixins } from 'vue-property-decorator';
|
import { Component, Emit, Mixins } from "vue-property-decorator";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
|
|
||||||
import { showError } from '@nextcloud/dialogs'
|
import { showError } from "@nextcloud/dialogs";
|
||||||
import * as dav from '../../services/DavRequests';
|
import * as dav from "../../services/DavRequests";
|
||||||
|
|
||||||
import Modal from './Modal.vue';
|
import Modal from "./Modal.vue";
|
||||||
import AlbumForm from './AlbumForm.vue';
|
import AlbumForm from "./AlbumForm.vue";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
Modal,
|
Modal,
|
||||||
AlbumForm,
|
AlbumForm,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
export default class AlbumCreateModal extends Mixins(GlobalMixin) {
|
export default class AlbumCreateModal extends Mixins(GlobalMixin) {
|
||||||
private show = false;
|
private show = false;
|
||||||
private album: any = null;
|
private album: any = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* Open the modal
|
|
||||||
* @param edit If true, the modal will be opened in edit mode
|
|
||||||
*/
|
|
||||||
public async open(edit: boolean) {
|
|
||||||
if (edit) {
|
|
||||||
try {
|
|
||||||
this.album = await dav.getAlbum(this.$route.params.user, this.$route.params.name);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
showError(this.t('photos', 'Could not load the selected album'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.album = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.show = true;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the modal
|
||||||
|
* @param edit If true, the modal will be opened in edit mode
|
||||||
|
*/
|
||||||
|
public async open(edit: boolean) {
|
||||||
|
if (edit) {
|
||||||
|
try {
|
||||||
|
this.album = await dav.getAlbum(
|
||||||
|
this.$route.params.user,
|
||||||
|
this.$route.params.name
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showError(this.t("photos", "Could not load the selected album"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.album = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Emit('close')
|
this.show = true;
|
||||||
public close() {
|
}
|
||||||
this.show = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public done({ album }: any) {
|
@Emit("close")
|
||||||
if (!this.album || album.basename !== this.album.basename) {
|
public close() {
|
||||||
const user = album.filename.split('/')[2];
|
this.show = false;
|
||||||
const name = album.basename;
|
}
|
||||||
this.$router.push({ name: 'albums', params: { user, name } });
|
|
||||||
}
|
public done({ album }: any) {
|
||||||
this.close();
|
if (!this.album || album.basename !== this.album.basename) {
|
||||||
|
const user = album.filename.split("/")[2];
|
||||||
|
const name = album.basename;
|
||||||
|
this.$router.push({ name: "albums", params: { user, name } });
|
||||||
}
|
}
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.outer {
|
.outer {
|
||||||
margin-top: 15px;
|
margin-top: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-pad {
|
.info-pad {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,81 +1,91 @@
|
||||||
<template>
|
<template>
|
||||||
<Modal @close="close" v-if="show">
|
<Modal @close="close" v-if="show">
|
||||||
<template #title>
|
<template #title>
|
||||||
{{ t('memories', 'Remove Album') }}
|
{{ t("memories", "Remove Album") }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
{{ t('memories', 'Are you sure you want to permanently remove album "{name}"?', { name }) }}
|
{{
|
||||||
</span>
|
t(
|
||||||
|
"memories",
|
||||||
|
'Are you sure you want to permanently remove album "{name}"?',
|
||||||
|
{ name }
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
|
||||||
<template #buttons>
|
<template #buttons>
|
||||||
<NcButton @click="save" class="button" type="error">
|
<NcButton @click="save" class="button" type="error">
|
||||||
{{ t('memories', 'Delete') }}
|
{{ t("memories", "Delete") }}
|
||||||
</NcButton>
|
</NcButton>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Emit, Mixins, Watch } from 'vue-property-decorator';
|
import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
|
||||||
import { NcButton, NcTextField } from '@nextcloud/vue';
|
import { NcButton, NcTextField } from "@nextcloud/vue";
|
||||||
import { showError } from '@nextcloud/dialogs';
|
import { showError } from "@nextcloud/dialogs";
|
||||||
import { getCurrentUser } from '@nextcloud/auth';
|
import { getCurrentUser } from "@nextcloud/auth";
|
||||||
import Modal from './Modal.vue';
|
import Modal from "./Modal.vue";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
import client from '../../services/DavClient';
|
import client from "../../services/DavClient";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
NcButton,
|
NcButton,
|
||||||
NcTextField,
|
NcTextField,
|
||||||
Modal,
|
Modal,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
export default class AlbumDeleteModal extends Mixins(GlobalMixin) {
|
export default class AlbumDeleteModal extends Mixins(GlobalMixin) {
|
||||||
private user: string = "";
|
private user: string = "";
|
||||||
private name: string = "";
|
private name: string = "";
|
||||||
private show = false;
|
private show = false;
|
||||||
|
|
||||||
@Emit('close')
|
@Emit("close")
|
||||||
public close() {
|
public close() {
|
||||||
this.show = false;
|
this.show = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public open() {
|
public open() {
|
||||||
const user = this.$route.params.user || '';
|
const user = this.$route.params.user || "";
|
||||||
if (this.$route.params.user !== getCurrentUser().uid) {
|
if (this.$route.params.user !== getCurrentUser().uid) {
|
||||||
showError(this.t('memories', 'Only user "{user}" can delete this album', { user }));
|
showError(
|
||||||
return;
|
this.t("memories", 'Only user "{user}" can delete this album', { user })
|
||||||
}
|
);
|
||||||
this.show = true;
|
return;
|
||||||
}
|
}
|
||||||
|
this.show = true;
|
||||||
|
}
|
||||||
|
|
||||||
@Watch('$route')
|
@Watch("$route")
|
||||||
async routeChange(from: any, to: any) {
|
async routeChange(from: any, to: any) {
|
||||||
this.refreshParams();
|
this.refreshParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refreshParams();
|
this.refreshParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
public refreshParams() {
|
public refreshParams() {
|
||||||
this.user = this.$route.params.user || '';
|
this.user = this.$route.params.user || "";
|
||||||
this.name = this.$route.params.name || '';
|
this.name = this.$route.params.name || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
public async save() {
|
public async save() {
|
||||||
try {
|
try {
|
||||||
await client.deleteFile(`/photos/${this.user}/albums/${this.name}`)
|
await client.deleteFile(`/photos/${this.user}/albums/${this.name}`);
|
||||||
this.$router.push({ name: 'albums' });
|
this.$router.push({ name: "albums" });
|
||||||
this.close();
|
this.close();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
showError(this.t('photos', 'Failed to delete {name}.', {
|
showError(
|
||||||
name: this.name,
|
this.t("photos", "Failed to delete {name}.", {
|
||||||
}));
|
name: this.name,
|
||||||
}
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
|
@ -20,244 +20,274 @@
|
||||||
-
|
-
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<form v-if="!showCollaboratorView" class="album-form" @submit.prevent="submit">
|
<form
|
||||||
<div class="form-inputs">
|
v-if="!showCollaboratorView"
|
||||||
<NcTextField ref="nameInput"
|
class="album-form"
|
||||||
:value.sync="albumName"
|
@submit.prevent="submit"
|
||||||
type="text"
|
>
|
||||||
name="name"
|
<div class="form-inputs">
|
||||||
:required="true"
|
<NcTextField
|
||||||
autofocus="true"
|
ref="nameInput"
|
||||||
:placeholder="t('photos', 'Name of the album')" />
|
:value.sync="albumName"
|
||||||
<label>
|
type="text"
|
||||||
<NcTextField :value.sync="albumLocation"
|
name="name"
|
||||||
name="location"
|
:required="true"
|
||||||
type="text"
|
autofocus="true"
|
||||||
:placeholder="t('photos', 'Location of the album')" />
|
:placeholder="t('photos', 'Name of the album')"
|
||||||
</label>
|
/>
|
||||||
</div>
|
<label>
|
||||||
<div class="form-buttons">
|
<NcTextField
|
||||||
<span class="left-buttons">
|
:value.sync="albumLocation"
|
||||||
<NcButton v-if="displayBackButton"
|
name="location"
|
||||||
:aria-label="t('photos', 'Go back to the previous view.')"
|
type="text"
|
||||||
type="tertiary"
|
:placeholder="t('photos', 'Location of the album')"
|
||||||
@click="back">
|
/>
|
||||||
{{ t('photos', 'Back') }}
|
</label>
|
||||||
</NcButton>
|
</div>
|
||||||
</span>
|
<div class="form-buttons">
|
||||||
<span class="right-buttons">
|
<span class="left-buttons">
|
||||||
<NcButton v-if="sharingEnabled && !editMode"
|
<NcButton
|
||||||
:aria-label="t('photos', 'Go to the add collaborators view.')"
|
v-if="displayBackButton"
|
||||||
type="secondary"
|
:aria-label="t('photos', 'Go back to the previous view.')"
|
||||||
:disabled="albumName.trim() === '' || loading"
|
type="tertiary"
|
||||||
@click="showCollaboratorView = true">
|
@click="back"
|
||||||
<template #icon>
|
>
|
||||||
<AccountMultiplePlus />
|
{{ t("photos", "Back") }}
|
||||||
</template>
|
</NcButton>
|
||||||
{{ t('photos', 'Add collaborators') }}
|
</span>
|
||||||
</NcButton>
|
<span class="right-buttons">
|
||||||
<NcButton :aria-label="editMode ? t('photos', 'Save.') : t('photos', 'Create the album.')"
|
<NcButton
|
||||||
type="primary"
|
v-if="sharingEnabled && !editMode"
|
||||||
:disabled="albumName === '' || loading"
|
:aria-label="t('photos', 'Go to the add collaborators view.')"
|
||||||
@click="submit()">
|
type="secondary"
|
||||||
<template #icon>
|
:disabled="albumName.trim() === '' || loading"
|
||||||
<NcLoadingIcon v-if="loading" />
|
@click="showCollaboratorView = true"
|
||||||
<Send v-else />
|
>
|
||||||
</template>
|
<template #icon>
|
||||||
{{ editMode ? t('photos', 'Save') : t('photos', 'Create album') }}
|
<AccountMultiplePlus />
|
||||||
</NcButton>
|
</template>
|
||||||
</span>
|
{{ t("photos", "Add collaborators") }}
|
||||||
</div>
|
</NcButton>
|
||||||
</form>
|
<NcButton
|
||||||
<AlbumCollaborators v-else
|
:aria-label="
|
||||||
:album-name="albumName"
|
editMode ? t('photos', 'Save.') : t('photos', 'Create the album.')
|
||||||
:allow-public-link="false"
|
"
|
||||||
:collaborators="[]">
|
type="primary"
|
||||||
<template slot-scope="{collaborators}">
|
:disabled="albumName === '' || loading"
|
||||||
<span class="left-buttons">
|
@click="submit()"
|
||||||
<NcButton :aria-label="t('photos', 'Back to the new album form.')"
|
>
|
||||||
type="tertiary"
|
<template #icon>
|
||||||
@click="showCollaboratorView = false">
|
<NcLoadingIcon v-if="loading" />
|
||||||
{{ t('photos', 'Back') }}
|
<Send v-else />
|
||||||
</NcButton>
|
</template>
|
||||||
</span>
|
{{ editMode ? t("photos", "Save") : t("photos", "Create album") }}
|
||||||
<span class="right-buttons">
|
</NcButton>
|
||||||
<NcButton :aria-label="editMode ? t('photos', 'Save.') : t('photos', 'Create the album.')"
|
</span>
|
||||||
type="primary"
|
</div>
|
||||||
:disabled="albumName.trim() === '' || loading"
|
</form>
|
||||||
@click="submit(collaborators)">
|
<AlbumCollaborators
|
||||||
<template #icon>
|
v-else
|
||||||
<NcLoadingIcon v-if="loading" />
|
:album-name="albumName"
|
||||||
<Send v-else />
|
:allow-public-link="false"
|
||||||
</template>
|
:collaborators="[]"
|
||||||
{{ editMode ? t('photos', 'Save') : t('photos', 'Create album') }}
|
>
|
||||||
</NcButton>
|
<template slot-scope="{ collaborators }">
|
||||||
</span>
|
<span class="left-buttons">
|
||||||
</template>
|
<NcButton
|
||||||
</AlbumCollaborators>
|
:aria-label="t('photos', 'Back to the new album form.')"
|
||||||
|
type="tertiary"
|
||||||
|
@click="showCollaboratorView = false"
|
||||||
|
>
|
||||||
|
{{ t("photos", "Back") }}
|
||||||
|
</NcButton>
|
||||||
|
</span>
|
||||||
|
<span class="right-buttons">
|
||||||
|
<NcButton
|
||||||
|
:aria-label="
|
||||||
|
editMode ? t('photos', 'Save.') : t('photos', 'Create the album.')
|
||||||
|
"
|
||||||
|
type="primary"
|
||||||
|
:disabled="albumName.trim() === '' || loading"
|
||||||
|
@click="submit(collaborators)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<NcLoadingIcon v-if="loading" />
|
||||||
|
<Send v-else />
|
||||||
|
</template>
|
||||||
|
{{ editMode ? t("photos", "Save") : t("photos", "Create album") }}
|
||||||
|
</NcButton>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</AlbumCollaborators>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator';
|
import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
|
|
||||||
import { getCurrentUser } from '@nextcloud/auth'
|
import { getCurrentUser } from "@nextcloud/auth";
|
||||||
import { NcButton, NcLoadingIcon, NcTextField } from '@nextcloud/vue'
|
import { NcButton, NcLoadingIcon, NcTextField } from "@nextcloud/vue";
|
||||||
import moment from 'moment';
|
import moment from "moment";
|
||||||
import * as dav from '../../services/DavRequests';
|
import * as dav from "../../services/DavRequests";
|
||||||
|
|
||||||
import AlbumCollaborators from './AlbumCollaborators.vue'
|
import AlbumCollaborators from "./AlbumCollaborators.vue";
|
||||||
|
|
||||||
import Send from 'vue-material-design-icons/Send.vue'
|
|
||||||
import AccountMultiplePlus from 'vue-material-design-icons/AccountMultiplePlus.vue'
|
|
||||||
|
|
||||||
|
import Send from "vue-material-design-icons/Send.vue";
|
||||||
|
import AccountMultiplePlus from "vue-material-design-icons/AccountMultiplePlus.vue";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
NcButton,
|
NcButton,
|
||||||
NcLoadingIcon,
|
NcLoadingIcon,
|
||||||
NcTextField,
|
NcTextField,
|
||||||
AlbumCollaborators,
|
AlbumCollaborators,
|
||||||
|
|
||||||
Send,
|
Send,
|
||||||
AccountMultiplePlus,
|
AccountMultiplePlus,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class AlbumForm extends Mixins(GlobalMixin) {
|
export default class AlbumForm extends Mixins(GlobalMixin) {
|
||||||
@Prop() private album: any;
|
@Prop() private album: any;
|
||||||
@Prop() private displayBackButton: boolean;
|
@Prop() private displayBackButton: boolean;
|
||||||
|
|
||||||
private showCollaboratorView = false;
|
private showCollaboratorView = false;
|
||||||
private albumName = '';
|
private albumName = "";
|
||||||
private albumLocation = '';
|
private albumLocation = "";
|
||||||
private loading = false;
|
private loading = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Whether sharing is enabled.
|
* @return Whether sharing is enabled.
|
||||||
*/
|
*/
|
||||||
get editMode(): boolean {
|
get editMode(): boolean {
|
||||||
return Boolean(this.album);
|
return Boolean(this.album);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Whether sharing is enabled.
|
||||||
|
*/
|
||||||
|
get sharingEnabled(): boolean {
|
||||||
|
return window.OC.Share !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
if (this.editMode) {
|
||||||
|
this.albumName = this.album.basename;
|
||||||
|
this.albumLocation = this.album.location;
|
||||||
}
|
}
|
||||||
|
this.$nextTick(() => {
|
||||||
|
(<any>this.$refs.nameInput).$el.getElementsByTagName("input")[0].focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
submit(collaborators = []) {
|
||||||
* @return Whether sharing is enabled.
|
if (this.albumName === "" || this.loading) {
|
||||||
*/
|
return;
|
||||||
get sharingEnabled(): boolean {
|
|
||||||
return window.OC.Share !== undefined
|
|
||||||
}
|
}
|
||||||
|
if (this.editMode) {
|
||||||
mounted() {
|
this.handleUpdateAlbum();
|
||||||
if (this.editMode) {
|
} else {
|
||||||
this.albumName = this.album.basename
|
this.handleCreateAlbum(collaborators);
|
||||||
this.albumLocation = this.album.location
|
|
||||||
}
|
|
||||||
this.$nextTick(() => {
|
|
||||||
(<any>this.$refs.nameInput).$el.getElementsByTagName('input')[0].focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
submit(collaborators = []) {
|
|
||||||
if (this.albumName === '' || this.loading) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (this.editMode) {
|
|
||||||
this.handleUpdateAlbum()
|
|
||||||
} else {
|
|
||||||
this.handleCreateAlbum(collaborators)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async handleCreateAlbum(collaborators = []) {
|
async handleCreateAlbum(collaborators = []) {
|
||||||
try {
|
try {
|
||||||
this.loading = true
|
this.loading = true;
|
||||||
let album = {
|
let album = {
|
||||||
basename: this.albumName,
|
basename: this.albumName,
|
||||||
filename: `/photos/${getCurrentUser().uid}/albums/${this.albumName}`,
|
filename: `/photos/${getCurrentUser().uid}/albums/${this.albumName}`,
|
||||||
nbItems: 0,
|
nbItems: 0,
|
||||||
location: this.albumLocation,
|
location: this.albumLocation,
|
||||||
lastPhoto: -1,
|
lastPhoto: -1,
|
||||||
date: moment().format('MMMM YYYY'),
|
date: moment().format("MMMM YYYY"),
|
||||||
collaborators,
|
collaborators,
|
||||||
}
|
};
|
||||||
await dav.createAlbum(album.basename);
|
await dav.createAlbum(album.basename);
|
||||||
|
|
||||||
if (this.albumLocation !== '' || collaborators.length !== 0) {
|
if (this.albumLocation !== "" || collaborators.length !== 0) {
|
||||||
album = await dav.updateAlbum(album, {
|
album = await dav.updateAlbum(album, {
|
||||||
albumName: this.albumName,
|
albumName: this.albumName,
|
||||||
properties: {
|
properties: {
|
||||||
location: this.albumLocation,
|
location: this.albumLocation,
|
||||||
collaborators,
|
collaborators,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$emit('done', { album })
|
this.$emit("done", { album });
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false
|
this.loading = false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async handleUpdateAlbum() {
|
async handleUpdateAlbum() {
|
||||||
try {
|
try {
|
||||||
this.loading = true
|
this.loading = true;
|
||||||
let album = { ...this.album }
|
let album = { ...this.album };
|
||||||
if (this.album.basename !== this.albumName) {
|
if (this.album.basename !== this.albumName) {
|
||||||
album = await dav.renameAlbum(this.album, { currentAlbumName: this.album.basename, newAlbumName: this.albumName })
|
album = await dav.renameAlbum(this.album, {
|
||||||
}
|
currentAlbumName: this.album.basename,
|
||||||
if (this.album.location !== this.albumLocation) {
|
newAlbumName: this.albumName,
|
||||||
album.location = await dav.updateAlbum(this.album, { albumName: this.albumName, properties: { location: this.albumLocation } })
|
});
|
||||||
}
|
}
|
||||||
this.$emit('done', { album })
|
if (this.album.location !== this.albumLocation) {
|
||||||
} finally {
|
album.location = await dav.updateAlbum(this.album, {
|
||||||
this.loading = false
|
albumName: this.albumName,
|
||||||
}
|
properties: { location: this.albumLocation },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.$emit("done", { album });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Emit('back')
|
@Emit("back")
|
||||||
back() {}
|
back() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.album-form {
|
.album-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 350px;
|
height: 350px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
.form-title {
|
.form-title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
.form-subtitle {
|
.form-subtitle {
|
||||||
color: var(--color-text-lighter);
|
color: var(--color-text-lighter);
|
||||||
}
|
}
|
||||||
.form-inputs {
|
.form-inputs {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
justify-items: flex-end;
|
justify-items: flex-end;
|
||||||
input {
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
label {
|
label {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
:deep svg {
|
:deep svg {
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.form-buttons {
|
.form-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
.left-buttons, .right-buttons {
|
.left-buttons,
|
||||||
display: flex;
|
.right-buttons {
|
||||||
}
|
display: flex;
|
||||||
.right-buttons {
|
}
|
||||||
justify-content: flex-end;
|
.right-buttons {
|
||||||
}
|
justify-content: flex-end;
|
||||||
button {
|
}
|
||||||
margin-right: 16px;
|
button {
|
||||||
}
|
margin-right: 16px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.left-buttons {
|
.left-buttons {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -20,168 +20,185 @@
|
||||||
-
|
-
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<div v-if="!showAlbumCreationForm" class="album-picker">
|
<div v-if="!showAlbumCreationForm" class="album-picker">
|
||||||
<NcLoadingIcon v-if="loadingAlbums" class="loading-icon" />
|
<NcLoadingIcon v-if="loadingAlbums" class="loading-icon" />
|
||||||
|
|
||||||
<ul class="albums-container">
|
<ul class="albums-container">
|
||||||
<NcListItem v-for="album in albums"
|
<NcListItem
|
||||||
:key="album.album_id"
|
v-for="album in albums"
|
||||||
class="album"
|
:key="album.album_id"
|
||||||
:title="getAlbumName(album)"
|
class="album"
|
||||||
:aria-label="t('photos', 'Add selection to album {albumName}', {albumName: getAlbumName(album)})"
|
:title="getAlbumName(album)"
|
||||||
@click="pickAlbum(album)">
|
:aria-label="
|
||||||
<template slot="icon">
|
t('photos', 'Add selection to album {albumName}', {
|
||||||
<img v-if="album.last_added_photo !== -1" class="album__image" :src="album.last_added_photo | toCoverUrl">
|
albumName: getAlbumName(album),
|
||||||
<div v-else class="album__image album__image--placeholder">
|
})
|
||||||
<ImageMultiple :size="32" />
|
"
|
||||||
</div>
|
@click="pickAlbum(album)"
|
||||||
</template>
|
>
|
||||||
|
<template slot="icon">
|
||||||
|
<img
|
||||||
|
v-if="album.last_added_photo !== -1"
|
||||||
|
class="album__image"
|
||||||
|
:src="album.last_added_photo | toCoverUrl"
|
||||||
|
/>
|
||||||
|
<div v-else class="album__image album__image--placeholder">
|
||||||
|
<ImageMultiple :size="32" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template slot="subtitle">
|
<template slot="subtitle">
|
||||||
{{ n('photos', '%n item', '%n items', album.count) }}
|
{{ n("photos", "%n item", "%n items", album.count) }}
|
||||||
<!-- TODO: finish collaboration -->
|
<!-- TODO: finish collaboration -->
|
||||||
<!--⸱ {{ n('photos', 'Share with %n user', 'Share with %n users', album.isShared) }}-->
|
<!--⸱ {{ n('photos', 'Share with %n user', 'Share with %n users', album.isShared) }}-->
|
||||||
</template>
|
</template>
|
||||||
</NcListItem>
|
</NcListItem>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<NcButton :aria-label="t('photos', 'Create a new album.')"
|
<NcButton
|
||||||
class="new-album-button"
|
:aria-label="t('photos', 'Create a new album.')"
|
||||||
type="tertiary"
|
class="new-album-button"
|
||||||
@click="showAlbumCreationForm = true">
|
type="tertiary"
|
||||||
<template #icon>
|
@click="showAlbumCreationForm = true"
|
||||||
<Plus />
|
>
|
||||||
</template>
|
<template #icon>
|
||||||
{{ t('photos', 'Create new album') }}
|
<Plus />
|
||||||
</NcButton>
|
</template>
|
||||||
</div>
|
{{ t("photos", "Create new album") }}
|
||||||
|
</NcButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AlbumForm v-else
|
<AlbumForm
|
||||||
:display-back-button="true"
|
v-else
|
||||||
:title="t('photos', 'New album')"
|
:display-back-button="true"
|
||||||
@back="showAlbumCreationForm = false"
|
:title="t('photos', 'New album')"
|
||||||
@done="albumCreatedHandler" />
|
@back="showAlbumCreationForm = false"
|
||||||
|
@done="albumCreatedHandler"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Emit, Mixins } from 'vue-property-decorator';
|
import { Component, Emit, Mixins } from "vue-property-decorator";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
import { getCurrentUser } from '@nextcloud/auth';
|
import { getCurrentUser } from "@nextcloud/auth";
|
||||||
|
|
||||||
import AlbumForm from './AlbumForm.vue'
|
import AlbumForm from "./AlbumForm.vue";
|
||||||
import Plus from 'vue-material-design-icons/Plus.vue'
|
import Plus from "vue-material-design-icons/Plus.vue";
|
||||||
import ImageMultiple from 'vue-material-design-icons/ImageMultiple.vue'
|
import ImageMultiple from "vue-material-design-icons/ImageMultiple.vue";
|
||||||
|
|
||||||
import { NcButton, NcListItem, NcLoadingIcon } from '@nextcloud/vue'
|
import { NcButton, NcListItem, NcLoadingIcon } from "@nextcloud/vue";
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from "@nextcloud/router";
|
||||||
import { IAlbum } from '../../types';
|
import { IAlbum } from "../../types";
|
||||||
import axios from '@nextcloud/axios'
|
import axios from "@nextcloud/axios";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
AlbumForm,
|
AlbumForm,
|
||||||
Plus,
|
Plus,
|
||||||
ImageMultiple,
|
ImageMultiple,
|
||||||
NcButton,
|
NcButton,
|
||||||
NcListItem,
|
NcListItem,
|
||||||
NcLoadingIcon,
|
NcLoadingIcon,
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
toCoverUrl(fileId: string) {
|
||||||
|
return generateUrl(
|
||||||
|
`/apps/photos/api/v1/preview/${fileId}?x=${256}&y=${256}`
|
||||||
|
);
|
||||||
},
|
},
|
||||||
filters: {
|
},
|
||||||
toCoverUrl(fileId: string) {
|
|
||||||
return generateUrl(`/apps/photos/api/v1/preview/${fileId}?x=${256}&y=${256}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
export default class AlbumPicker extends Mixins(GlobalMixin) {
|
export default class AlbumPicker extends Mixins(GlobalMixin) {
|
||||||
private showAlbumCreationForm = false;
|
private showAlbumCreationForm = false;
|
||||||
private albums: IAlbum[] = [];
|
private albums: IAlbum[] = [];
|
||||||
private loadingAlbums = true;
|
private loadingAlbums = true;
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.loadAlbums();
|
this.loadAlbums();
|
||||||
|
}
|
||||||
|
|
||||||
|
albumCreatedHandler() {
|
||||||
|
this.showAlbumCreationForm = false;
|
||||||
|
this.loadAlbums();
|
||||||
|
}
|
||||||
|
|
||||||
|
getAlbumName(album: IAlbum) {
|
||||||
|
if (album.user === getCurrentUser()?.uid) {
|
||||||
|
return album.name;
|
||||||
}
|
}
|
||||||
|
return `${album.name} (${album.user})`;
|
||||||
|
}
|
||||||
|
|
||||||
albumCreatedHandler() {
|
async loadAlbums() {
|
||||||
this.showAlbumCreationForm = false
|
try {
|
||||||
this.loadAlbums();
|
const res = await axios.get<IAlbum[]>(
|
||||||
|
generateUrl("/apps/memories/api/albums?t=3")
|
||||||
|
);
|
||||||
|
this.albums = res.data;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
this.loadingAlbums = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getAlbumName(album: IAlbum) {
|
@Emit("select")
|
||||||
if (album.user === getCurrentUser()?.uid) {
|
pickAlbum(album: IAlbum) {}
|
||||||
return album.name
|
|
||||||
}
|
|
||||||
return `${album.name} (${album.user})`
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadAlbums() {
|
|
||||||
try {
|
|
||||||
const res = await axios.get<IAlbum[]>(generateUrl('/apps/memories/api/albums?t=3'));
|
|
||||||
this.albums = res.data;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
this.loadingAlbums = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Emit('select')
|
|
||||||
pickAlbum(album: IAlbum) {}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.album-picker {
|
.album-picker {
|
||||||
h2 {
|
h2 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
|
|
||||||
.loading-icon {
|
.loading-icon {
|
||||||
margin-left: 32px;
|
margin-left: 32px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.albums-container {
|
.albums-container {
|
||||||
min-height: 150px;
|
min-height: 150px;
|
||||||
max-height: 350px;
|
max-height: 350px;
|
||||||
overflow-x: scroll;
|
overflow-x: scroll;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
|
|
||||||
.album {
|
.album {
|
||||||
|
:deep .list-item {
|
||||||
|
padding: 8px 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
:deep .list-item {
|
&:not(:last-child) {
|
||||||
padding: 8px 16px;
|
margin-bottom: 16px;
|
||||||
box-sizing: border-box;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
&__image {
|
||||||
margin-bottom: 16px;
|
width: 40px;
|
||||||
}
|
height: 40px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
|
||||||
&__image {
|
&--placeholder {
|
||||||
width: 40px;
|
background: var(--color-primary-light);
|
||||||
height: 40px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
|
|
||||||
&--placeholder {
|
:deep .material-design-icon {
|
||||||
background: var(--color-primary-light);
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
:deep .material-design-icon {
|
.material-design-icon__svg {
|
||||||
width: 100%;
|
fill: var(--color-primary);
|
||||||
height: 100%;
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.material-design-icon__svg {
|
.new-album-button {
|
||||||
fill: var(--color-primary);
|
margin-top: 32px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-album-button {
|
|
||||||
margin-top: 32px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,76 +1,83 @@
|
||||||
<template>
|
<template>
|
||||||
<Modal @close="close" v-if="show">
|
<Modal @close="close" v-if="show">
|
||||||
<template #title>
|
<template #title>
|
||||||
{{ t('memories', 'Share Album') }}
|
{{ t("memories", "Share Album") }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<AlbumCollaborators v-if="album"
|
<AlbumCollaborators
|
||||||
:album-name="album.basename"
|
v-if="album"
|
||||||
:collaborators="album.collaborators"
|
:album-name="album.basename"
|
||||||
:public-link="album.publicLink">
|
:collaborators="album.collaborators"
|
||||||
<template slot-scope="{collaborators}">
|
:public-link="album.publicLink"
|
||||||
<NcButton :aria-label="t('photos', 'Save collaborators for this album.')"
|
>
|
||||||
type="primary"
|
<template slot-scope="{ collaborators }">
|
||||||
:disabled="loadingAddCollaborators"
|
<NcButton
|
||||||
@click="handleSetCollaborators(collaborators)">
|
:aria-label="t('photos', 'Save collaborators for this album.')"
|
||||||
<template #icon>
|
type="primary"
|
||||||
<NcLoadingIcon v-if="loadingAddCollaborators" />
|
:disabled="loadingAddCollaborators"
|
||||||
</template>
|
@click="handleSetCollaborators(collaborators)"
|
||||||
{{ t('photos', 'Save') }}
|
>
|
||||||
</NcButton>
|
<template #icon>
|
||||||
</template>
|
<NcLoadingIcon v-if="loadingAddCollaborators" />
|
||||||
</AlbumCollaborators>
|
</template>
|
||||||
</Modal>
|
{{ t("photos", "Save") }}
|
||||||
|
</NcButton>
|
||||||
|
</template>
|
||||||
|
</AlbumCollaborators>
|
||||||
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Emit, Mixins } from 'vue-property-decorator';
|
import { Component, Emit, Mixins } from "vue-property-decorator";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
|
|
||||||
import { NcButton, NcLoadingIcon } from '@nextcloud/vue';
|
import { NcButton, NcLoadingIcon } from "@nextcloud/vue";
|
||||||
import * as dav from '../../services/DavRequests';
|
import * as dav from "../../services/DavRequests";
|
||||||
|
|
||||||
import Modal from './Modal.vue';
|
import Modal from "./Modal.vue";
|
||||||
import AlbumCollaborators from './AlbumCollaborators.vue';
|
import AlbumCollaborators from "./AlbumCollaborators.vue";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
NcButton,
|
NcButton,
|
||||||
NcLoadingIcon,
|
NcLoadingIcon,
|
||||||
Modal,
|
Modal,
|
||||||
AlbumCollaborators,
|
AlbumCollaborators,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
export default class AlbumShareModal extends Mixins(GlobalMixin) {
|
export default class AlbumShareModal extends Mixins(GlobalMixin) {
|
||||||
private album: any = null;
|
private album: any = null;
|
||||||
private show = false;
|
private show = false;
|
||||||
private loadingAddCollaborators = false;
|
private loadingAddCollaborators = false;
|
||||||
|
|
||||||
@Emit('close')
|
@Emit("close")
|
||||||
public close() {
|
public close() {
|
||||||
this.show = false;
|
this.show = false;
|
||||||
this.album = null;
|
this.album = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async open() {
|
public async open() {
|
||||||
this.show = true;
|
this.show = true;
|
||||||
this.loadingAddCollaborators = true;
|
this.loadingAddCollaborators = true;
|
||||||
const user = this.$route.params.user || '';
|
const user = this.$route.params.user || "";
|
||||||
const name = this.$route.params.name || '';
|
const name = this.$route.params.name || "";
|
||||||
this.album = await dav.getAlbum(user, name);
|
this.album = await dav.getAlbum(user, name);
|
||||||
this.loadingAddCollaborators = false;
|
this.loadingAddCollaborators = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleSetCollaborators(collaborators: any[]) {
|
async handleSetCollaborators(collaborators: any[]) {
|
||||||
try {
|
try {
|
||||||
this.loadingAddCollaborators = true
|
this.loadingAddCollaborators = true;
|
||||||
await dav.updateAlbum(this.album, { albumName: this.album.basename, properties: { collaborators } })
|
await dav.updateAlbum(this.album, {
|
||||||
this.close();
|
albumName: this.album.basename,
|
||||||
} catch (error) {
|
properties: { collaborators },
|
||||||
console.error(error);
|
});
|
||||||
} finally {
|
this.close();
|
||||||
this.loadingAddCollaborators = false
|
} catch (error) {
|
||||||
}
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
this.loadingAddCollaborators = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
|
@ -1,425 +1,463 @@
|
||||||
<template>
|
<template>
|
||||||
<Modal
|
<Modal v-if="photos.length > 0" @close="close">
|
||||||
v-if="photos.length > 0"
|
<template #title>
|
||||||
@close="close">
|
{{ t("memories", "Edit Date/Time") }}
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #title>
|
<template #buttons>
|
||||||
{{ t('memories', 'Edit Date/Time') }}
|
<NcButton @click="save" class="button" type="error" v-if="longDateStr">
|
||||||
</template>
|
{{ t("memories", "Update Exif") }}
|
||||||
|
</NcButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #buttons>
|
<div v-if="longDateStr">
|
||||||
<NcButton @click="save" class="button" type="error" v-if="longDateStr">
|
<span v-if="photos.length > 1"> [{{ t("memories", "Newest") }}] </span>
|
||||||
{{ t('memories', 'Update Exif') }}
|
{{ longDateStr }}
|
||||||
</NcButton>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="longDateStr">
|
<div class="fields">
|
||||||
<span v-if="photos.length > 1">
|
<NcTextField
|
||||||
[{{ t('memories', 'Newest') }}]
|
:value.sync="year"
|
||||||
</span>
|
class="field"
|
||||||
{{ longDateStr }}
|
@input="newestChange()"
|
||||||
|
:label="t('memories', 'Year')"
|
||||||
|
:label-visible="true"
|
||||||
|
:placeholder="t('memories', 'Year')"
|
||||||
|
/>
|
||||||
|
<NcTextField
|
||||||
|
:value.sync="month"
|
||||||
|
class="field"
|
||||||
|
@input="newestChange()"
|
||||||
|
:label="t('memories', 'Month')"
|
||||||
|
:label-visible="true"
|
||||||
|
:placeholder="t('memories', 'Month')"
|
||||||
|
/>
|
||||||
|
<NcTextField
|
||||||
|
:value.sync="day"
|
||||||
|
class="field"
|
||||||
|
@input="newestChange()"
|
||||||
|
:label="t('memories', 'Day')"
|
||||||
|
:label-visible="true"
|
||||||
|
:placeholder="t('memories', 'Day')"
|
||||||
|
/>
|
||||||
|
<NcTextField
|
||||||
|
:value.sync="hour"
|
||||||
|
class="field"
|
||||||
|
@input="newestChange(true)"
|
||||||
|
:label="t('memories', 'Time')"
|
||||||
|
:label-visible="true"
|
||||||
|
:placeholder="t('memories', 'Hour')"
|
||||||
|
/>
|
||||||
|
<NcTextField
|
||||||
|
:value.sync="minute"
|
||||||
|
class="field"
|
||||||
|
@input="newestChange(true)"
|
||||||
|
:label="t('memories', 'Minute')"
|
||||||
|
:placeholder="t('memories', 'Minute')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="fields">
|
<div v-if="photos.length > 1" class="oldest">
|
||||||
<NcTextField :value.sync="year"
|
<span> [{{ t("memories", "Oldest") }}] </span>
|
||||||
class="field"
|
{{ longDateStrLast }}
|
||||||
@input="newestChange()"
|
|
||||||
:label="t('memories', 'Year')" :label-visible="true"
|
|
||||||
:placeholder="t('memories', 'Year')" />
|
|
||||||
<NcTextField :value.sync="month"
|
|
||||||
class="field"
|
|
||||||
@input="newestChange()"
|
|
||||||
:label="t('memories', 'Month')" :label-visible="true"
|
|
||||||
:placeholder="t('memories', 'Month')" />
|
|
||||||
<NcTextField :value.sync="day"
|
|
||||||
class="field"
|
|
||||||
@input="newestChange()"
|
|
||||||
:label="t('memories', 'Day')" :label-visible="true"
|
|
||||||
:placeholder="t('memories', 'Day')" />
|
|
||||||
<NcTextField :value.sync="hour"
|
|
||||||
class="field"
|
|
||||||
@input="newestChange(true)"
|
|
||||||
:label="t('memories', 'Time')" :label-visible="true"
|
|
||||||
:placeholder="t('memories', 'Hour')" />
|
|
||||||
<NcTextField :value.sync="minute"
|
|
||||||
class="field"
|
|
||||||
@input="newestChange(true)"
|
|
||||||
:label="t('memories', 'Minute')"
|
|
||||||
:placeholder="t('memories', 'Minute')" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="photos.length > 1" class="oldest">
|
<div class="fields">
|
||||||
<span>
|
<NcTextField
|
||||||
[{{ t('memories', 'Oldest') }}]
|
:value.sync="yearLast"
|
||||||
</span>
|
class="field"
|
||||||
{{ longDateStrLast }}
|
:label="t('memories', 'Year')"
|
||||||
|
:label-visible="true"
|
||||||
<div class="fields">
|
:placeholder="t('memories', 'Year')"
|
||||||
<NcTextField :value.sync="yearLast"
|
/>
|
||||||
class="field"
|
<NcTextField
|
||||||
:label="t('memories', 'Year')" :label-visible="true"
|
:value.sync="monthLast"
|
||||||
:placeholder="t('memories', 'Year')" />
|
class="field"
|
||||||
<NcTextField :value.sync="monthLast"
|
:label="t('memories', 'Month')"
|
||||||
class="field"
|
:label-visible="true"
|
||||||
:label="t('memories', 'Month')" :label-visible="true"
|
:placeholder="t('memories', 'Month')"
|
||||||
:placeholder="t('memories', 'Month')" />
|
/>
|
||||||
<NcTextField :value.sync="dayLast"
|
<NcTextField
|
||||||
class="field"
|
:value.sync="dayLast"
|
||||||
:label="t('memories', 'Day')" :label-visible="true"
|
class="field"
|
||||||
:placeholder="t('memories', 'Day')" />
|
:label="t('memories', 'Day')"
|
||||||
<NcTextField :value.sync="hourLast"
|
:label-visible="true"
|
||||||
class="field"
|
:placeholder="t('memories', 'Day')"
|
||||||
:label="t('memories', 'Time')" :label-visible="true"
|
/>
|
||||||
:placeholder="t('memories', 'Hour')" />
|
<NcTextField
|
||||||
<NcTextField :value.sync="minuteLast"
|
:value.sync="hourLast"
|
||||||
class="field"
|
class="field"
|
||||||
:label="t('memories', 'Minute')"
|
:label="t('memories', 'Time')"
|
||||||
:placeholder="t('memories', 'Minute')" />
|
:label-visible="true"
|
||||||
</div>
|
:placeholder="t('memories', 'Hour')"
|
||||||
</div>
|
/>
|
||||||
|
<NcTextField
|
||||||
<div v-if="processing" class="info-pad">
|
:value.sync="minuteLast"
|
||||||
{{ t('memories', 'Processing … {n}/{m}', {
|
class="field"
|
||||||
n: photosDone,
|
:label="t('memories', 'Minute')"
|
||||||
m: photos.length,
|
:placeholder="t('memories', 'Minute')"
|
||||||
}) }}
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-pad warn">
|
|
||||||
{{ t('memories', 'This feature modifies files in your storage to update Exif data.') }}
|
|
||||||
{{ t('memories', 'Exercise caution and make sure you have backups.') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-if="processing" class="info-pad">
|
||||||
{{ t('memories', 'Loading data … {n}/{m}', {
|
{{
|
||||||
n: photosDone,
|
t("memories", "Processing … {n}/{m}", {
|
||||||
m: photos.length,
|
n: photosDone,
|
||||||
}) }}
|
m: photos.length,
|
||||||
</div>
|
})
|
||||||
</Modal>
|
}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-pad warn">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
"memories",
|
||||||
|
"This feature modifies files in your storage to update Exif data."
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{ t("memories", "Exercise caution and make sure you have backups.") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
{{
|
||||||
|
t("memories", "Loading data … {n}/{m}", {
|
||||||
|
n: photosDone,
|
||||||
|
m: photos.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Emit, Mixins } from 'vue-property-decorator';
|
import { Component, Emit, Mixins } from "vue-property-decorator";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
import { IPhoto } from '../../types';
|
import { IPhoto } from "../../types";
|
||||||
|
|
||||||
import { NcButton, NcTextField } from '@nextcloud/vue';
|
import { NcButton, NcTextField } from "@nextcloud/vue";
|
||||||
import { showError } from '@nextcloud/dialogs'
|
import { showError } from "@nextcloud/dialogs";
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from "@nextcloud/router";
|
||||||
import Modal from './Modal.vue';
|
import Modal from "./Modal.vue";
|
||||||
import axios from '@nextcloud/axios'
|
import axios from "@nextcloud/axios";
|
||||||
import * as utils from '../../services/Utils';
|
import * as utils from "../../services/Utils";
|
||||||
import * as dav from "../../services/DavRequests";
|
import * as dav from "../../services/DavRequests";
|
||||||
|
|
||||||
const INFO_API_URL = '/apps/memories/api/info/{id}';
|
const INFO_API_URL = "/apps/memories/api/info/{id}";
|
||||||
const EDIT_API_URL = '/apps/memories/api/edit/{id}';
|
const EDIT_API_URL = "/apps/memories/api/edit/{id}";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
NcButton,
|
NcButton,
|
||||||
NcTextField,
|
NcTextField,
|
||||||
Modal,
|
Modal,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
export default class EditDate extends Mixins(GlobalMixin) {
|
export default class EditDate extends Mixins(GlobalMixin) {
|
||||||
@Emit('refresh') emitRefresh(val: boolean) {}
|
@Emit("refresh") emitRefresh(val: boolean) {}
|
||||||
|
|
||||||
private photos: IPhoto[] = [];
|
private photos: IPhoto[] = [];
|
||||||
private photosDone: number = 0;
|
private photosDone: number = 0;
|
||||||
private processing: boolean = false;
|
private processing: boolean = false;
|
||||||
|
|
||||||
private longDateStr: string = '';
|
private longDateStr: string = "";
|
||||||
private year: string = "0";
|
private year: string = "0";
|
||||||
private month: string = "0";
|
private month: string = "0";
|
||||||
private day: string = "0";
|
private day: string = "0";
|
||||||
private hour: string = "0";
|
private hour: string = "0";
|
||||||
private minute: string = "0";
|
private minute: string = "0";
|
||||||
private second: string = "0";
|
private second: string = "0";
|
||||||
|
|
||||||
private longDateStrLast: string = '';
|
private longDateStrLast: string = "";
|
||||||
private yearLast: string = "0";
|
private yearLast: string = "0";
|
||||||
private monthLast: string = "0";
|
private monthLast: string = "0";
|
||||||
private dayLast: string = "0";
|
private dayLast: string = "0";
|
||||||
private hourLast: string = "0";
|
private hourLast: string = "0";
|
||||||
private minuteLast: string = "0";
|
private minuteLast: string = "0";
|
||||||
private secondLast: string = "0";
|
private secondLast: string = "0";
|
||||||
|
|
||||||
public async open(photos: IPhoto[]) {
|
public async open(photos: IPhoto[]) {
|
||||||
this.photos = photos;
|
this.photos = photos;
|
||||||
if (photos.length === 0) {
|
if (photos.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
this.photosDone = 0;
|
||||||
|
this.longDateStr = "";
|
||||||
|
|
||||||
|
const calls = photos.map((p) => async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get<any>(
|
||||||
|
generateUrl(INFO_API_URL, { id: p.fileid })
|
||||||
|
);
|
||||||
|
if (typeof res.data.datetaken !== "number") {
|
||||||
|
console.error("Invalid date for", p.fileid);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
this.photosDone = 0;
|
p.datetaken = res.data.datetaken * 1000;
|
||||||
this.longDateStr = '';
|
} catch (error) {
|
||||||
|
console.error("Failed to get date info for", p.fileid, error);
|
||||||
|
} finally {
|
||||||
|
this.photosDone++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const calls = photos.map((p) => async () => {
|
for await (const _ of dav.runInParallel(calls, 10)) {
|
||||||
try {
|
// nothing to do
|
||||||
const res = await axios.get<any>(generateUrl(INFO_API_URL, { id: p.fileid }));
|
|
||||||
if (typeof res.data.datetaken !== "number") {
|
|
||||||
console.error("Invalid date for", p.fileid);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
p.datetaken = res.data.datetaken * 1000;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get date info for', p.fileid, error);
|
|
||||||
} finally {
|
|
||||||
this.photosDone++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for await (const _ of dav.runInParallel(calls, 10)) {
|
|
||||||
// nothing to do
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove photos without datetaken
|
|
||||||
this.photos = this.photos.filter((p) => p.datetaken !== undefined);
|
|
||||||
|
|
||||||
// Sort photos by datetaken descending
|
|
||||||
this.photos.sort((a, b) => b.datetaken - a.datetaken);
|
|
||||||
|
|
||||||
// Get date of newest photo
|
|
||||||
let date = new Date(this.photos[0].datetaken);
|
|
||||||
this.year = date.getUTCFullYear().toString();
|
|
||||||
this.month = (date.getUTCMonth() + 1).toString();
|
|
||||||
this.day = date.getUTCDate().toString();
|
|
||||||
this.hour = date.getUTCHours().toString();
|
|
||||||
this.minute = date.getUTCMinutes().toString();
|
|
||||||
this.second = date.getUTCSeconds().toString();
|
|
||||||
this.longDateStr = utils.getLongDateStr(date, false, true);
|
|
||||||
|
|
||||||
// Get date of oldest photo
|
|
||||||
if (this.photos.length > 1) {
|
|
||||||
date = new Date(this.photos[this.photos.length - 1].datetaken);
|
|
||||||
this.yearLast = date.getUTCFullYear().toString();
|
|
||||||
this.monthLast = (date.getUTCMonth() + 1).toString();
|
|
||||||
this.dayLast = date.getUTCDate().toString();
|
|
||||||
this.hourLast = date.getUTCHours().toString();
|
|
||||||
this.minuteLast = date.getUTCMinutes().toString();
|
|
||||||
this.secondLast = date.getUTCSeconds().toString();
|
|
||||||
this.longDateStrLast = utils.getLongDateStr(date, false, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public newestChange(time=false) {
|
// Remove photos without datetaken
|
||||||
if (this.photos.length === 0) {
|
this.photos = this.photos.filter((p) => p.datetaken !== undefined);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the last date to have the same offset to newest date
|
// Sort photos by datetaken descending
|
||||||
try {
|
this.photos.sort((a, b) => b.datetaken - a.datetaken);
|
||||||
const date = new Date(this.photos[0].datetaken);
|
|
||||||
const dateLast = new Date(this.photos[this.photos.length - 1].datetaken);
|
|
||||||
|
|
||||||
const dateNew = this.getDate();
|
// Get date of newest photo
|
||||||
const offset = dateNew.getTime() - date.getTime();
|
let date = new Date(this.photos[0].datetaken);
|
||||||
const dateLastNew = new Date(dateLast.getTime() + offset);
|
this.year = date.getUTCFullYear().toString();
|
||||||
|
this.month = (date.getUTCMonth() + 1).toString();
|
||||||
|
this.day = date.getUTCDate().toString();
|
||||||
|
this.hour = date.getUTCHours().toString();
|
||||||
|
this.minute = date.getUTCMinutes().toString();
|
||||||
|
this.second = date.getUTCSeconds().toString();
|
||||||
|
this.longDateStr = utils.getLongDateStr(date, false, true);
|
||||||
|
|
||||||
this.yearLast = dateLastNew.getUTCFullYear().toString();
|
// Get date of oldest photo
|
||||||
this.monthLast = (dateLastNew.getUTCMonth() + 1).toString();
|
if (this.photos.length > 1) {
|
||||||
this.dayLast = dateLastNew.getUTCDate().toString();
|
date = new Date(this.photos[this.photos.length - 1].datetaken);
|
||||||
|
this.yearLast = date.getUTCFullYear().toString();
|
||||||
|
this.monthLast = (date.getUTCMonth() + 1).toString();
|
||||||
|
this.dayLast = date.getUTCDate().toString();
|
||||||
|
this.hourLast = date.getUTCHours().toString();
|
||||||
|
this.minuteLast = date.getUTCMinutes().toString();
|
||||||
|
this.secondLast = date.getUTCSeconds().toString();
|
||||||
|
this.longDateStrLast = utils.getLongDateStr(date, false, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (time) {
|
public newestChange(time = false) {
|
||||||
this.hourLast = dateLastNew.getUTCHours().toString();
|
if (this.photos.length === 0) {
|
||||||
this.minuteLast = dateLastNew.getUTCMinutes().toString();
|
return;
|
||||||
this.secondLast = dateLastNew.getUTCSeconds().toString();
|
|
||||||
}
|
|
||||||
} catch (error) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public close() {
|
// Set the last date to have the same offset to newest date
|
||||||
this.photos = [];
|
try {
|
||||||
|
const date = new Date(this.photos[0].datetaken);
|
||||||
|
const dateLast = new Date(this.photos[this.photos.length - 1].datetaken);
|
||||||
|
|
||||||
|
const dateNew = this.getDate();
|
||||||
|
const offset = dateNew.getTime() - date.getTime();
|
||||||
|
const dateLastNew = new Date(dateLast.getTime() + offset);
|
||||||
|
|
||||||
|
this.yearLast = dateLastNew.getUTCFullYear().toString();
|
||||||
|
this.monthLast = (dateLastNew.getUTCMonth() + 1).toString();
|
||||||
|
this.dayLast = dateLastNew.getUTCDate().toString();
|
||||||
|
|
||||||
|
if (time) {
|
||||||
|
this.hourLast = dateLastNew.getUTCHours().toString();
|
||||||
|
this.minuteLast = dateLastNew.getUTCMinutes().toString();
|
||||||
|
this.secondLast = dateLastNew.getUTCSeconds().toString();
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
public close() {
|
||||||
|
this.photos = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async saveOne() {
|
||||||
|
// Make PATCH request to update date
|
||||||
|
try {
|
||||||
|
this.processing = true;
|
||||||
|
const res = await axios.patch<any>(
|
||||||
|
generateUrl(EDIT_API_URL, { id: this.photos[0].fileid }),
|
||||||
|
{
|
||||||
|
date: this.getExifFormat(this.getDate()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.emitRefresh(true);
|
||||||
|
this.close();
|
||||||
|
} catch (e) {
|
||||||
|
if (e.response?.data?.message) {
|
||||||
|
showError(e.response.data.message);
|
||||||
|
} else {
|
||||||
|
showError(e);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async saveMany() {
|
||||||
|
if (this.processing) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async saveOne() {
|
// Get difference between newest and oldest date
|
||||||
// Make PATCH request to update date
|
const date = new Date(this.photos[0].datetaken);
|
||||||
try {
|
const dateLast = new Date(this.photos[this.photos.length - 1].datetaken);
|
||||||
this.processing = true;
|
const diff = date.getTime() - dateLast.getTime();
|
||||||
const res = await axios.patch<any>(generateUrl(EDIT_API_URL, { id: this.photos[0].fileid }), {
|
|
||||||
date: this.getExifFormat(this.getDate()),
|
// Get new difference between newest and oldest date
|
||||||
});
|
let dateNew: Date;
|
||||||
this.emitRefresh(true);
|
let dateLastNew: Date;
|
||||||
this.close();
|
let diffNew: number;
|
||||||
} catch (e) {
|
|
||||||
if (e.response?.data?.message) {
|
try {
|
||||||
showError(e.response.data.message);
|
dateNew = this.getDate();
|
||||||
} else {
|
dateLastNew = this.getDateLast();
|
||||||
showError(e);
|
diffNew = dateNew.getTime() - dateLastNew.getTime();
|
||||||
}
|
} catch (e) {
|
||||||
} finally {
|
showError(e);
|
||||||
this.processing = false;
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async saveMany() {
|
// Validate if the old is still old
|
||||||
if (this.processing) {
|
if (diffNew < 0) {
|
||||||
return;
|
showError("The newest date must be newer than the oldest date");
|
||||||
}
|
return;
|
||||||
|
|
||||||
// Get difference between newest and oldest date
|
|
||||||
const date = new Date(this.photos[0].datetaken);
|
|
||||||
const dateLast = new Date(this.photos[this.photos.length - 1].datetaken);
|
|
||||||
const diff = date.getTime() - dateLast.getTime();
|
|
||||||
|
|
||||||
// Get new difference between newest and oldest date
|
|
||||||
let dateNew: Date;
|
|
||||||
let dateLastNew: Date;
|
|
||||||
let diffNew: number;
|
|
||||||
|
|
||||||
try {
|
|
||||||
dateNew = this.getDate();
|
|
||||||
dateLastNew = this.getDateLast();
|
|
||||||
diffNew = dateNew.getTime() - dateLastNew.getTime();
|
|
||||||
} catch (e) {
|
|
||||||
showError(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate if the old is still old
|
|
||||||
if (diffNew < 0) {
|
|
||||||
showError("The newest date must be newer than the oldest date");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark processing
|
|
||||||
this.processing = true;
|
|
||||||
this.photosDone = 0;
|
|
||||||
|
|
||||||
// Create PATCH requests
|
|
||||||
const calls = this.photos.map((p) => async () => {
|
|
||||||
try {
|
|
||||||
let pDate = new Date(p.datetaken);
|
|
||||||
|
|
||||||
// Fallback to start date if invalid date
|
|
||||||
if (isNaN(pDate.getTime())) {
|
|
||||||
pDate = date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const offset = date.getTime() - pDate.getTime();
|
|
||||||
const scale = diff > 0 ? (diffNew / diff) : 0;
|
|
||||||
const pDateNew = new Date(dateNew.getTime() - offset * scale);
|
|
||||||
const res = await axios.patch<any>(generateUrl(EDIT_API_URL, { id: p.fileid }), {
|
|
||||||
date: this.getExifFormat(pDateNew),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (e.response?.data?.message) {
|
|
||||||
showError(e.response.data.message);
|
|
||||||
} else {
|
|
||||||
showError(e);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this.photosDone++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for await (const _ of dav.runInParallel(calls, 10)) {
|
|
||||||
// nothing to do
|
|
||||||
}
|
|
||||||
this.processing = false;
|
|
||||||
this.emitRefresh(true);
|
|
||||||
this.close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async save() {
|
// Mark processing
|
||||||
if (this.photos.length === 0) {
|
this.processing = true;
|
||||||
return;
|
this.photosDone = 0;
|
||||||
|
|
||||||
|
// Create PATCH requests
|
||||||
|
const calls = this.photos.map((p) => async () => {
|
||||||
|
try {
|
||||||
|
let pDate = new Date(p.datetaken);
|
||||||
|
|
||||||
|
// Fallback to start date if invalid date
|
||||||
|
if (isNaN(pDate.getTime())) {
|
||||||
|
pDate = date;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.photos.length === 1) {
|
const offset = date.getTime() - pDate.getTime();
|
||||||
return await this.saveOne();
|
const scale = diff > 0 ? diffNew / diff : 0;
|
||||||
|
const pDateNew = new Date(dateNew.getTime() - offset * scale);
|
||||||
|
const res = await axios.patch<any>(
|
||||||
|
generateUrl(EDIT_API_URL, { id: p.fileid }),
|
||||||
|
{
|
||||||
|
date: this.getExifFormat(pDateNew),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.response?.data?.message) {
|
||||||
|
showError(e.response.data.message);
|
||||||
|
} else {
|
||||||
|
showError(e);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
this.photosDone++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return await this.saveMany();
|
for await (const _ of dav.runInParallel(calls, 10)) {
|
||||||
|
// nothing to do
|
||||||
|
}
|
||||||
|
this.processing = false;
|
||||||
|
this.emitRefresh(true);
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async save() {
|
||||||
|
if (this.photos.length === 0) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getExifFormat(date: Date) {
|
if (this.photos.length === 1) {
|
||||||
const year = date.getUTCFullYear().toString().padStart(4, "0");
|
return await this.saveOne();
|
||||||
const month = (date.getUTCMonth() + 1).toString().padStart(2, "0");
|
|
||||||
const day = date.getUTCDate().toString().padStart(2, "0");
|
|
||||||
const hour = date.getUTCHours().toString().padStart(2, "0");
|
|
||||||
const minute = date.getUTCMinutes().toString().padStart(2, "0");
|
|
||||||
const second = date.getUTCSeconds().toString().padStart(2, "0");
|
|
||||||
return `${year}:${month}:${day} ${hour}:${minute}:${second}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDate() {
|
return await this.saveMany();
|
||||||
const dateNew = new Date();
|
}
|
||||||
const year = parseInt(this.year, 10);
|
|
||||||
const month = parseInt(this.month, 10) - 1;
|
|
||||||
const day = parseInt(this.day, 10);
|
|
||||||
const hour = parseInt(this.hour, 10);
|
|
||||||
const minute = parseInt(this.minute, 10);
|
|
||||||
const second = parseInt(this.second, 10) || 0;
|
|
||||||
|
|
||||||
if (isNaN(year)) throw new Error("Invalid year");
|
private getExifFormat(date: Date) {
|
||||||
if (isNaN(month)) throw new Error("Invalid month");
|
const year = date.getUTCFullYear().toString().padStart(4, "0");
|
||||||
if (isNaN(day)) throw new Error("Invalid day");
|
const month = (date.getUTCMonth() + 1).toString().padStart(2, "0");
|
||||||
if (isNaN(hour)) throw new Error("Invalid hour");
|
const day = date.getUTCDate().toString().padStart(2, "0");
|
||||||
if (isNaN(minute)) throw new Error("Invalid minute");
|
const hour = date.getUTCHours().toString().padStart(2, "0");
|
||||||
if (isNaN(second)) throw new Error("Invalid second");
|
const minute = date.getUTCMinutes().toString().padStart(2, "0");
|
||||||
|
const second = date.getUTCSeconds().toString().padStart(2, "0");
|
||||||
|
return `${year}:${month}:${day} ${hour}:${minute}:${second}`;
|
||||||
|
}
|
||||||
|
|
||||||
dateNew.setUTCFullYear(year);
|
public getDate() {
|
||||||
dateNew.setUTCMonth(month);
|
const dateNew = new Date();
|
||||||
dateNew.setUTCDate(day);
|
const year = parseInt(this.year, 10);
|
||||||
dateNew.setUTCHours(hour);
|
const month = parseInt(this.month, 10) - 1;
|
||||||
dateNew.setUTCMinutes(minute);
|
const day = parseInt(this.day, 10);
|
||||||
dateNew.setUTCSeconds(second);
|
const hour = parseInt(this.hour, 10);
|
||||||
return dateNew;
|
const minute = parseInt(this.minute, 10);
|
||||||
}
|
const second = parseInt(this.second, 10) || 0;
|
||||||
|
|
||||||
public getDateLast() {
|
if (isNaN(year)) throw new Error("Invalid year");
|
||||||
const dateNew = new Date();
|
if (isNaN(month)) throw new Error("Invalid month");
|
||||||
const year = parseInt(this.yearLast, 10);
|
if (isNaN(day)) throw new Error("Invalid day");
|
||||||
const month = parseInt(this.monthLast, 10) - 1;
|
if (isNaN(hour)) throw new Error("Invalid hour");
|
||||||
const day = parseInt(this.dayLast, 10);
|
if (isNaN(minute)) throw new Error("Invalid minute");
|
||||||
const hour = parseInt(this.hourLast, 10);
|
if (isNaN(second)) throw new Error("Invalid second");
|
||||||
const minute = parseInt(this.minuteLast, 10);
|
|
||||||
const second = parseInt(this.secondLast, 10) || 0;
|
|
||||||
|
|
||||||
if (isNaN(year)) throw new Error("Invalid last year");
|
dateNew.setUTCFullYear(year);
|
||||||
if (isNaN(month)) throw new Error("Invalid last month");
|
dateNew.setUTCMonth(month);
|
||||||
if (isNaN(day)) throw new Error("Invalid last day");
|
dateNew.setUTCDate(day);
|
||||||
if (isNaN(hour)) throw new Error("Invalid last hour");
|
dateNew.setUTCHours(hour);
|
||||||
if (isNaN(minute)) throw new Error("Invalid last minute");
|
dateNew.setUTCMinutes(minute);
|
||||||
if (isNaN(second)) throw new Error("Invalid last second");
|
dateNew.setUTCSeconds(second);
|
||||||
|
return dateNew;
|
||||||
|
}
|
||||||
|
|
||||||
dateNew.setUTCFullYear(year);
|
public getDateLast() {
|
||||||
dateNew.setUTCMonth(month);
|
const dateNew = new Date();
|
||||||
dateNew.setUTCDate(day);
|
const year = parseInt(this.yearLast, 10);
|
||||||
dateNew.setUTCHours(hour);
|
const month = parseInt(this.monthLast, 10) - 1;
|
||||||
dateNew.setUTCMinutes(minute);
|
const day = parseInt(this.dayLast, 10);
|
||||||
dateNew.setUTCSeconds(second);
|
const hour = parseInt(this.hourLast, 10);
|
||||||
return dateNew;
|
const minute = parseInt(this.minuteLast, 10);
|
||||||
}
|
const second = parseInt(this.secondLast, 10) || 0;
|
||||||
|
|
||||||
|
if (isNaN(year)) throw new Error("Invalid last year");
|
||||||
|
if (isNaN(month)) throw new Error("Invalid last month");
|
||||||
|
if (isNaN(day)) throw new Error("Invalid last day");
|
||||||
|
if (isNaN(hour)) throw new Error("Invalid last hour");
|
||||||
|
if (isNaN(minute)) throw new Error("Invalid last minute");
|
||||||
|
if (isNaN(second)) throw new Error("Invalid last second");
|
||||||
|
|
||||||
|
dateNew.setUTCFullYear(year);
|
||||||
|
dateNew.setUTCMonth(month);
|
||||||
|
dateNew.setUTCDate(day);
|
||||||
|
dateNew.setUTCHours(hour);
|
||||||
|
dateNew.setUTCMinutes(minute);
|
||||||
|
dateNew.setUTCSeconds(second);
|
||||||
|
return dateNew;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.fields {
|
.fields {
|
||||||
.field {
|
.field {
|
||||||
width: 4.1em;
|
width: 4.1em;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep label {
|
:deep label {
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
padding-left: 3px !important;
|
padding-left: 3px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.oldest {
|
.oldest {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-pad {
|
.info-pad {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
|
|
||||||
&.warn {
|
&.warn {
|
||||||
color: #f44336;
|
color: #f44336;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
line-height: 1em;
|
line-height: 1em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,79 +1,87 @@
|
||||||
<template>
|
<template>
|
||||||
<Modal @close="close" v-if="show">
|
<Modal @close="close" v-if="show">
|
||||||
<template #title>
|
<template #title>
|
||||||
{{ t('memories', 'Remove person') }}
|
{{ t("memories", "Remove person") }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<span>{{ t('memories', 'Are you sure you want to remove {name}?', { name }) }}</span>
|
<span>{{
|
||||||
|
t("memories", "Are you sure you want to remove {name}?", { name })
|
||||||
|
}}</span>
|
||||||
|
|
||||||
<template #buttons>
|
<template #buttons>
|
||||||
<NcButton @click="save" class="button" type="error">
|
<NcButton @click="save" class="button" type="error">
|
||||||
{{ t('memories', 'Delete') }}
|
{{ t("memories", "Delete") }}
|
||||||
</NcButton>
|
</NcButton>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Emit, Mixins, Watch } from 'vue-property-decorator';
|
import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
|
||||||
import { NcButton, NcTextField } from '@nextcloud/vue';
|
import { NcButton, NcTextField } from "@nextcloud/vue";
|
||||||
import { showError } from '@nextcloud/dialogs';
|
import { showError } from "@nextcloud/dialogs";
|
||||||
import { getCurrentUser } from '@nextcloud/auth';
|
import { getCurrentUser } from "@nextcloud/auth";
|
||||||
import Modal from './Modal.vue';
|
import Modal from "./Modal.vue";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
import client from '../../services/DavClient';
|
import client from "../../services/DavClient";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
NcButton,
|
NcButton,
|
||||||
NcTextField,
|
NcTextField,
|
||||||
Modal,
|
Modal,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
export default class FaceDeleteModal extends Mixins(GlobalMixin) {
|
export default class FaceDeleteModal extends Mixins(GlobalMixin) {
|
||||||
private user: string = "";
|
private user: string = "";
|
||||||
private name: string = "";
|
private name: string = "";
|
||||||
private show = false;
|
private show = false;
|
||||||
|
|
||||||
@Emit('close')
|
@Emit("close")
|
||||||
public close() {
|
public close() {
|
||||||
this.show = false;
|
this.show = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public open() {
|
public open() {
|
||||||
const user = this.$route.params.user || '';
|
const user = this.$route.params.user || "";
|
||||||
if (this.$route.params.user !== getCurrentUser().uid) {
|
if (this.$route.params.user !== getCurrentUser().uid) {
|
||||||
showError(this.t('memories', 'Only user "{user}" can delete this person', { user }));
|
showError(
|
||||||
return;
|
this.t("memories", 'Only user "{user}" can delete this person', {
|
||||||
}
|
user,
|
||||||
this.show = true;
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
this.show = true;
|
||||||
|
}
|
||||||
|
|
||||||
@Watch('$route')
|
@Watch("$route")
|
||||||
async routeChange(from: any, to: any) {
|
async routeChange(from: any, to: any) {
|
||||||
this.refreshParams();
|
this.refreshParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refreshParams();
|
this.refreshParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
public refreshParams() {
|
public refreshParams() {
|
||||||
this.user = this.$route.params.user || '';
|
this.user = this.$route.params.user || "";
|
||||||
this.name = this.$route.params.name || '';
|
this.name = this.$route.params.name || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
public async save() {
|
public async save() {
|
||||||
try {
|
try {
|
||||||
await client.deleteFile(`/recognize/${this.user}/faces/${this.name}`)
|
await client.deleteFile(`/recognize/${this.user}/faces/${this.name}`);
|
||||||
this.$router.push({ name: 'people' });
|
this.$router.push({ name: "people" });
|
||||||
this.close();
|
this.close();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
showError(this.t('photos', 'Failed to delete {name}.', {
|
showError(
|
||||||
name: this.name,
|
this.t("photos", "Failed to delete {name}.", {
|
||||||
}));
|
name: this.name,
|
||||||
}
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
|
@ -1,97 +1,109 @@
|
||||||
<template>
|
<template>
|
||||||
<Modal @close="close" v-if="show">
|
<Modal @close="close" v-if="show">
|
||||||
<template #title>
|
<template #title>
|
||||||
{{ t('memories', 'Rename person') }}
|
{{ t("memories", "Rename person") }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<NcTextField :value.sync="name"
|
<NcTextField
|
||||||
class="field"
|
:value.sync="name"
|
||||||
:label="t('memories', 'Name')" :label-visible="false"
|
class="field"
|
||||||
:placeholder="t('memories', 'Name')"
|
:label="t('memories', 'Name')"
|
||||||
@keypress.enter="save()" />
|
:label-visible="false"
|
||||||
</div>
|
:placeholder="t('memories', 'Name')"
|
||||||
|
@keypress.enter="save()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template #buttons>
|
<template #buttons>
|
||||||
<NcButton @click="save" class="button" type="primary">
|
<NcButton @click="save" class="button" type="primary">
|
||||||
{{ t('memories', 'Update') }}
|
{{ t("memories", "Update") }}
|
||||||
</NcButton>
|
</NcButton>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Emit, Mixins, Watch } from 'vue-property-decorator';
|
import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
|
||||||
import { NcButton, NcTextField } from '@nextcloud/vue';
|
import { NcButton, NcTextField } from "@nextcloud/vue";
|
||||||
import { showError } from '@nextcloud/dialogs';
|
import { showError } from "@nextcloud/dialogs";
|
||||||
import { getCurrentUser } from '@nextcloud/auth';
|
import { getCurrentUser } from "@nextcloud/auth";
|
||||||
import Modal from './Modal.vue';
|
import Modal from "./Modal.vue";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
import client from '../../services/DavClient';
|
import client from "../../services/DavClient";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
NcButton,
|
NcButton,
|
||||||
NcTextField,
|
NcTextField,
|
||||||
Modal,
|
Modal,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
export default class FaceEditModal extends Mixins(GlobalMixin) {
|
export default class FaceEditModal extends Mixins(GlobalMixin) {
|
||||||
private user: string = "";
|
private user: string = "";
|
||||||
private name: string = "";
|
private name: string = "";
|
||||||
private oldName: string = "";
|
private oldName: string = "";
|
||||||
private show = false;
|
private show = false;
|
||||||
|
|
||||||
@Emit('close')
|
@Emit("close")
|
||||||
public close() {
|
public close() {
|
||||||
this.show = false;
|
this.show = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public open() {
|
public open() {
|
||||||
const user = this.$route.params.user || '';
|
const user = this.$route.params.user || "";
|
||||||
if (this.$route.params.user !== getCurrentUser().uid) {
|
if (this.$route.params.user !== getCurrentUser().uid) {
|
||||||
showError(this.t('memories', 'Only user "{user}" can update this person', { user }));
|
showError(
|
||||||
return;
|
this.t("memories", 'Only user "{user}" can update this person', {
|
||||||
}
|
user,
|
||||||
this.show = true;
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
this.show = true;
|
||||||
|
}
|
||||||
|
|
||||||
@Watch('$route')
|
@Watch("$route")
|
||||||
async routeChange(from: any, to: any) {
|
async routeChange(from: any, to: any) {
|
||||||
this.refreshParams();
|
this.refreshParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refreshParams();
|
this.refreshParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
public refreshParams() {
|
public refreshParams() {
|
||||||
this.user = this.$route.params.user || '';
|
this.user = this.$route.params.user || "";
|
||||||
this.name = this.$route.params.name || '';
|
this.name = this.$route.params.name || "";
|
||||||
this.oldName = this.$route.params.name || '';
|
this.oldName = this.$route.params.name || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
public async save() {
|
public async save() {
|
||||||
try {
|
try {
|
||||||
await client.moveFile(
|
await client.moveFile(
|
||||||
`/recognize/${this.user}/faces/${this.oldName}`,
|
`/recognize/${this.user}/faces/${this.oldName}`,
|
||||||
`/recognize/${this.user}/faces/${this.name}`,
|
`/recognize/${this.user}/faces/${this.name}`
|
||||||
);
|
);
|
||||||
this.$router.push({ name: 'people', params: { user: this.user, name: this.name } });
|
this.$router.push({
|
||||||
this.close();
|
name: "people",
|
||||||
} catch (error) {
|
params: { user: this.user, name: this.name },
|
||||||
console.log(error);
|
});
|
||||||
showError(this.t('photos', 'Failed to rename {oldName} to {name}.', {
|
this.close();
|
||||||
oldName: this.oldName,
|
} catch (error) {
|
||||||
name: this.name,
|
console.log(error);
|
||||||
}));
|
showError(
|
||||||
}
|
this.t("photos", "Failed to rename {oldName} to {name}.", {
|
||||||
|
oldName: this.oldName,
|
||||||
|
name: this.name,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.fields {
|
.fields {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,83 +1,83 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="outer" v-if="detail">
|
<div class="outer" v-if="detail">
|
||||||
<div class="photo" v-for="photo of detail" :key="photo.fileid" >
|
<div class="photo" v-for="photo of detail" :key="photo.fileid">
|
||||||
<Tag :data="photo" :noNavigate="true" @open="clickFace" />
|
<Tag :data="photo" :noNavigate="true" @open="clickFace" />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
{{ t('memories', 'Loading …') }}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
{{ t("memories", "Loading …") }}
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Emit, Mixins, Watch } from 'vue-property-decorator';
|
import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
|
||||||
import { IPhoto, ITag } from '../../types';
|
import { IPhoto, ITag } from "../../types";
|
||||||
import Tag from '../frame/Tag.vue';
|
import Tag from "../frame/Tag.vue";
|
||||||
|
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
import * as dav from '../../services/DavRequests';
|
import * as dav from "../../services/DavRequests";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
Tag,
|
Tag,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
export default class FaceMergeModal extends Mixins(GlobalMixin) {
|
export default class FaceMergeModal extends Mixins(GlobalMixin) {
|
||||||
private user: string = "";
|
private user: string = "";
|
||||||
private name: string = "";
|
private name: string = "";
|
||||||
private detail: IPhoto[] | null = null;
|
private detail: IPhoto[] | null = null;
|
||||||
|
|
||||||
@Emit('close')
|
@Emit("close")
|
||||||
public close() {}
|
public close() {}
|
||||||
|
|
||||||
@Watch('$route')
|
@Watch("$route")
|
||||||
async routeChange(from: any, to: any) {
|
async routeChange(from: any, to: any) {
|
||||||
this.refreshParams();
|
this.refreshParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refreshParams();
|
this.refreshParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async refreshParams() {
|
public async refreshParams() {
|
||||||
this.user = this.$route.params.user || '';
|
this.user = this.$route.params.user || "";
|
||||||
this.name = this.$route.params.name || '';
|
this.name = this.$route.params.name || "";
|
||||||
this.detail = null;
|
this.detail = null;
|
||||||
|
|
||||||
const data = await dav.getPeopleData();
|
const data = await dav.getPeopleData();
|
||||||
let detail = data[0].detail;
|
let detail = data[0].detail;
|
||||||
detail.forEach((photo: IPhoto) => {
|
detail.forEach((photo: IPhoto) => {
|
||||||
photo.flag = this.c.FLAG_IS_FACE | this.c.FLAG_IS_TAG;
|
photo.flag = this.c.FLAG_IS_FACE | this.c.FLAG_IS_TAG;
|
||||||
});
|
});
|
||||||
detail = detail.filter((photo: ITag) => {
|
detail = detail.filter((photo: ITag) => {
|
||||||
const pname = photo.name || photo.fileid.toString();
|
const pname = photo.name || photo.fileid.toString();
|
||||||
return photo.user_id !== this.user || pname !== this.name;
|
return photo.user_id !== this.user || pname !== this.name;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.detail = detail;
|
this.detail = detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Emit('select')
|
@Emit("select")
|
||||||
public async clickFace(face: ITag) {}
|
public async clickFace(face: ITag) {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.outer {
|
.outer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: calc(90vh - 80px - 4em);
|
max-height: calc(90vh - 80px - 4em);
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.photo {
|
.photo {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
|
|
||||||
max-width: 120px;
|
max-width: 120px;
|
||||||
width: calc(33.33%);
|
width: calc(33.33%);
|
||||||
aspect-ratio: 1/1;
|
aspect-ratio: 1/1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,136 +1,156 @@
|
||||||
<template>
|
<template>
|
||||||
<Modal @close="close" size="large" v-if="show">
|
<Modal @close="close" size="large" v-if="show">
|
||||||
<template #title>
|
<template #title>
|
||||||
{{ t('memories', 'Merge {name} with person', { name: $route.params.name }) }}
|
{{
|
||||||
</template>
|
t("memories", "Merge {name} with person", { name: $route.params.name })
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="outer">
|
<div class="outer">
|
||||||
<FaceList @select="clickFace" />
|
<FaceList @select="clickFace" />
|
||||||
|
|
||||||
<div v-if="procesingTotal > 0" class="info-pad">
|
<div v-if="procesingTotal > 0" class="info-pad">
|
||||||
{{ t('memories', 'Processing … {n}/{m}', {
|
{{
|
||||||
n: processing,
|
t("memories", "Processing … {n}/{m}", {
|
||||||
m: procesingTotal,
|
n: processing,
|
||||||
}) }}
|
m: procesingTotal,
|
||||||
</div>
|
})
|
||||||
</div>
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template #buttons>
|
<template #buttons>
|
||||||
<NcButton @click="close" class="button" type="error">
|
<NcButton @click="close" class="button" type="error">
|
||||||
{{ t('memories', 'Cancel') }}
|
{{ t("memories", "Cancel") }}
|
||||||
</NcButton>
|
</NcButton>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Emit, Mixins } from 'vue-property-decorator';
|
import { Component, Emit, Mixins } from "vue-property-decorator";
|
||||||
import { NcButton, NcTextField } from '@nextcloud/vue';
|
import { NcButton, NcTextField } from "@nextcloud/vue";
|
||||||
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, ITag } from "../../types";
|
||||||
import Tag from '../frame/Tag.vue';
|
import Tag from "../frame/Tag.vue";
|
||||||
import FaceList from './FaceList.vue';
|
import FaceList from "./FaceList.vue";
|
||||||
|
|
||||||
import Modal from './Modal.vue';
|
import Modal from "./Modal.vue";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
import client from '../../services/DavClient';
|
import client from "../../services/DavClient";
|
||||||
import * as dav from '../../services/DavRequests';
|
import * as dav from "../../services/DavRequests";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
NcButton,
|
NcButton,
|
||||||
NcTextField,
|
NcTextField,
|
||||||
Modal,
|
Modal,
|
||||||
Tag,
|
Tag,
|
||||||
FaceList,
|
FaceList,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
export default class FaceMergeModal extends Mixins(GlobalMixin) {
|
export default class FaceMergeModal extends Mixins(GlobalMixin) {
|
||||||
private processing = 0;
|
private processing = 0;
|
||||||
private procesingTotal = 0;
|
private procesingTotal = 0;
|
||||||
private show = false;
|
private show = false;
|
||||||
|
|
||||||
@Emit('close')
|
@Emit("close")
|
||||||
public close() {
|
public close() {
|
||||||
this.show = false;
|
this.show = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public open() {
|
||||||
|
const user = this.$route.params.user || "";
|
||||||
|
if (this.$route.params.user !== getCurrentUser().uid) {
|
||||||
|
showError(
|
||||||
|
this.t("memories", 'Only user "{user}" can update this person', {
|
||||||
|
user,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.show = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async clickFace(face: ITag) {
|
||||||
|
const user = this.$route.params.user || "";
|
||||||
|
const name = this.$route.params.name || "";
|
||||||
|
|
||||||
|
const newName = face.name || face.fileid.toString();
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
this.t(
|
||||||
|
"memories",
|
||||||
|
"Are you sure you want to merge {name} with {newName}?",
|
||||||
|
{ name, newName }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
public open() {
|
try {
|
||||||
const user = this.$route.params.user || '';
|
// Get all files for current face
|
||||||
if (this.$route.params.user !== getCurrentUser().uid) {
|
let res = (await client.getDirectoryContents(
|
||||||
showError(this.t('memories', 'Only user "{user}" can update this person', { user }));
|
`/recognize/${user}/faces/${name}`,
|
||||||
return;
|
{ details: true }
|
||||||
|
)) as any;
|
||||||
|
let data: IFileInfo[] = res.data;
|
||||||
|
this.procesingTotal = data.length;
|
||||||
|
|
||||||
|
// Don't try too much
|
||||||
|
let failures = 0;
|
||||||
|
|
||||||
|
// Create move calls
|
||||||
|
const calls = data.map((p) => async () => {
|
||||||
|
// Short circuit if we have too many failures
|
||||||
|
if (failures === 10) {
|
||||||
|
showError(this.t("memories", "Too many failures, aborting"));
|
||||||
|
failures++;
|
||||||
}
|
}
|
||||||
this.show = true;
|
if (failures >= 10) return;
|
||||||
}
|
|
||||||
|
|
||||||
public async clickFace(face: ITag) {
|
// Move to new face with webdav
|
||||||
const user = this.$route.params.user || '';
|
try {
|
||||||
const name = this.$route.params.name || '';
|
await client.moveFile(
|
||||||
|
`/recognize/${user}/faces/${name}/${p.basename}`,
|
||||||
const newName = face.name || face.fileid.toString();
|
`/recognize/${face.user_id}/faces/${newName}/${p.basename}`
|
||||||
if (!confirm(this.t('memories', 'Are you sure you want to merge {name} with {newName}?', { name, newName}))) {
|
);
|
||||||
return;
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showError(this.t("memories", "Error while moving {basename}", p));
|
||||||
|
failures++;
|
||||||
|
} finally {
|
||||||
|
this.processing++;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
for await (const _ of dav.runInParallel(calls, 10)) {
|
||||||
|
// nothing to do
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
// Go to new face
|
||||||
// Get all files for current face
|
if (failures === 0) {
|
||||||
let res = await client.getDirectoryContents(
|
this.$router.push({
|
||||||
`/recognize/${user}/faces/${name}`, { details: true }
|
name: "people",
|
||||||
) as any;
|
params: { user: face.user_id, name: newName },
|
||||||
let data: IFileInfo[] = res.data;
|
});
|
||||||
this.procesingTotal = data.length;
|
this.close();
|
||||||
|
}
|
||||||
// Don't try too much
|
} catch (error) {
|
||||||
let failures = 0;
|
console.error(error);
|
||||||
|
showError(this.t("photos", "Failed to move {name}.", { name }));
|
||||||
// Create move calls
|
|
||||||
const calls = data.map((p) => async () => {
|
|
||||||
// Short circuit if we have too many failures
|
|
||||||
if (failures === 10) {
|
|
||||||
showError(this.t('memories', 'Too many failures, aborting'));
|
|
||||||
failures++;
|
|
||||||
}
|
|
||||||
if (failures >= 10) return;
|
|
||||||
|
|
||||||
// Move to new face with webdav
|
|
||||||
try {
|
|
||||||
await client.moveFile(
|
|
||||||
`/recognize/${user}/faces/${name}/${p.basename}`,
|
|
||||||
`/recognize/${face.user_id}/faces/${newName}/${p.basename}`
|
|
||||||
)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
showError(this.t('memories', 'Error while moving {basename}', p));
|
|
||||||
failures++;
|
|
||||||
} finally {
|
|
||||||
this.processing++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
for await (const _ of dav.runInParallel(calls, 10)) {
|
|
||||||
// nothing to do
|
|
||||||
}
|
|
||||||
|
|
||||||
// Go to new face
|
|
||||||
if (failures === 0) {
|
|
||||||
this.$router.push({ name: 'people', params: { user: face.user_id, name: newName } });
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
showError(this.t('photos', 'Failed to move {name}.', { name }));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.outer {
|
.outer {
|
||||||
margin-top: 15px;
|
margin-top: 15px;
|
||||||
}
|
}
|
||||||
.info-pad {
|
.info-pad {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,129 +1,141 @@
|
||||||
<template>
|
<template>
|
||||||
<Modal @close="close" size="large" v-if="show">
|
<Modal @close="close" size="large" v-if="show">
|
||||||
<template #title>
|
<template #title>
|
||||||
{{ t('memories', 'Move selected photos to person') }}
|
{{ t("memories", "Move selected photos to person") }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="outer">
|
<div class="outer">
|
||||||
<FaceList @select="clickFace" />
|
<FaceList @select="clickFace" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #buttons>
|
<template #buttons>
|
||||||
<NcButton @click="close" class="button" type="error">
|
<NcButton @click="close" class="button" type="error">
|
||||||
{{ t('memories', 'Cancel') }}
|
{{ t("memories", "Cancel") }}
|
||||||
</NcButton>
|
</NcButton>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator';
|
import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
|
|
||||||
import { NcButton, NcTextField } from '@nextcloud/vue';
|
import { NcButton, NcTextField } from "@nextcloud/vue";
|
||||||
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, ITag } from "../../types";
|
||||||
import Tag from '../frame/Tag.vue';
|
import Tag from "../frame/Tag.vue";
|
||||||
import FaceList from './FaceList.vue';
|
import FaceList from "./FaceList.vue";
|
||||||
|
|
||||||
import Modal from './Modal.vue';
|
import Modal from "./Modal.vue";
|
||||||
import client from '../../services/DavClient';
|
import client from "../../services/DavClient";
|
||||||
import * as dav from '../../services/DavRequests';
|
import * as dav from "../../services/DavRequests";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
NcButton,
|
NcButton,
|
||||||
NcTextField,
|
NcTextField,
|
||||||
Modal,
|
Modal,
|
||||||
Tag,
|
Tag,
|
||||||
FaceList,
|
FaceList,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
export default class FaceMoveModal extends Mixins(GlobalMixin) {
|
export default class FaceMoveModal extends Mixins(GlobalMixin) {
|
||||||
private show = false;
|
private show = false;
|
||||||
private photos: IPhoto[] = [];
|
private photos: IPhoto[] = [];
|
||||||
|
|
||||||
@Prop()
|
@Prop()
|
||||||
private updateLoading: (delta: number) => void;
|
private updateLoading: (delta: number) => void;
|
||||||
|
|
||||||
public open(photos: IPhoto[]) {
|
public open(photos: IPhoto[]) {
|
||||||
if (this.photos.length) {
|
if (this.photos.length) {
|
||||||
// is processing
|
// is processing
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
// check ownership
|
|
||||||
const user = this.$route.params.user || '';
|
|
||||||
if (this.$route.params.user !== getCurrentUser().uid) {
|
|
||||||
showError(this.t('memories', 'Only user "{user}" can update this person', { user }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.show = true;
|
|
||||||
this.photos = photos;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Emit('close')
|
// check ownership
|
||||||
public close() {
|
const user = this.$route.params.user || "";
|
||||||
this.photos = [];
|
if (this.$route.params.user !== getCurrentUser().uid) {
|
||||||
this.show = false;
|
showError(
|
||||||
|
this.t("memories", 'Only user "{user}" can update this person', {
|
||||||
|
user,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Emit('moved')
|
this.show = true;
|
||||||
public moved(list: IPhoto[]) {}
|
this.photos = photos;
|
||||||
|
}
|
||||||
|
|
||||||
public async clickFace(face: ITag) {
|
@Emit("close")
|
||||||
const user = this.$route.params.user || '';
|
public close() {
|
||||||
const name = this.$route.params.name || '';
|
this.photos = [];
|
||||||
|
this.show = false;
|
||||||
|
}
|
||||||
|
|
||||||
const newName = face.name || face.fileid.toString();
|
@Emit("moved")
|
||||||
|
public moved(list: IPhoto[]) {}
|
||||||
|
|
||||||
if (!confirm(this.t('memories', 'Are you sure you want to move the selected photos from {name} to {newName}?', { name, newName}))) {
|
public async clickFace(face: ITag) {
|
||||||
return;
|
const user = this.$route.params.user || "";
|
||||||
}
|
const name = this.$route.params.name || "";
|
||||||
|
|
||||||
try {
|
const newName = face.name || face.fileid.toString();
|
||||||
this.show = false;
|
|
||||||
this.updateLoading(1);
|
|
||||||
|
|
||||||
// Create map to return IPhoto later
|
if (
|
||||||
let photoMap = new Map<number, IPhoto>();
|
!confirm(
|
||||||
for (const photo of this.photos) {
|
this.t(
|
||||||
photoMap.set(photo.fileid, photo);
|
"memories",
|
||||||
}
|
"Are you sure you want to move the selected photos from {name} to {newName}?",
|
||||||
|
{ name, newName }
|
||||||
let data = await dav.getFiles(this.photos.map(p => p.fileid));
|
)
|
||||||
|
)
|
||||||
// Create move calls
|
) {
|
||||||
const calls = data.map((p) => async () => {
|
return;
|
||||||
try {
|
|
||||||
await client.moveFile(
|
|
||||||
`/recognize/${user}/faces/${name}/${p.fileid}-${p.basename}`,
|
|
||||||
`/recognize/${face.user_id}/faces/${newName}/${p.fileid}-${p.basename}`
|
|
||||||
)
|
|
||||||
return photoMap.get(p.fileid);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
showError(this.t('memories', 'Error while moving {basename}', p));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
for await (const resp of dav.runInParallel(calls, 10)) {
|
|
||||||
this.moved(resp);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
showError(this.t('photos', 'Failed to move {name}.', { name }));
|
|
||||||
} finally {
|
|
||||||
this.updateLoading(-1);
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.show = false;
|
||||||
|
this.updateLoading(1);
|
||||||
|
|
||||||
|
// Create map to return IPhoto later
|
||||||
|
let photoMap = new Map<number, IPhoto>();
|
||||||
|
for (const photo of this.photos) {
|
||||||
|
photoMap.set(photo.fileid, photo);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = await dav.getFiles(this.photos.map((p) => p.fileid));
|
||||||
|
|
||||||
|
// Create move calls
|
||||||
|
const calls = data.map((p) => async () => {
|
||||||
|
try {
|
||||||
|
await client.moveFile(
|
||||||
|
`/recognize/${user}/faces/${name}/${p.fileid}-${p.basename}`,
|
||||||
|
`/recognize/${face.user_id}/faces/${newName}/${p.fileid}-${p.basename}`
|
||||||
|
);
|
||||||
|
return photoMap.get(p.fileid);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showError(this.t("memories", "Error while moving {basename}", p));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for await (const resp of dav.runInParallel(calls, 10)) {
|
||||||
|
this.moved(resp);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
showError(this.t("photos", "Failed to move {name}.", { name }));
|
||||||
|
} finally {
|
||||||
|
this.updateLoading(-1);
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.outer {
|
.outer {
|
||||||
margin-top: 15px;
|
margin-top: 15px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,56 +1,53 @@
|
||||||
<template>
|
<template>
|
||||||
<NcModal
|
<NcModal :size="size" @close="close" :outTransition="true">
|
||||||
:size="size"
|
<div class="container">
|
||||||
@close="close"
|
<div class="head">
|
||||||
:outTransition="true">
|
<span> <slot name="title"></slot> </span>
|
||||||
<div class="container">
|
</div>
|
||||||
<div class="head">
|
|
||||||
<span> <slot name="title"></slot> </span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
|
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<slot name="buttons"></slot>
|
<slot name="buttons"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NcModal>
|
</NcModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Emit, Prop, Vue } from 'vue-property-decorator';
|
import { Component, Emit, Prop, Vue } from "vue-property-decorator";
|
||||||
import { NcModal } from '@nextcloud/vue';
|
import { NcModal } from "@nextcloud/vue";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
NcModal,
|
NcModal,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
export default class Modal extends Vue {
|
export default class Modal extends Vue {
|
||||||
@Prop({default: 'small'}) private size?: string;
|
@Prop({ default: "small" }) private size?: string;
|
||||||
|
|
||||||
@Emit('close')
|
@Emit("close")
|
||||||
public close() {}
|
public close() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.container {
|
.container {
|
||||||
margin: 20px;
|
margin: 20px;
|
||||||
|
|
||||||
.head {
|
.head {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 1.15em;
|
font-size: 1.15em;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep .buttons {
|
:deep .buttons {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
|
||||||
> button {
|
> button {
|
||||||
display: inline-block !important;
|
display: inline-block !important;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,123 +1,139 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="album-top-matter">
|
<div class="album-top-matter">
|
||||||
<NcActions v-if="!isAlbumList">
|
<NcActions v-if="!isAlbumList">
|
||||||
<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>
|
||||||
|
|
||||||
<div class="name">{{ name }}</div>
|
<div class="name">{{ name }}</div>
|
||||||
|
|
||||||
<div class="right-actions">
|
<div class="right-actions">
|
||||||
<NcActions :inline="1">
|
<NcActions :inline="1">
|
||||||
<NcActionButton :aria-label="t('memories', 'Create new album')" @click="$refs.createModal.open(false)" close-after-click
|
<NcActionButton
|
||||||
v-if="isAlbumList">
|
:aria-label="t('memories', 'Create new album')"
|
||||||
{{ t('memories', 'Create new album') }}
|
@click="$refs.createModal.open(false)"
|
||||||
<template #icon> <PlusIcon :size="20" /> </template>
|
close-after-click
|
||||||
</NcActionButton>
|
v-if="isAlbumList"
|
||||||
<NcActionButton :aria-label="t('memories', 'Share album')" @click="$refs.shareModal.open(false)" close-after-click
|
>
|
||||||
v-if="!isAlbumList">
|
{{ t("memories", "Create new album") }}
|
||||||
{{ t('memories', 'Share album') }}
|
<template #icon> <PlusIcon :size="20" /> </template>
|
||||||
<template #icon> <ShareIcon :size="20" /> </template>
|
</NcActionButton>
|
||||||
</NcActionButton>
|
<NcActionButton
|
||||||
<NcActionButton :aria-label="t('memories', 'Edit album details')" @click="$refs.createModal.open(true)" close-after-click
|
:aria-label="t('memories', 'Share album')"
|
||||||
v-if="!isAlbumList">
|
@click="$refs.shareModal.open(false)"
|
||||||
{{ t('memories', 'Edit album details') }}
|
close-after-click
|
||||||
<template #icon> <EditIcon :size="20" /> </template>
|
v-if="!isAlbumList"
|
||||||
</NcActionButton>
|
>
|
||||||
<NcActionButton :aria-label="t('memories', 'Delete album')" @click="$refs.deleteModal.open()" close-after-click
|
{{ t("memories", "Share album") }}
|
||||||
v-if="!isAlbumList">
|
<template #icon> <ShareIcon :size="20" /> </template>
|
||||||
{{ t('memories', 'Delete album') }}
|
</NcActionButton>
|
||||||
<template #icon> <DeleteIcon :size="20" /> </template>
|
<NcActionButton
|
||||||
</NcActionButton>
|
:aria-label="t('memories', 'Edit album details')"
|
||||||
</NcActions>
|
@click="$refs.createModal.open(true)"
|
||||||
</div>
|
close-after-click
|
||||||
|
v-if="!isAlbumList"
|
||||||
<AlbumCreateModal ref="createModal" />
|
>
|
||||||
<AlbumDeleteModal ref="deleteModal" />
|
{{ t("memories", "Edit album details") }}
|
||||||
<AlbumShareModal ref="shareModal" />
|
<template #icon> <EditIcon :size="20" /> </template>
|
||||||
|
</NcActionButton>
|
||||||
|
<NcActionButton
|
||||||
|
:aria-label="t('memories', 'Delete album')"
|
||||||
|
@click="$refs.deleteModal.open()"
|
||||||
|
close-after-click
|
||||||
|
v-if="!isAlbumList"
|
||||||
|
>
|
||||||
|
{{ t("memories", "Delete album") }}
|
||||||
|
<template #icon> <DeleteIcon :size="20" /> </template>
|
||||||
|
</NcActionButton>
|
||||||
|
</NcActions>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AlbumCreateModal ref="createModal" />
|
||||||
|
<AlbumDeleteModal ref="deleteModal" />
|
||||||
|
<AlbumShareModal ref="shareModal" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Mixins, Watch } from 'vue-property-decorator';
|
import { Component, Mixins, Watch } from "vue-property-decorator";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
import UserConfig from "../../mixins/UserConfig";
|
import UserConfig from "../../mixins/UserConfig";
|
||||||
|
|
||||||
import AlbumCreateModal from '../modal/AlbumCreateModal.vue';
|
import AlbumCreateModal from "../modal/AlbumCreateModal.vue";
|
||||||
import AlbumDeleteModal from '../modal/AlbumDeleteModal.vue';
|
import AlbumDeleteModal from "../modal/AlbumDeleteModal.vue";
|
||||||
import AlbumShareModal from '../modal/AlbumShareModal.vue';
|
import AlbumShareModal from "../modal/AlbumShareModal.vue";
|
||||||
|
|
||||||
import { NcActions, NcActionButton, NcActionCheckbox } from '@nextcloud/vue';
|
import { NcActions, NcActionButton, NcActionCheckbox } from "@nextcloud/vue";
|
||||||
import BackIcon from 'vue-material-design-icons/ArrowLeft.vue';
|
import BackIcon from "vue-material-design-icons/ArrowLeft.vue";
|
||||||
import EditIcon from 'vue-material-design-icons/Pencil.vue';
|
import EditIcon from "vue-material-design-icons/Pencil.vue";
|
||||||
import DeleteIcon from 'vue-material-design-icons/Close.vue';
|
import DeleteIcon from "vue-material-design-icons/Close.vue";
|
||||||
import PlusIcon from 'vue-material-design-icons/Plus.vue';
|
import PlusIcon from "vue-material-design-icons/Plus.vue";
|
||||||
import ShareIcon from 'vue-material-design-icons/ShareVariant.vue';
|
import ShareIcon from "vue-material-design-icons/ShareVariant.vue";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
NcActions,
|
NcActions,
|
||||||
NcActionButton,
|
NcActionButton,
|
||||||
NcActionCheckbox,
|
NcActionCheckbox,
|
||||||
|
|
||||||
AlbumCreateModal,
|
AlbumCreateModal,
|
||||||
AlbumDeleteModal,
|
AlbumDeleteModal,
|
||||||
AlbumShareModal,
|
AlbumShareModal,
|
||||||
|
|
||||||
BackIcon,
|
BackIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
DeleteIcon,
|
DeleteIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
ShareIcon,
|
ShareIcon,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class AlbumTopMatter extends Mixins(GlobalMixin, UserConfig) {
|
export default class AlbumTopMatter extends Mixins(GlobalMixin, UserConfig) {
|
||||||
private name: string = '';
|
private name: string = "";
|
||||||
|
|
||||||
get isAlbumList() {
|
get isAlbumList() {
|
||||||
return !Boolean(this.$route.params.name);
|
return !Boolean(this.$route.params.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Watch('$route')
|
@Watch("$route")
|
||||||
async routeChange(from: any, to: any) {
|
async routeChange(from: any, to: any) {
|
||||||
this.createMatter();
|
this.createMatter();
|
||||||
}
|
}
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.createMatter();
|
this.createMatter();
|
||||||
}
|
}
|
||||||
|
|
||||||
createMatter() {
|
createMatter() {
|
||||||
this.name = this.$route.params.name || this.t('memories', 'Albums');
|
this.name = this.$route.params.name || this.t("memories", "Albums");
|
||||||
}
|
}
|
||||||
|
|
||||||
back() {
|
back() {
|
||||||
this.$router.push({ name: 'albums' });
|
this.$router.push({ name: "albums" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.album-top-matter {
|
.album-top-matter {
|
||||||
display: flex;
|
display: flex;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 40px;
|
line-height: 40px;
|
||||||
padding-left: 3px;
|
padding-left: 3px;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-actions {
|
.right-actions {
|
||||||
margin-right: 40px;
|
margin-right: 40px;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,116 +1,135 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="name" class="face-top-matter">
|
<div v-if="name" class="face-top-matter">
|
||||||
<NcActions>
|
<NcActions>
|
||||||
<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>
|
||||||
|
|
||||||
<div class="name">{{ name }}</div>
|
<div class="name">{{ name }}</div>
|
||||||
|
|
||||||
<div class="right-actions">
|
<div class="right-actions">
|
||||||
<NcActions :inline="1">
|
<NcActions :inline="1">
|
||||||
<NcActionButton :aria-label="t('memories', 'Rename person')" @click="$refs.editModal.open()" close-after-click>
|
<NcActionButton
|
||||||
{{ t('memories', 'Rename person') }}
|
:aria-label="t('memories', 'Rename person')"
|
||||||
<template #icon> <EditIcon :size="20" /> </template>
|
@click="$refs.editModal.open()"
|
||||||
</NcActionButton>
|
close-after-click
|
||||||
<NcActionButton :aria-label="t('memories', 'Merge with different person')" @click="$refs.mergeModal.open()" close-after-click>
|
>
|
||||||
{{ t('memories', 'Merge with different person') }}
|
{{ t("memories", "Rename person") }}
|
||||||
<template #icon> <MergeIcon :size="20" /> </template>
|
<template #icon> <EditIcon :size="20" /> </template>
|
||||||
</NcActionButton>
|
</NcActionButton>
|
||||||
<NcActionCheckbox :aria-label="t('memories', 'Mark person in preview')" :checked.sync="config_showFaceRect" @change="changeShowFaceRect">
|
<NcActionButton
|
||||||
{{ t('memories', 'Mark person in preview') }}
|
:aria-label="t('memories', 'Merge with different person')"
|
||||||
</NcActionCheckbox>
|
@click="$refs.mergeModal.open()"
|
||||||
<NcActionButton :aria-label="t('memories', 'Remove person')" @click="$refs.deleteModal.open()" close-after-click>
|
close-after-click
|
||||||
{{ t('memories', 'Remove person') }}
|
>
|
||||||
<template #icon> <DeleteIcon :size="20" /> </template>
|
{{ t("memories", "Merge with different person") }}
|
||||||
</NcActionButton>
|
<template #icon> <MergeIcon :size="20" /> </template>
|
||||||
</NcActions>
|
</NcActionButton>
|
||||||
</div>
|
<NcActionCheckbox
|
||||||
|
:aria-label="t('memories', 'Mark person in preview')"
|
||||||
<FaceEditModal ref="editModal" />
|
:checked.sync="config_showFaceRect"
|
||||||
<FaceDeleteModal ref="deleteModal" />
|
@change="changeShowFaceRect"
|
||||||
<FaceMergeModal ref="mergeModal" />
|
>
|
||||||
|
{{ t("memories", "Mark person in preview") }}
|
||||||
|
</NcActionCheckbox>
|
||||||
|
<NcActionButton
|
||||||
|
:aria-label="t('memories', 'Remove person')"
|
||||||
|
@click="$refs.deleteModal.open()"
|
||||||
|
close-after-click
|
||||||
|
>
|
||||||
|
{{ t("memories", "Remove person") }}
|
||||||
|
<template #icon> <DeleteIcon :size="20" /> </template>
|
||||||
|
</NcActionButton>
|
||||||
|
</NcActions>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FaceEditModal ref="editModal" />
|
||||||
|
<FaceDeleteModal ref="deleteModal" />
|
||||||
|
<FaceMergeModal ref="mergeModal" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Mixins, Watch } from 'vue-property-decorator';
|
import { Component, Mixins, Watch } from "vue-property-decorator";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
import UserConfig from "../../mixins/UserConfig";
|
import UserConfig from "../../mixins/UserConfig";
|
||||||
|
|
||||||
import { NcActions, NcActionButton, NcActionCheckbox } from '@nextcloud/vue';
|
import { NcActions, NcActionButton, NcActionCheckbox } from "@nextcloud/vue";
|
||||||
import FaceEditModal from '../modal/FaceEditModal.vue';
|
import FaceEditModal from "../modal/FaceEditModal.vue";
|
||||||
import FaceDeleteModal from '../modal/FaceDeleteModal.vue';
|
import FaceDeleteModal from "../modal/FaceDeleteModal.vue";
|
||||||
import FaceMergeModal from '../modal/FaceMergeModal.vue';
|
import FaceMergeModal from "../modal/FaceMergeModal.vue";
|
||||||
import BackIcon from 'vue-material-design-icons/ArrowLeft.vue';
|
import BackIcon from "vue-material-design-icons/ArrowLeft.vue";
|
||||||
import EditIcon from 'vue-material-design-icons/Pencil.vue';
|
import EditIcon from "vue-material-design-icons/Pencil.vue";
|
||||||
import DeleteIcon from 'vue-material-design-icons/Close.vue';
|
import DeleteIcon from "vue-material-design-icons/Close.vue";
|
||||||
import MergeIcon from 'vue-material-design-icons/Merge.vue';
|
import MergeIcon from "vue-material-design-icons/Merge.vue";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
NcActions,
|
NcActions,
|
||||||
NcActionButton,
|
NcActionButton,
|
||||||
NcActionCheckbox,
|
NcActionCheckbox,
|
||||||
FaceEditModal,
|
FaceEditModal,
|
||||||
FaceDeleteModal,
|
FaceDeleteModal,
|
||||||
FaceMergeModal,
|
FaceMergeModal,
|
||||||
BackIcon,
|
BackIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
DeleteIcon,
|
DeleteIcon,
|
||||||
MergeIcon,
|
MergeIcon,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class FaceTopMatter extends Mixins(GlobalMixin, UserConfig) {
|
export default class FaceTopMatter extends Mixins(GlobalMixin, UserConfig) {
|
||||||
private name: string = '';
|
private name: string = "";
|
||||||
|
|
||||||
@Watch('$route')
|
@Watch("$route")
|
||||||
async routeChange(from: any, to: any) {
|
async routeChange(from: any, to: any) {
|
||||||
this.createMatter();
|
this.createMatter();
|
||||||
}
|
}
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.createMatter();
|
this.createMatter();
|
||||||
}
|
}
|
||||||
|
|
||||||
createMatter() {
|
createMatter() {
|
||||||
this.name = this.$route.params.name || '';
|
this.name = this.$route.params.name || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
back() {
|
back() {
|
||||||
this.$router.push({ name: 'people' });
|
this.$router.push({ name: "people" });
|
||||||
}
|
}
|
||||||
|
|
||||||
changeShowFaceRect() {
|
changeShowFaceRect() {
|
||||||
localStorage.setItem('memories_showFaceRect', this.config_showFaceRect ? '1' : '0');
|
localStorage.setItem(
|
||||||
setTimeout(() => {
|
"memories_showFaceRect",
|
||||||
this.$router.go(0); // refresh page
|
this.config_showFaceRect ? "1" : "0"
|
||||||
}, 500);
|
);
|
||||||
}
|
setTimeout(() => {
|
||||||
|
this.$router.go(0); // refresh page
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.face-top-matter {
|
.face-top-matter {
|
||||||
display: flex;
|
display: flex;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 40px;
|
line-height: 40px;
|
||||||
padding-left: 3px;
|
padding-left: 3px;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-actions {
|
.right-actions {
|
||||||
margin-right: 40px;
|
margin-right: 40px;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,60 +1,66 @@
|
||||||
<template>
|
<template>
|
||||||
<NcBreadcrumbs v-if="topMatter">
|
<NcBreadcrumbs v-if="topMatter">
|
||||||
<NcBreadcrumb title="Home" :to="{ name: 'folders' }">
|
<NcBreadcrumb title="Home" :to="{ name: 'folders' }">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<HomeIcon :size="20" />
|
<HomeIcon :size="20" />
|
||||||
</template>
|
</template>
|
||||||
</NcBreadcrumb>
|
</NcBreadcrumb>
|
||||||
<NcBreadcrumb v-for="folder in topMatter.list" :key="folder.path" :title="folder.text"
|
<NcBreadcrumb
|
||||||
:to="{ name: 'folders', params: { path: folder.path }}" />
|
v-for="folder in topMatter.list"
|
||||||
</NcBreadcrumbs>
|
:key="folder.path"
|
||||||
|
:title="folder.text"
|
||||||
|
:to="{ name: 'folders', params: { path: folder.path } }"
|
||||||
|
/>
|
||||||
|
</NcBreadcrumbs>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Mixins, Watch } from 'vue-property-decorator';
|
import { Component, Mixins, Watch } from "vue-property-decorator";
|
||||||
import { TopMatterFolder, TopMatterType } from "../../types";
|
import { TopMatterFolder, TopMatterType } from "../../types";
|
||||||
import { NcBreadcrumbs, NcBreadcrumb } from '@nextcloud/vue';
|
import { NcBreadcrumbs, NcBreadcrumb } from "@nextcloud/vue";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
import HomeIcon from 'vue-material-design-icons/Home.vue';
|
import HomeIcon from "vue-material-design-icons/Home.vue";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
NcBreadcrumbs,
|
NcBreadcrumbs,
|
||||||
NcBreadcrumb,
|
NcBreadcrumb,
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
export default class FolderTopMatter extends Mixins(GlobalMixin) {
|
export default class FolderTopMatter extends Mixins(GlobalMixin) {
|
||||||
private topMatter?: TopMatterFolder = null;
|
private topMatter?: TopMatterFolder = null;
|
||||||
|
|
||||||
@Watch('$route')
|
@Watch("$route")
|
||||||
async routeChange(from: any, to: any) {
|
async routeChange(from: any, to: any) {
|
||||||
this.createMatter();
|
this.createMatter();
|
||||||
}
|
}
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.createMatter();
|
this.createMatter();
|
||||||
}
|
}
|
||||||
|
|
||||||
createMatter() {
|
createMatter() {
|
||||||
if (this.$route.name === 'folders') {
|
if (this.$route.name === "folders") {
|
||||||
let path: any = this.$route.params.path || '';
|
let path: any = this.$route.params.path || "";
|
||||||
if (typeof path === 'string') {
|
if (typeof path === "string") {
|
||||||
path = path.split('/');
|
path = path.split("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.topMatter = {
|
this.topMatter = {
|
||||||
type: TopMatterType.FOLDER,
|
type: TopMatterType.FOLDER,
|
||||||
list: path.filter(x => x).map((x, idx, arr) => {
|
list: path
|
||||||
return {
|
.filter((x) => x)
|
||||||
text: x,
|
.map((x, idx, arr) => {
|
||||||
path: arr.slice(0, idx + 1).join('/'),
|
return {
|
||||||
}
|
text: x,
|
||||||
}),
|
path: arr.slice(0, idx + 1).join("/"),
|
||||||
};
|
};
|
||||||
} else {
|
}),
|
||||||
this.topMatter = null;
|
};
|
||||||
}
|
} else {
|
||||||
|
this.topMatter = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
|
@ -1,190 +1,210 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="outer" v-show="years.length > 0">
|
<div class="outer" v-show="years.length > 0">
|
||||||
<div class="inner" ref="inner">
|
<div class="inner" ref="inner">
|
||||||
<div v-for="year of years" class="group" :key="year.year" @click="click(year)">
|
<div
|
||||||
<img class="fill-block"
|
v-for="year of years"
|
||||||
:src="year.url" />
|
class="group"
|
||||||
|
:key="year.year"
|
||||||
|
@click="click(year)"
|
||||||
|
>
|
||||||
|
<img class="fill-block" :src="year.url" />
|
||||||
|
|
||||||
<div class="overlay">
|
<div class="overlay">
|
||||||
{{ year.text }}
|
{{ year.text }}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="left-btn dir-btn" v-if="hasLeft">
|
|
||||||
<NcActions>
|
|
||||||
<NcActionButton
|
|
||||||
:aria-label="t('memories', 'Move left')"
|
|
||||||
@click="moveLeft">
|
|
||||||
{{ t('memories', 'Move left') }}
|
|
||||||
<template #icon> <LeftMoveIcon :size="28" /> </template>
|
|
||||||
</NcActionButton>
|
|
||||||
</NcActions>
|
|
||||||
</div>
|
|
||||||
<div class="right-btn dir-btn" v-if="hasRight">
|
|
||||||
<NcActions>
|
|
||||||
<NcActionButton
|
|
||||||
:aria-label="t('memories', 'Move right')"
|
|
||||||
@click="moveRight">
|
|
||||||
{{ t('memories', 'Move right') }}
|
|
||||||
<template #icon> <RightMoveIcon :size="28" /> </template>
|
|
||||||
</NcActionButton>
|
|
||||||
</NcActions>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="left-btn dir-btn" v-if="hasLeft">
|
||||||
|
<NcActions>
|
||||||
|
<NcActionButton
|
||||||
|
:aria-label="t('memories', 'Move left')"
|
||||||
|
@click="moveLeft"
|
||||||
|
>
|
||||||
|
{{ t("memories", "Move left") }}
|
||||||
|
<template #icon> <LeftMoveIcon :size="28" /> </template>
|
||||||
|
</NcActionButton>
|
||||||
|
</NcActions>
|
||||||
|
</div>
|
||||||
|
<div class="right-btn dir-btn" v-if="hasRight">
|
||||||
|
<NcActions>
|
||||||
|
<NcActionButton
|
||||||
|
:aria-label="t('memories', 'Move right')"
|
||||||
|
@click="moveRight"
|
||||||
|
>
|
||||||
|
{{ t("memories", "Move right") }}
|
||||||
|
<template #icon> <RightMoveIcon :size="28" /> </template>
|
||||||
|
</NcActionButton>
|
||||||
|
</NcActions>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator';
|
import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
import { NcActions, NcActionButton } from '@nextcloud/vue';
|
import { NcActions, NcActionButton } from "@nextcloud/vue";
|
||||||
|
|
||||||
import * as utils from "../../services/Utils";
|
import * as utils from "../../services/Utils";
|
||||||
import * as dav from '../../services/DavRequests';
|
import * as dav from "../../services/DavRequests";
|
||||||
import { ViewerManager } from "../../services/Viewer";
|
import { ViewerManager } from "../../services/Viewer";
|
||||||
import { IPhoto } from '../../types';
|
import { IPhoto } from "../../types";
|
||||||
import { getPreviewUrl } from "../../services/FileUtils";
|
import { getPreviewUrl } from "../../services/FileUtils";
|
||||||
|
|
||||||
import LeftMoveIcon from 'vue-material-design-icons/ChevronLeft.vue';
|
import LeftMoveIcon from "vue-material-design-icons/ChevronLeft.vue";
|
||||||
import RightMoveIcon from 'vue-material-design-icons/ChevronRight.vue';
|
import RightMoveIcon from "vue-material-design-icons/ChevronRight.vue";
|
||||||
|
|
||||||
interface IYear {
|
interface IYear {
|
||||||
year: number;
|
year: number;
|
||||||
url: string;
|
url: string;
|
||||||
preview: IPhoto;
|
preview: IPhoto;
|
||||||
photos: IPhoto[];
|
photos: IPhoto[];
|
||||||
text: string;
|
text: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
name: 'OnThisDay',
|
name: "OnThisDay",
|
||||||
components: {
|
components: {
|
||||||
NcActions,
|
NcActions,
|
||||||
NcActionButton,
|
NcActionButton,
|
||||||
LeftMoveIcon,
|
LeftMoveIcon,
|
||||||
RightMoveIcon,
|
RightMoveIcon,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
export default class OnThisDay extends Mixins(GlobalMixin) {
|
export default class OnThisDay extends Mixins(GlobalMixin) {
|
||||||
private getPreviewUrl = getPreviewUrl;
|
private getPreviewUrl = getPreviewUrl;
|
||||||
|
|
||||||
@Emit('load')
|
@Emit("load")
|
||||||
onload() {}
|
onload() {}
|
||||||
|
|
||||||
private years: IYear[] = []
|
private years: IYear[] = [];
|
||||||
|
|
||||||
private hasRight = false;
|
private hasRight = false;
|
||||||
private hasLeft = false;
|
private hasLeft = false;
|
||||||
private scrollStack: number[] = [];
|
private scrollStack: number[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nextcloud viewer proxy
|
* Nextcloud viewer proxy
|
||||||
* Can't use the timeline instance because these photos
|
* Can't use the timeline instance because these photos
|
||||||
* might not be in view, so can't delete them
|
* might not be in view, so can't delete them
|
||||||
*/
|
*/
|
||||||
@Prop()
|
@Prop()
|
||||||
private viewerManager!: ViewerManager;
|
private viewerManager!: ViewerManager;
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
const inner = this.$refs.inner as HTMLElement;
|
const inner = this.$refs.inner as HTMLElement;
|
||||||
inner.addEventListener('scroll', this.onScroll.bind(this));
|
inner.addEventListener("scroll", this.onScroll.bind(this));
|
||||||
|
|
||||||
this.refresh();
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
// Look for cache
|
||||||
|
const dayIdToday = utils.dateToDayId(new Date());
|
||||||
|
const cacheUrl = `/onthisday/${dayIdToday}`;
|
||||||
|
const cache = await utils.getCachedData<IPhoto[]>(cacheUrl);
|
||||||
|
if (cache) this.process(cache);
|
||||||
|
|
||||||
|
// Network request
|
||||||
|
const photos = await dav.getOnThisDayRaw();
|
||||||
|
utils.cacheData(cacheUrl, photos);
|
||||||
|
|
||||||
|
// Check if exactly same as cache
|
||||||
|
if (
|
||||||
|
cache?.length === photos.length &&
|
||||||
|
cache.every((p, i) => p.fileid === photos[i].fileid)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
this.process(photos);
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(photos: IPhoto[]) {
|
||||||
|
this.years = [];
|
||||||
|
|
||||||
|
let currentYear = 9999;
|
||||||
|
|
||||||
|
for (const photo of photos) {
|
||||||
|
const dateTaken = utils.dayIdToDate(photo.dayid);
|
||||||
|
const year = dateTaken.getUTCFullYear();
|
||||||
|
|
||||||
|
if (year !== currentYear) {
|
||||||
|
this.years.push({
|
||||||
|
year,
|
||||||
|
url: "",
|
||||||
|
preview: null,
|
||||||
|
photos: [],
|
||||||
|
text: utils.getFromNowStr(dateTaken),
|
||||||
|
});
|
||||||
|
currentYear = year;
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearObj = this.years[this.years.length - 1];
|
||||||
|
yearObj.photos.push(photo);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refresh() {
|
// For each year, randomly choose 10 photos to display
|
||||||
// Look for cache
|
for (const year of this.years) {
|
||||||
const dayIdToday = utils.dateToDayId(new Date());
|
year.photos = utils.randomSubarray(year.photos, 10);
|
||||||
const cacheUrl = `/onthisday/${dayIdToday}`;
|
|
||||||
const cache = await utils.getCachedData<IPhoto[]>(cacheUrl);
|
|
||||||
if (cache) this.process(cache);
|
|
||||||
|
|
||||||
// Network request
|
|
||||||
const photos = await dav.getOnThisDayRaw();
|
|
||||||
utils.cacheData(cacheUrl, photos);
|
|
||||||
|
|
||||||
// Check if exactly same as cache
|
|
||||||
if (cache?.length === photos.length &&
|
|
||||||
cache.every((p, i) => p.fileid === photos[i].fileid)) return;
|
|
||||||
this.process(photos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async process(photos: IPhoto[]) {
|
// Choose preview photo
|
||||||
this.years = [];
|
for (const year of this.years) {
|
||||||
|
// Try to prioritize landscape photos on desktop
|
||||||
|
if (window.innerWidth <= 600) {
|
||||||
|
const landscape = year.photos.filter((p) => p.w > p.h);
|
||||||
|
year.preview = utils.randomChoice(landscape);
|
||||||
|
}
|
||||||
|
|
||||||
let currentYear = 9999;
|
// Get random photo
|
||||||
|
year.preview ||= utils.randomChoice(year.photos);
|
||||||
for (const photo of photos) {
|
year.url = getPreviewUrl(
|
||||||
const dateTaken = utils.dayIdToDate(photo.dayid);
|
year.preview.fileid,
|
||||||
const year = dateTaken.getUTCFullYear();
|
year.preview.etag,
|
||||||
|
false,
|
||||||
if (year !== currentYear) {
|
512
|
||||||
this.years.push({
|
);
|
||||||
year,
|
|
||||||
url: '',
|
|
||||||
preview: null,
|
|
||||||
photos: [],
|
|
||||||
text: utils.getFromNowStr(dateTaken),
|
|
||||||
});
|
|
||||||
currentYear = year;
|
|
||||||
}
|
|
||||||
|
|
||||||
const yearObj = this.years[this.years.length - 1];
|
|
||||||
yearObj.photos.push(photo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For each year, randomly choose 10 photos to display
|
|
||||||
for (const year of this.years) {
|
|
||||||
year.photos = utils.randomSubarray(year.photos, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Choose preview photo
|
|
||||||
for (const year of this.years) {
|
|
||||||
// Try to prioritize landscape photos on desktop
|
|
||||||
if (window.innerWidth <= 600) {
|
|
||||||
const landscape = year.photos.filter(p => p.w > p.h);
|
|
||||||
year.preview = utils.randomChoice(landscape)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get random photo
|
|
||||||
year.preview ||= utils.randomChoice(year.photos);
|
|
||||||
year.url = getPreviewUrl(year.preview.fileid, year.preview.etag, false, 512);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.$nextTick();
|
|
||||||
this.onScroll();
|
|
||||||
this.onload();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
moveLeft() {
|
await this.$nextTick();
|
||||||
const inner = this.$refs.inner as HTMLElement;
|
this.onScroll();
|
||||||
inner.scrollBy(-(this.scrollStack.pop() || inner.clientWidth), 0);
|
this.onload();
|
||||||
}
|
}
|
||||||
|
|
||||||
moveRight() {
|
moveLeft() {
|
||||||
const inner = this.$refs.inner as HTMLElement;
|
const inner = this.$refs.inner as HTMLElement;
|
||||||
const innerRect = inner.getBoundingClientRect();
|
inner.scrollBy(-(this.scrollStack.pop() || inner.clientWidth), 0);
|
||||||
const nextChild = Array.from(inner.children).map(c => c.getBoundingClientRect()).find((rect) =>
|
}
|
||||||
rect.right > innerRect.right
|
|
||||||
);
|
|
||||||
|
|
||||||
let scroll = nextChild ? (nextChild.left - innerRect.left) : inner.clientWidth;
|
moveRight() {
|
||||||
scroll = Math.min(inner.scrollWidth - inner.scrollLeft - inner.clientWidth, scroll);
|
const inner = this.$refs.inner as HTMLElement;
|
||||||
this.scrollStack.push(scroll);
|
const innerRect = inner.getBoundingClientRect();
|
||||||
inner.scrollBy(scroll, 0);
|
const nextChild = Array.from(inner.children)
|
||||||
}
|
.map((c) => c.getBoundingClientRect())
|
||||||
|
.find((rect) => rect.right > innerRect.right);
|
||||||
|
|
||||||
onScroll() {
|
let scroll = nextChild
|
||||||
const inner = this.$refs.inner as HTMLElement;
|
? nextChild.left - innerRect.left
|
||||||
if (!inner) return;
|
: inner.clientWidth;
|
||||||
this.hasLeft = inner.scrollLeft > 0;
|
scroll = Math.min(
|
||||||
this.hasRight = (inner.clientWidth + inner.scrollLeft < inner.scrollWidth - 20);
|
inner.scrollWidth - inner.scrollLeft - inner.clientWidth,
|
||||||
}
|
scroll
|
||||||
|
);
|
||||||
|
this.scrollStack.push(scroll);
|
||||||
|
inner.scrollBy(scroll, 0);
|
||||||
|
}
|
||||||
|
|
||||||
click(year: IYear) {
|
onScroll() {
|
||||||
const allPhotos = this.years.flatMap(y => y.photos);
|
const inner = this.$refs.inner as HTMLElement;
|
||||||
this.viewerManager.open(year.preview, allPhotos);
|
if (!inner) return;
|
||||||
}
|
this.hasLeft = inner.scrollLeft > 0;
|
||||||
|
this.hasRight =
|
||||||
|
inner.clientWidth + inner.scrollLeft < inner.scrollWidth - 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
click(year: IYear) {
|
||||||
|
const allPhotos = this.years.flatMap((y) => y.photos);
|
||||||
|
this.viewerManager.open(year.preview, allPhotos);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -193,97 +213,107 @@ $height: 200px;
|
||||||
$mobHeight: 150px;
|
$mobHeight: 150px;
|
||||||
|
|
||||||
.outer {
|
.outer {
|
||||||
width: calc(100% - 50px);
|
width: calc(100% - 50px);
|
||||||
height: $height;
|
height: $height;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0 calc(28px * 0.6);
|
padding: 0 calc(28px * 0.6);
|
||||||
|
|
||||||
// Sloppy: ideally this should be done in Timeline
|
// Sloppy: ideally this should be done in Timeline
|
||||||
// to put a gap between the title and this
|
// to put a gap between the title and this
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
|
||||||
|
.inner {
|
||||||
|
height: calc(100% + 20px);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow-x: scroll;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep .dir-btn button {
|
||||||
|
transform: scale(0.6);
|
||||||
|
box-shadow: var(--color-main-text) 0 0 3px 0 !important;
|
||||||
|
background-color: var(--color-main-background) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
transform: translate(-10%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 0;
|
||||||
|
transform: translate(10%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
width: 98%;
|
||||||
|
padding: 0;
|
||||||
.inner {
|
.inner {
|
||||||
height: calc(100% + 20px);
|
padding: 0 8px;
|
||||||
white-space: nowrap;
|
|
||||||
overflow-x: scroll;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
}
|
||||||
|
.dir-btn {
|
||||||
:deep .dir-btn button {
|
display: none;
|
||||||
transform: scale(0.6);
|
|
||||||
box-shadow: var(--color-main-text) 0 0 3px 0 !important;
|
|
||||||
background-color: var(--color-main-background) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%; left: 0;
|
|
||||||
transform: translate(-10%, -50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%; right: 0;
|
|
||||||
transform: translate(10%, -50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
width: 98%;
|
|
||||||
padding: 0;
|
|
||||||
.inner { padding: 0 8px; }
|
|
||||||
.dir-btn { display: none; }
|
|
||||||
}
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
height: $mobHeight;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
height: $mobHeight;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.group {
|
.group {
|
||||||
height: $height;
|
height: $height;
|
||||||
aspect-ratio: 4/3;
|
aspect-ratio: 4/3;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:not(:last-of-type) { margin-right: 8px; }
|
&:not(:last-of-type) {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
cursor: inherit;
|
cursor: inherit;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background-color: var(--color-background-dark);
|
background-color: var(--color-background-dark);
|
||||||
background-clip: padding-box, content-box;
|
background-clip: padding-box, content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.2em;
|
||||||
|
padding: 5%;
|
||||||
|
white-space: normal;
|
||||||
|
cursor: inherit;
|
||||||
|
transition: background-color 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .overlay {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
aspect-ratio: 3/4;
|
||||||
|
height: $mobHeight;
|
||||||
.overlay {
|
.overlay {
|
||||||
position: absolute;
|
font-size: 1.1em;
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: end;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
font-size: 1.2em;
|
|
||||||
padding: 5%;
|
|
||||||
white-space: normal;
|
|
||||||
cursor: inherit;
|
|
||||||
transition: background-color 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover .overlay {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
aspect-ratio: 3/4;
|
|
||||||
height: $mobHeight;
|
|
||||||
.overlay { font-size: 1.1em; }
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,63 +1,63 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="name" class="tag-top-matter">
|
<div v-if="name" class="tag-top-matter">
|
||||||
<NcActions>
|
<NcActions>
|
||||||
<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 }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Mixins, Watch } from 'vue-property-decorator';
|
import { Component, Mixins, Watch } from "vue-property-decorator";
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
|
|
||||||
import { NcActions, NcActionButton } from '@nextcloud/vue';
|
import { NcActions, NcActionButton } from "@nextcloud/vue";
|
||||||
import BackIcon from 'vue-material-design-icons/ArrowLeft.vue';
|
import BackIcon from "vue-material-design-icons/ArrowLeft.vue";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
NcActions,
|
NcActions,
|
||||||
NcActionButton,
|
NcActionButton,
|
||||||
BackIcon,
|
BackIcon,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class TagTopMatter extends Mixins(GlobalMixin) {
|
export default class TagTopMatter extends Mixins(GlobalMixin) {
|
||||||
private name: string = '';
|
private name: string = "";
|
||||||
|
|
||||||
@Watch('$route')
|
@Watch("$route")
|
||||||
async routeChange(from: any, to: any) {
|
async routeChange(from: any, to: any) {
|
||||||
this.createMatter();
|
this.createMatter();
|
||||||
}
|
}
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.createMatter();
|
this.createMatter();
|
||||||
}
|
}
|
||||||
|
|
||||||
createMatter() {
|
createMatter() {
|
||||||
this.name = this.$route.params.name || '';
|
this.name = this.$route.params.name || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
back() {
|
back() {
|
||||||
this.$router.push({ name: 'tags' });
|
this.$router.push({ name: "tags" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.tag-top-matter {
|
.tag-top-matter {
|
||||||
.name {
|
.name {
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 42px;
|
line-height: 42px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,53 +1,62 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="top-matter" v-if="type">
|
<div class="top-matter" v-if="type">
|
||||||
<FolderTopMatter v-if="type === 1" />
|
<FolderTopMatter v-if="type === 1" />
|
||||||
<TagTopMatter v-else-if="type === 2" />
|
<TagTopMatter 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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Mixins, Watch } from 'vue-property-decorator';
|
import { Component, Mixins, Watch } from "vue-property-decorator";
|
||||||
import FolderTopMatter from "./FolderTopMatter.vue";
|
import FolderTopMatter from "./FolderTopMatter.vue";
|
||||||
import TagTopMatter from "./TagTopMatter.vue";
|
import TagTopMatter from "./TagTopMatter.vue";
|
||||||
import FaceTopMatter from "./FaceTopMatter.vue";
|
import FaceTopMatter from "./FaceTopMatter.vue";
|
||||||
import AlbumTopMatter from "./AlbumTopMatter.vue";
|
import AlbumTopMatter from "./AlbumTopMatter.vue";
|
||||||
|
|
||||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||||
import { TopMatterType } from '../../types';
|
import { TopMatterType } from "../../types";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
FolderTopMatter,
|
FolderTopMatter,
|
||||||
TagTopMatter,
|
TagTopMatter,
|
||||||
FaceTopMatter,
|
FaceTopMatter,
|
||||||
AlbumTopMatter,
|
AlbumTopMatter,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class TopMatter extends Mixins(GlobalMixin) {
|
export default class TopMatter extends Mixins(GlobalMixin) {
|
||||||
public type: TopMatterType = TopMatterType.NONE;
|
public type: TopMatterType = TopMatterType.NONE;
|
||||||
|
|
||||||
@Watch('$route')
|
@Watch("$route")
|
||||||
async routeChange(from: any, to: any) {
|
async routeChange(from: any, to: any) {
|
||||||
this.setTopMatter();
|
this.setTopMatter();
|
||||||
}
|
}
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.setTopMatter();
|
this.setTopMatter();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create top matter */
|
/** Create top matter */
|
||||||
setTopMatter() {
|
setTopMatter() {
|
||||||
this.type = (() => {
|
this.type = (() => {
|
||||||
switch (this.$route.name) {
|
switch (this.$route.name) {
|
||||||
case 'folders': return TopMatterType.FOLDER;
|
case "folders":
|
||||||
case 'tags': return this.$route.params.name ? TopMatterType.TAG : TopMatterType.NONE;
|
return TopMatterType.FOLDER;
|
||||||
case 'people': return this.$route.params.name ? TopMatterType.FACE : TopMatterType.NONE;
|
case "tags":
|
||||||
case 'albums': return TopMatterType.ALBUM;
|
return this.$route.params.name
|
||||||
default: return TopMatterType.NONE;
|
? TopMatterType.TAG
|
||||||
}
|
: TopMatterType.NONE;
|
||||||
})();
|
case "people":
|
||||||
}
|
return this.$route.params.name
|
||||||
|
? TopMatterType.FACE
|
||||||
|
: TopMatterType.NONE;
|
||||||
|
case "albums":
|
||||||
|
return TopMatterType.ALBUM;
|
||||||
|
default:
|
||||||
|
return TopMatterType.NONE;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
40
src/main.ts
40
src/main.ts
|
@ -19,30 +19,34 @@
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
import 'reflect-metadata'
|
import "reflect-metadata";
|
||||||
import Vue from 'vue'
|
import Vue from "vue";
|
||||||
import VueVirtualScroller from 'vue-virtual-scroller'
|
import VueVirtualScroller from "vue-virtual-scroller";
|
||||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from "./App.vue";
|
||||||
import router from './router'
|
import router from "./router";
|
||||||
|
|
||||||
Vue.use(VueVirtualScroller)
|
Vue.use(VueVirtualScroller);
|
||||||
|
|
||||||
// https://github.com/nextcloud/photos/blob/156f280c0476c483cb9ce81769ccb0c1c6500a4e/src/main.js
|
// https://github.com/nextcloud/photos/blob/156f280c0476c483cb9ce81769ccb0c1c6500a4e/src/main.js
|
||||||
// TODO: remove when we have a proper fileinfo standalone library
|
// TODO: remove when we have a proper fileinfo standalone library
|
||||||
// original scripts are loaded from
|
// original scripts are loaded from
|
||||||
// https://github.com/nextcloud/server/blob/5bf3d1bb384da56adbf205752be8f840aac3b0c5/lib/private/legacy/template.php#L120-L122
|
// https://github.com/nextcloud/server/blob/5bf3d1bb384da56adbf205752be8f840aac3b0c5/lib/private/legacy/template.php#L120-L122
|
||||||
window.addEventListener('DOMContentLoaded', () => {
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
if (!globalThis.OCA.Files) {
|
if (!globalThis.OCA.Files) {
|
||||||
globalThis.OCA.Files = {}
|
globalThis.OCA.Files = {};
|
||||||
}
|
}
|
||||||
// register unused client for the sidebar to have access to its parser methods
|
// register unused client for the sidebar to have access to its parser methods
|
||||||
Object.assign(globalThis.OCA.Files, { App: { fileList: { filesClient: globalThis.OC.Files.getClient() } } }, globalThis.OCA.Files)
|
Object.assign(
|
||||||
})
|
globalThis.OCA.Files,
|
||||||
|
{ App: { fileList: { filesClient: globalThis.OC.Files.getClient() } } },
|
||||||
|
globalThis.OCA.Files
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export default new Vue({
|
export default new Vue({
|
||||||
el: '#content',
|
el: "#content",
|
||||||
router,
|
router,
|
||||||
render: h => h(App),
|
render: (h) => h(App),
|
||||||
})
|
});
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import { Component, Vue } from 'vue-property-decorator';
|
import { Component, Vue } from "vue-property-decorator";
|
||||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
|
||||||
import { constants } from '../services/Utils';
|
import { constants } from "../services/Utils";
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
export default class GlobalMixin extends Vue {
|
export default class GlobalMixin extends Vue {
|
||||||
public readonly t = t;
|
public readonly t = t;
|
||||||
public readonly n = n;
|
public readonly n = n;
|
||||||
|
|
||||||
public readonly c = constants.c;
|
public readonly c = constants.c;
|
||||||
public readonly TagDayID = constants.TagDayID;
|
public readonly TagDayID = constants.TagDayID;
|
||||||
public readonly TagDayIDValueSet = constants.TagDayIDValueSet;
|
public readonly TagDayIDValueSet = constants.TagDayIDValueSet;
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,60 +20,60 @@
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, Vue } from 'vue-property-decorator';
|
import { Component, Vue } from "vue-property-decorator";
|
||||||
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
|
import { emit, subscribe, unsubscribe } from "@nextcloud/event-bus";
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from "@nextcloud/router";
|
||||||
import { loadState } from '@nextcloud/initial-state'
|
import { loadState } from "@nextcloud/initial-state";
|
||||||
import axios from '@nextcloud/axios'
|
import axios from "@nextcloud/axios";
|
||||||
|
|
||||||
const eventName = 'memories:user-config-changed'
|
const eventName = "memories:user-config-changed";
|
||||||
const localSettings = ['squareThumbs', 'showFaceRect'];
|
const localSettings = ["squareThumbs", "showFaceRect"];
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
export default class UserConfig extends Vue {
|
export default class UserConfig extends Vue {
|
||||||
config_timelinePath: string = loadState('memories', 'timelinePath') || '';
|
config_timelinePath: string = loadState("memories", "timelinePath") || "";
|
||||||
config_foldersPath: string = loadState('memories', 'foldersPath') || '/';
|
config_foldersPath: string = loadState("memories", "foldersPath") || "/";
|
||||||
config_showHidden = loadState('memories', 'showHidden') === "true";
|
config_showHidden = loadState("memories", "showHidden") === "true";
|
||||||
|
|
||||||
config_tagsEnabled = Boolean(loadState('memories', 'systemtags'));
|
config_tagsEnabled = Boolean(loadState("memories", "systemtags"));
|
||||||
config_recognizeEnabled = Boolean(loadState('memories', 'recognize'));
|
config_recognizeEnabled = Boolean(loadState("memories", "recognize"));
|
||||||
config_mapsEnabled = Boolean(loadState('memories', 'maps'));
|
config_mapsEnabled = Boolean(loadState("memories", "maps"));
|
||||||
config_albumsEnabled = Boolean(loadState('memories', 'albums'));
|
config_albumsEnabled = Boolean(loadState("memories", "albums"));
|
||||||
|
|
||||||
config_squareThumbs = localStorage.getItem('memories_squareThumbs') === '1';
|
config_squareThumbs = localStorage.getItem("memories_squareThumbs") === "1";
|
||||||
config_showFaceRect = localStorage.getItem('memories_showFaceRect') === '1';
|
config_showFaceRect = localStorage.getItem("memories_showFaceRect") === "1";
|
||||||
|
|
||||||
config_eventName = eventName;
|
config_eventName = eventName;
|
||||||
|
|
||||||
created() {
|
created() {
|
||||||
subscribe(eventName, this.updateLocalSetting)
|
subscribe(eventName, this.updateLocalSetting);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeDestroy() {
|
||||||
|
unsubscribe(eventName, this.updateLocalSetting);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLocalSetting({ setting, value }) {
|
||||||
|
this["config_" + setting] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSetting(setting: string) {
|
||||||
|
const value = this["config_" + setting];
|
||||||
|
|
||||||
|
if (localSettings.includes(setting)) {
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
localStorage.setItem("memories_" + setting, value ? "1" : "0");
|
||||||
|
} else {
|
||||||
|
localStorage.setItem("memories_" + setting, value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Long time save setting
|
||||||
|
await axios.put(generateUrl("apps/memories/api/config/" + setting), {
|
||||||
|
value: value.toString(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeDestroy() {
|
// Visible elements update setting
|
||||||
unsubscribe(eventName, this.updateLocalSetting)
|
emit(eventName, { setting, value });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
updateLocalSetting({ setting, value }) {
|
|
||||||
this['config_' + setting] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateSetting(setting: string) {
|
|
||||||
const value = this['config_' + setting]
|
|
||||||
|
|
||||||
if (localSettings.includes(setting)) {
|
|
||||||
if (typeof value === 'boolean') {
|
|
||||||
localStorage.setItem('memories_' + setting, value ? '1' : '0')
|
|
||||||
} else {
|
|
||||||
localStorage.setItem('memories_' + setting, value)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Long time save setting
|
|
||||||
await axios.put(generateUrl('apps/memories/api/config/' + setting), {
|
|
||||||
value: value.toString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Visible elements update setting
|
|
||||||
emit(eventName, { setting, value });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
210
src/router.ts
210
src/router.ts
|
@ -20,120 +20,120 @@
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from "@nextcloud/router";
|
||||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
|
||||||
import Router from 'vue-router'
|
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";
|
||||||
|
|
||||||
Vue.use(Router)
|
Vue.use(Router);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the path of a route : join the elements of the array and return a single string with slashes
|
* Parse the path of a route : join the elements of the array and return a single string with slashes
|
||||||
* + always lead current path with a slash
|
* + always lead current path with a slash
|
||||||
*
|
*
|
||||||
* @param {string | Array} path path arguments to parse
|
* @param {string | Array} path path arguments to parse
|
||||||
* @return {string}
|
* @return {string}
|
||||||
*/
|
*/
|
||||||
const parsePathParams = (path) => {
|
const parsePathParams = (path) => {
|
||||||
return `/${Array.isArray(path) ? path.join('/') : path || ''}`
|
return `/${Array.isArray(path) ? path.join("/") : path || ""}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default new Router({
|
export default new Router({
|
||||||
mode: 'history',
|
mode: "history",
|
||||||
// if index.php is in the url AND we got this far, then it's working:
|
// if index.php is in the url AND we got this far, then it's working:
|
||||||
// let's keep using index.php in the url
|
// let's keep using index.php in the url
|
||||||
base: generateUrl('/apps/memories'),
|
base: generateUrl("/apps/memories"),
|
||||||
linkActiveClass: 'active',
|
linkActiveClass: "active",
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: "/",
|
||||||
component: Timeline,
|
component: Timeline,
|
||||||
name: 'timeline',
|
name: "timeline",
|
||||||
props: route => ({
|
props: (route) => ({
|
||||||
rootTitle: t('memories', 'Timeline'),
|
rootTitle: t("memories", "Timeline"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/folders/:path*',
|
path: "/folders/:path*",
|
||||||
component: Timeline,
|
component: Timeline,
|
||||||
name: 'folders',
|
name: "folders",
|
||||||
props: route => ({
|
props: (route) => ({
|
||||||
rootTitle: t('memories', 'Folders'),
|
rootTitle: t("memories", "Folders"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/favorites',
|
path: "/favorites",
|
||||||
component: Timeline,
|
component: Timeline,
|
||||||
name: 'favorites',
|
name: "favorites",
|
||||||
props: route => ({
|
props: (route) => ({
|
||||||
rootTitle: t('memories', 'Favorites'),
|
rootTitle: t("memories", "Favorites"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/videos',
|
path: "/videos",
|
||||||
component: Timeline,
|
component: Timeline,
|
||||||
name: 'videos',
|
name: "videos",
|
||||||
props: route => ({
|
props: (route) => ({
|
||||||
rootTitle: t('memories', 'Videos'),
|
rootTitle: t("memories", "Videos"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/albums/:user?/:name?',
|
path: "/albums/:user?/:name?",
|
||||||
component: Timeline,
|
component: Timeline,
|
||||||
name: 'albums',
|
name: "albums",
|
||||||
props: route => ({
|
props: (route) => ({
|
||||||
rootTitle: t('memories', 'Albums'),
|
rootTitle: t("memories", "Albums"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/archive',
|
path: "/archive",
|
||||||
component: Timeline,
|
component: Timeline,
|
||||||
name: 'archive',
|
name: "archive",
|
||||||
props: route => ({
|
props: (route) => ({
|
||||||
rootTitle: t('memories', 'Archive'),
|
rootTitle: t("memories", "Archive"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/thisday',
|
path: "/thisday",
|
||||||
component: Timeline,
|
component: Timeline,
|
||||||
name: 'thisday',
|
name: "thisday",
|
||||||
props: route => ({
|
props: (route) => ({
|
||||||
rootTitle: t('memories', 'On this day'),
|
rootTitle: t("memories", "On this day"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/people/:user?/:name?',
|
path: "/people/:user?/:name?",
|
||||||
component: Timeline,
|
component: Timeline,
|
||||||
name: 'people',
|
name: "people",
|
||||||
props: route => ({
|
props: (route) => ({
|
||||||
rootTitle: t('memories', 'People'),
|
rootTitle: t("memories", "People"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/tags/:name*',
|
path: "/tags/:name*",
|
||||||
component: Timeline,
|
component: Timeline,
|
||||||
name: 'tags',
|
name: "tags",
|
||||||
props: route => ({
|
props: (route) => ({
|
||||||
rootTitle: t('memories', 'Tags'),
|
rootTitle: t("memories", "Tags"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/maps',
|
path: "/maps",
|
||||||
name: 'maps',
|
name: "maps",
|
||||||
// router-link doesn't support external url, let's force the redirect
|
// router-link doesn't support external url, let's force the redirect
|
||||||
beforeEnter() {
|
beforeEnter() {
|
||||||
window.open(generateUrl('/apps/maps'), '_blank')
|
window.open(generateUrl("/apps/maps"), "_blank");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|
|
@ -20,26 +20,30 @@
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as webdav from 'webdav'
|
import * as webdav from "webdav";
|
||||||
import axios from '@nextcloud/axios'
|
import axios from "@nextcloud/axios";
|
||||||
import parseUrl from 'url-parse'
|
import parseUrl from "url-parse";
|
||||||
import { generateRemoteUrl } from '@nextcloud/router'
|
import { generateRemoteUrl } from "@nextcloud/router";
|
||||||
|
|
||||||
// Monkey business
|
// Monkey business
|
||||||
import * as rq from 'webdav/dist/node/request';
|
import * as rq from "webdav/dist/node/request";
|
||||||
(<any>rq).prepareRequestOptionsOld = rq.prepareRequestOptions.bind(rq);
|
(<any>rq).prepareRequestOptionsOld = rq.prepareRequestOptions.bind(rq);
|
||||||
(<any>rq).prepareRequestOptions = (function(requestOptions, context, userOptions) {
|
(<any>rq).prepareRequestOptions = function (
|
||||||
requestOptions.method = userOptions.method || requestOptions.method;
|
requestOptions,
|
||||||
return this.prepareRequestOptionsOld(requestOptions, context, userOptions);
|
context,
|
||||||
}).bind(rq);
|
userOptions
|
||||||
|
) {
|
||||||
|
requestOptions.method = userOptions.method || requestOptions.method;
|
||||||
|
return this.prepareRequestOptionsOld(requestOptions, context, userOptions);
|
||||||
|
}.bind(rq);
|
||||||
|
|
||||||
// force our axios
|
// force our axios
|
||||||
const patcher = webdav.getPatcher()
|
const patcher = webdav.getPatcher();
|
||||||
patcher.patch('request', axios)
|
patcher.patch("request", axios);
|
||||||
|
|
||||||
// init webdav client on default dav endpoint
|
// init webdav client on default dav endpoint
|
||||||
const remote = generateRemoteUrl('dav')
|
const remote = generateRemoteUrl("dav");
|
||||||
const client = webdav.createClient(remote)
|
const client = webdav.createClient(remote);
|
||||||
|
|
||||||
export const remotePath = parseUrl(remote).pathname
|
export const remotePath = parseUrl(remote).pathname;
|
||||||
export default client
|
export default client;
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
export * from './dav/base';
|
export * from "./dav/base";
|
||||||
export * from './dav/albums';
|
export * from "./dav/albums";
|
||||||
export * from './dav/archive';
|
export * from "./dav/archive";
|
||||||
export * from './dav/download';
|
export * from "./dav/download";
|
||||||
export * from './dav/face';
|
export * from "./dav/face";
|
||||||
export * from './dav/favorites';
|
export * from "./dav/favorites";
|
||||||
export * from './dav/folders';
|
export * from "./dav/folders";
|
||||||
export * from './dav/onthisday';
|
export * from "./dav/onthisday";
|
||||||
export * from './dav/tags';
|
export * from "./dav/tags";
|
||||||
|
|
|
@ -19,113 +19,136 @@
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
import camelcase from 'camelcase'
|
import camelcase from "camelcase";
|
||||||
import { isNumber } from './NumberUtils'
|
import { isNumber } from "./NumberUtils";
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from "@nextcloud/router";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an url encoded path
|
* Get an url encoded path
|
||||||
*
|
*
|
||||||
* @param {string} path the full path
|
* @param {string} path the full path
|
||||||
* @return {string} url encoded file path
|
* @return {string} url encoded file path
|
||||||
*/
|
*/
|
||||||
const encodeFilePath = function(path) {
|
const encodeFilePath = function (path) {
|
||||||
const pathSections = (path.startsWith('/') ? path : `/${path}`).split('/')
|
const pathSections = (path.startsWith("/") ? path : `/${path}`).split("/");
|
||||||
let relativePath = ''
|
let relativePath = "";
|
||||||
pathSections.forEach((section) => {
|
pathSections.forEach((section) => {
|
||||||
if (section !== '') {
|
if (section !== "") {
|
||||||
relativePath += '/' + encodeURIComponent(section)
|
relativePath += "/" + encodeURIComponent(section);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
return relativePath
|
return relativePath;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract dir and name from file path
|
* Extract dir and name from file path
|
||||||
*
|
*
|
||||||
* @param {string} path the full path
|
* @param {string} path the full path
|
||||||
* @return {string[]} [dirPath, fileName]
|
* @return {string[]} [dirPath, fileName]
|
||||||
*/
|
*/
|
||||||
const extractFilePaths = function(path) {
|
const extractFilePaths = function (path) {
|
||||||
const pathSections = path.split('/')
|
const pathSections = path.split("/");
|
||||||
const fileName = pathSections[pathSections.length - 1]
|
const fileName = pathSections[pathSections.length - 1];
|
||||||
const dirPath = pathSections.slice(0, pathSections.length - 1).join('/')
|
const dirPath = pathSections.slice(0, pathSections.length - 1).join("/");
|
||||||
return [dirPath, fileName]
|
return [dirPath, fileName];
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorting comparison function
|
* Sorting comparison function
|
||||||
*
|
*
|
||||||
* @param {object} fileInfo1 file 1 fileinfo
|
* @param {object} fileInfo1 file 1 fileinfo
|
||||||
* @param {object} fileInfo2 file 2 fileinfo
|
* @param {object} fileInfo2 file 2 fileinfo
|
||||||
* @param {string} key key to sort with
|
* @param {string} key key to sort with
|
||||||
* @param {boolean} [asc=true] sort ascending?
|
* @param {boolean} [asc=true] sort ascending?
|
||||||
* @return {number}
|
* @return {number}
|
||||||
*/
|
*/
|
||||||
const sortCompare = function(fileInfo1, fileInfo2, key, asc = true) {
|
const sortCompare = function (fileInfo1, fileInfo2, key, asc = true) {
|
||||||
|
// favorite always first
|
||||||
|
if (fileInfo1.isFavorite && !fileInfo2.isFavorite) {
|
||||||
|
return -1;
|
||||||
|
} else if (!fileInfo1.isFavorite && fileInfo2.isFavorite) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
// favorite always first
|
// if this is a number, let's sort by integer
|
||||||
if (fileInfo1.isFavorite && !fileInfo2.isFavorite) {
|
if (isNumber(fileInfo1[key]) && isNumber(fileInfo2[key])) {
|
||||||
return -1
|
return asc
|
||||||
} else if (!fileInfo1.isFavorite && fileInfo2.isFavorite) {
|
? Number(fileInfo2[key]) - Number(fileInfo1[key])
|
||||||
return 1
|
: Number(fileInfo1[key]) - Number(fileInfo2[key]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if this is a number, let's sort by integer
|
// else we sort by string, so let's sort directories first
|
||||||
if (isNumber(fileInfo1[key]) && isNumber(fileInfo2[key])) {
|
if (fileInfo1.type !== "file" && fileInfo2.type === "file") {
|
||||||
return asc
|
return asc ? -1 : 1;
|
||||||
? Number(fileInfo2[key]) - Number(fileInfo1[key])
|
} else if (fileInfo1.type === "file" && fileInfo2.type !== "file") {
|
||||||
: Number(fileInfo1[key]) - Number(fileInfo2[key])
|
return asc ? 1 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// else we sort by string, so let's sort directories first
|
// if this is a date, let's sort by date
|
||||||
if (fileInfo1.type !== 'file' && fileInfo2.type === 'file') {
|
if (
|
||||||
return asc ? -1 : 1
|
isNumber(new Date(fileInfo1[key]).getTime()) &&
|
||||||
} else if (fileInfo1.type === 'file' && fileInfo2.type !== 'file') {
|
isNumber(new Date(fileInfo2[key]).getTime())
|
||||||
return asc ? 1 : -1
|
) {
|
||||||
}
|
return asc
|
||||||
|
? new Date(fileInfo2[key]).getTime() - new Date(fileInfo1[key]).getTime()
|
||||||
|
: new Date(fileInfo1[key]).getTime() - new Date(fileInfo2[key]).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
// if this is a date, let's sort by date
|
// finally sort by name
|
||||||
if (isNumber(new Date(fileInfo1[key]).getTime()) && isNumber(new Date(fileInfo2[key]).getTime())) {
|
return asc
|
||||||
return asc
|
? fileInfo1[key]
|
||||||
? new Date(fileInfo2[key]).getTime() - new Date(fileInfo1[key]).getTime()
|
?.toString()
|
||||||
: new Date(fileInfo1[key]).getTime() - new Date(fileInfo2[key]).getTime()
|
?.localeCompare(
|
||||||
}
|
fileInfo2[key].toString(),
|
||||||
|
globalThis.OC.getLanguage()
|
||||||
|
) || 1
|
||||||
|
: -fileInfo1[key]
|
||||||
|
?.toString()
|
||||||
|
?.localeCompare(
|
||||||
|
fileInfo2[key].toString(),
|
||||||
|
globalThis.OC.getLanguage()
|
||||||
|
) || -1;
|
||||||
|
};
|
||||||
|
|
||||||
// finally sort by name
|
const genFileInfo = function (obj) {
|
||||||
return asc
|
const fileInfo = {};
|
||||||
? fileInfo1[key]?.toString()?.localeCompare(fileInfo2[key].toString(), globalThis.OC.getLanguage()) || 1
|
|
||||||
: -fileInfo1[key]?.toString()?.localeCompare(fileInfo2[key].toString(), globalThis.OC.getLanguage()) || -1
|
|
||||||
}
|
|
||||||
|
|
||||||
const genFileInfo = function(obj) {
|
Object.keys(obj).forEach((key) => {
|
||||||
const fileInfo = {}
|
const data = obj[key];
|
||||||
|
|
||||||
Object.keys(obj).forEach(key => {
|
// flatten object if any
|
||||||
const data = obj[key]
|
if (!!data && typeof data === "object") {
|
||||||
|
Object.assign(fileInfo, genFileInfo(data));
|
||||||
|
} else {
|
||||||
|
// format key and add it to the fileInfo
|
||||||
|
if (data === "false") {
|
||||||
|
fileInfo[camelcase(key)] = false;
|
||||||
|
} else if (data === "true") {
|
||||||
|
fileInfo[camelcase(key)] = true;
|
||||||
|
} else {
|
||||||
|
fileInfo[camelcase(key)] = isNumber(data) ? Number(data) : data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return fileInfo;
|
||||||
|
};
|
||||||
|
|
||||||
// flatten object if any
|
const getPreviewUrl = function (
|
||||||
if (!!data && typeof data === 'object') {
|
fileid: number,
|
||||||
Object.assign(fileInfo, genFileInfo(data))
|
etag: string,
|
||||||
} else {
|
square: boolean,
|
||||||
// format key and add it to the fileInfo
|
size: number
|
||||||
if (data === 'false') {
|
): string {
|
||||||
fileInfo[camelcase(key)] = false
|
const a = square ? "0" : "1";
|
||||||
} else if (data === 'true') {
|
return generateUrl(
|
||||||
fileInfo[camelcase(key)] = true
|
`/core/preview?fileId=${fileid}&c=${etag}&x=${size}&y=${size}&forceIcon=0&a=${a}`
|
||||||
} else {
|
);
|
||||||
fileInfo[camelcase(key)] = isNumber(data)
|
};
|
||||||
? Number(data)
|
|
||||||
: data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return fileInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPreviewUrl = function(fileid: number, etag: string, square: boolean, size: number): string {
|
export {
|
||||||
const a = square ? '0' : '1'
|
encodeFilePath,
|
||||||
return generateUrl(`/core/preview?fileId=${fileid}&c=${etag}&x=${size}&y=${size}&forceIcon=0&a=${a}`);
|
extractFilePaths,
|
||||||
}
|
sortCompare,
|
||||||
|
genFileInfo,
|
||||||
export { encodeFilePath, extractFilePaths, sortCompare, genFileInfo, getPreviewUrl }
|
getPreviewUrl,
|
||||||
|
};
|
||||||
|
|
|
@ -7,221 +7,236 @@ import justifiedLayout from "justified-layout";
|
||||||
* Otherwise, use flickr/justified-layout (at least for now).
|
* Otherwise, use flickr/justified-layout (at least for now).
|
||||||
*/
|
*/
|
||||||
export function getLayout(
|
export function getLayout(
|
||||||
input: {
|
input: {
|
||||||
width: number,
|
width: number;
|
||||||
height: number,
|
height: number;
|
||||||
forceSquare: boolean,
|
forceSquare: boolean;
|
||||||
}[],
|
}[],
|
||||||
opts: {
|
opts: {
|
||||||
rowWidth: number,
|
rowWidth: number;
|
||||||
rowHeight: number,
|
rowHeight: number;
|
||||||
squareMode: boolean,
|
squareMode: boolean;
|
||||||
numCols: number,
|
numCols: number;
|
||||||
allowBreakout: boolean,
|
allowBreakout: boolean;
|
||||||
seed: number,
|
seed: number;
|
||||||
}
|
}
|
||||||
): {
|
): {
|
||||||
top: number,
|
top: number;
|
||||||
left: number,
|
left: number;
|
||||||
width: number,
|
width: number;
|
||||||
height: number,
|
height: number;
|
||||||
rowHeight?: number,
|
rowHeight?: number;
|
||||||
}[] {
|
}[] {
|
||||||
if (input.length === 0) return [];
|
if (input.length === 0) return [];
|
||||||
|
|
||||||
if (!opts.squareMode) {
|
if (!opts.squareMode) {
|
||||||
return justifiedLayout((input), {
|
return justifiedLayout(input, {
|
||||||
containerPadding: 0,
|
containerPadding: 0,
|
||||||
boxSpacing: 0,
|
boxSpacing: 0,
|
||||||
containerWidth: opts.rowWidth,
|
containerWidth: opts.rowWidth,
|
||||||
targetRowHeight: opts.rowHeight,
|
targetRowHeight: opts.rowHeight,
|
||||||
targetRowHeightTolerance: 0.1,
|
targetRowHeightTolerance: 0.1,
|
||||||
}).boxes;
|
}).boxes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RNG
|
||||||
|
const rand = mulberry32(opts.seed);
|
||||||
|
|
||||||
|
// Binary flags
|
||||||
|
const FLAG_USE = 1 << 0;
|
||||||
|
const FLAG_USED = 1 << 1;
|
||||||
|
const FLAG_USE4 = 1 << 2;
|
||||||
|
const FLAG_USE6 = 1 << 3;
|
||||||
|
const FLAG_BREAKOUT = 1 << 4;
|
||||||
|
|
||||||
|
// Create 2d matrix to work in
|
||||||
|
const matrix: number[][] = [];
|
||||||
|
|
||||||
|
// Fill in the matrix
|
||||||
|
let row = 0;
|
||||||
|
let col = 0;
|
||||||
|
let photoId = 0;
|
||||||
|
while (photoId < input.length) {
|
||||||
|
// Check if we reached the end of row
|
||||||
|
if (col >= opts.numCols) {
|
||||||
|
row++;
|
||||||
|
col = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// RNG
|
// Make sure we have this and the next few rows
|
||||||
const rand = mulberry32(opts.seed);
|
while (row + 3 >= matrix.length) {
|
||||||
|
matrix.push(new Array(opts.numCols).fill(0));
|
||||||
// Binary flags
|
|
||||||
const FLAG_USE = 1 << 0;
|
|
||||||
const FLAG_USED = 1 << 1;
|
|
||||||
const FLAG_USE4 = 1 << 2;
|
|
||||||
const FLAG_USE6 = 1 << 3;
|
|
||||||
const FLAG_BREAKOUT = 1 << 4;
|
|
||||||
|
|
||||||
// Create 2d matrix to work in
|
|
||||||
const matrix: number[][] = [];
|
|
||||||
|
|
||||||
// Fill in the matrix
|
|
||||||
let row = 0;
|
|
||||||
let col = 0;
|
|
||||||
let photoId = 0;
|
|
||||||
while (photoId < input.length) {
|
|
||||||
// Check if we reached the end of row
|
|
||||||
if (col >= opts.numCols) {
|
|
||||||
row++; col = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure we have this and the next few rows
|
|
||||||
while (row + 3 >= matrix.length) {
|
|
||||||
matrix.push(new Array(opts.numCols).fill(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already used
|
|
||||||
if (matrix[row][col] & FLAG_USED) {
|
|
||||||
col++; continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use this slot
|
|
||||||
matrix[row][col] |= FLAG_USE;
|
|
||||||
|
|
||||||
// Check if previous row has something used
|
|
||||||
// or something beside this is used
|
|
||||||
// We don't do these one after another
|
|
||||||
if (!opts.allowBreakout ||
|
|
||||||
(row > 0 && matrix[row-1].some(v => v & FLAG_USED)) ||
|
|
||||||
(col > 0 && matrix[row][col-1] & FLAG_USED)
|
|
||||||
) {
|
|
||||||
photoId++; col++; continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Number of photos left
|
|
||||||
const numLeft = input.length-photoId-1;
|
|
||||||
// Number of photos needed for perfect fill after using n
|
|
||||||
const needFill = (n: number) => ((opts.numCols-col-2) + (n/2-1)*(opts.numCols-2));
|
|
||||||
|
|
||||||
let canUse4 =
|
|
||||||
// We have enough space
|
|
||||||
(row + 1 < matrix.length && col+1 < opts.numCols) &&
|
|
||||||
// This cannot end up being a widow (conservative)
|
|
||||||
// Also make sure the next row gets fully filled, otherwise looks weird
|
|
||||||
(numLeft === needFill(4) || numLeft >= needFill(4)+opts.numCols);
|
|
||||||
|
|
||||||
let canUse6 =
|
|
||||||
// Image is portrait
|
|
||||||
input[photoId].height > input[photoId].width &&
|
|
||||||
// We have enough space
|
|
||||||
(row + 2 < matrix.length && col+1 < opts.numCols) &&
|
|
||||||
// This cannot end up being a widow (conservative)
|
|
||||||
// Also make sure the next row gets fully filled, otherwise looks weird
|
|
||||||
(numLeft === needFill(6) || numLeft >= needFill(6)+2*opts.numCols);
|
|
||||||
|
|
||||||
let canBreakout =
|
|
||||||
// First column only
|
|
||||||
col === 0 &&
|
|
||||||
// Image is landscape
|
|
||||||
input[photoId].width > input[photoId].height &&
|
|
||||||
// The next row gets filled
|
|
||||||
(numLeft === 0 || numLeft >= opts.numCols);
|
|
||||||
|
|
||||||
// Probably folders or tags or faces
|
|
||||||
if (input[photoId].forceSquare) {
|
|
||||||
// We are square already. Everything below is else-if.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full width breakout
|
|
||||||
else if (canBreakout && rand() < (input.length > 0 ? 0.25 : 0.1)) {
|
|
||||||
matrix[row][col] |= FLAG_BREAKOUT;
|
|
||||||
for (let i = 1; i < opts.numCols; i++) {
|
|
||||||
matrix[row][i] |= FLAG_USED;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use 6 vertically
|
|
||||||
else if (canUse6 && rand() < 0.2) {
|
|
||||||
matrix[row][col] |= FLAG_USE6;
|
|
||||||
matrix[row+1][col] |= FLAG_USED;
|
|
||||||
matrix[row+2][col] |= FLAG_USED;
|
|
||||||
matrix[row][col+1] |= FLAG_USED;
|
|
||||||
matrix[row+1][col+1] |= FLAG_USED;
|
|
||||||
matrix[row+2][col+1] |= FLAG_USED;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use 4 box
|
|
||||||
else if (canUse4 && rand() < 0.35) {
|
|
||||||
matrix[row][col] |= FLAG_USE4;
|
|
||||||
matrix[row+1][col] |= FLAG_USED;
|
|
||||||
matrix[row][col+1] |= FLAG_USED;
|
|
||||||
matrix[row+1][col+1] |= FLAG_USED;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Go ahead
|
|
||||||
photoId++; col++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Square layout matrix
|
// Check if already used
|
||||||
const absMatrix: {
|
if (matrix[row][col] & FLAG_USED) {
|
||||||
top: number,
|
col++;
|
||||||
left: number,
|
continue;
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
let currTop = 0;
|
|
||||||
row = 0; col = 0; photoId = 0;
|
|
||||||
while (photoId < input.length) {
|
|
||||||
// Check if we reached the end of row
|
|
||||||
if (col >= opts.numCols) {
|
|
||||||
row++; col = 0;
|
|
||||||
currTop += opts.rowHeight;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if used
|
|
||||||
if (!(matrix[row][col] & FLAG_USE)) {
|
|
||||||
col++; continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create basic object
|
|
||||||
const sqsize = opts.rowHeight;
|
|
||||||
const p = {
|
|
||||||
top: currTop,
|
|
||||||
left: col * sqsize,
|
|
||||||
width: sqsize,
|
|
||||||
height: sqsize,
|
|
||||||
rowHeight: opts.rowHeight,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use twice the space
|
|
||||||
const v = matrix[row][col];
|
|
||||||
if (v & FLAG_USE4) {
|
|
||||||
p.width *= 2;
|
|
||||||
p.height *= 2;
|
|
||||||
col += 2;
|
|
||||||
} else if (v & FLAG_USE6) {
|
|
||||||
p.width *= 2;
|
|
||||||
p.height *= 3;
|
|
||||||
col += 2;
|
|
||||||
} else if (v & FLAG_BREAKOUT) {
|
|
||||||
p.width *= opts.numCols;
|
|
||||||
p.height = input[photoId].height * p.width / input[photoId].width;
|
|
||||||
p.rowHeight = p.height;
|
|
||||||
col += opts.numCols;
|
|
||||||
} else {
|
|
||||||
col++;
|
|
||||||
}
|
|
||||||
|
|
||||||
absMatrix.push(p);
|
|
||||||
photoId++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return absMatrix;
|
// Use this slot
|
||||||
|
matrix[row][col] |= FLAG_USE;
|
||||||
|
|
||||||
|
// Check if previous row has something used
|
||||||
|
// or something beside this is used
|
||||||
|
// We don't do these one after another
|
||||||
|
if (
|
||||||
|
!opts.allowBreakout ||
|
||||||
|
(row > 0 && matrix[row - 1].some((v) => v & FLAG_USED)) ||
|
||||||
|
(col > 0 && matrix[row][col - 1] & FLAG_USED)
|
||||||
|
) {
|
||||||
|
photoId++;
|
||||||
|
col++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number of photos left
|
||||||
|
const numLeft = input.length - photoId - 1;
|
||||||
|
// Number of photos needed for perfect fill after using n
|
||||||
|
const needFill = (n: number) =>
|
||||||
|
opts.numCols - col - 2 + (n / 2 - 1) * (opts.numCols - 2);
|
||||||
|
|
||||||
|
let canUse4 =
|
||||||
|
// We have enough space
|
||||||
|
row + 1 < matrix.length &&
|
||||||
|
col + 1 < opts.numCols &&
|
||||||
|
// This cannot end up being a widow (conservative)
|
||||||
|
// Also make sure the next row gets fully filled, otherwise looks weird
|
||||||
|
(numLeft === needFill(4) || numLeft >= needFill(4) + opts.numCols);
|
||||||
|
|
||||||
|
let canUse6 =
|
||||||
|
// Image is portrait
|
||||||
|
input[photoId].height > input[photoId].width &&
|
||||||
|
// We have enough space
|
||||||
|
row + 2 < matrix.length &&
|
||||||
|
col + 1 < opts.numCols &&
|
||||||
|
// This cannot end up being a widow (conservative)
|
||||||
|
// Also make sure the next row gets fully filled, otherwise looks weird
|
||||||
|
(numLeft === needFill(6) || numLeft >= needFill(6) + 2 * opts.numCols);
|
||||||
|
|
||||||
|
let canBreakout =
|
||||||
|
// First column only
|
||||||
|
col === 0 &&
|
||||||
|
// Image is landscape
|
||||||
|
input[photoId].width > input[photoId].height &&
|
||||||
|
// The next row gets filled
|
||||||
|
(numLeft === 0 || numLeft >= opts.numCols);
|
||||||
|
|
||||||
|
// Probably folders or tags or faces
|
||||||
|
if (input[photoId].forceSquare) {
|
||||||
|
// We are square already. Everything below is else-if.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full width breakout
|
||||||
|
else if (canBreakout && rand() < (input.length > 0 ? 0.25 : 0.1)) {
|
||||||
|
matrix[row][col] |= FLAG_BREAKOUT;
|
||||||
|
for (let i = 1; i < opts.numCols; i++) {
|
||||||
|
matrix[row][i] |= FLAG_USED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use 6 vertically
|
||||||
|
else if (canUse6 && rand() < 0.2) {
|
||||||
|
matrix[row][col] |= FLAG_USE6;
|
||||||
|
matrix[row + 1][col] |= FLAG_USED;
|
||||||
|
matrix[row + 2][col] |= FLAG_USED;
|
||||||
|
matrix[row][col + 1] |= FLAG_USED;
|
||||||
|
matrix[row + 1][col + 1] |= FLAG_USED;
|
||||||
|
matrix[row + 2][col + 1] |= FLAG_USED;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use 4 box
|
||||||
|
else if (canUse4 && rand() < 0.35) {
|
||||||
|
matrix[row][col] |= FLAG_USE4;
|
||||||
|
matrix[row + 1][col] |= FLAG_USED;
|
||||||
|
matrix[row][col + 1] |= FLAG_USED;
|
||||||
|
matrix[row + 1][col + 1] |= FLAG_USED;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go ahead
|
||||||
|
photoId++;
|
||||||
|
col++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Square layout matrix
|
||||||
|
const absMatrix: {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
let currTop = 0;
|
||||||
|
row = 0;
|
||||||
|
col = 0;
|
||||||
|
photoId = 0;
|
||||||
|
while (photoId < input.length) {
|
||||||
|
// Check if we reached the end of row
|
||||||
|
if (col >= opts.numCols) {
|
||||||
|
row++;
|
||||||
|
col = 0;
|
||||||
|
currTop += opts.rowHeight;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if used
|
||||||
|
if (!(matrix[row][col] & FLAG_USE)) {
|
||||||
|
col++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create basic object
|
||||||
|
const sqsize = opts.rowHeight;
|
||||||
|
const p = {
|
||||||
|
top: currTop,
|
||||||
|
left: col * sqsize,
|
||||||
|
width: sqsize,
|
||||||
|
height: sqsize,
|
||||||
|
rowHeight: opts.rowHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use twice the space
|
||||||
|
const v = matrix[row][col];
|
||||||
|
if (v & FLAG_USE4) {
|
||||||
|
p.width *= 2;
|
||||||
|
p.height *= 2;
|
||||||
|
col += 2;
|
||||||
|
} else if (v & FLAG_USE6) {
|
||||||
|
p.width *= 2;
|
||||||
|
p.height *= 3;
|
||||||
|
col += 2;
|
||||||
|
} else if (v & FLAG_BREAKOUT) {
|
||||||
|
p.width *= opts.numCols;
|
||||||
|
p.height = (input[photoId].height * p.width) / input[photoId].width;
|
||||||
|
p.rowHeight = p.height;
|
||||||
|
col += opts.numCols;
|
||||||
|
} else {
|
||||||
|
col++;
|
||||||
|
}
|
||||||
|
|
||||||
|
absMatrix.push(p);
|
||||||
|
photoId++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return absMatrix;
|
||||||
}
|
}
|
||||||
|
|
||||||
function flagMatrixStr(matrix: number[][], numFlag: number) {
|
function flagMatrixStr(matrix: number[][], numFlag: number) {
|
||||||
let str = '';
|
let str = "";
|
||||||
for (let i = 0; i < matrix.length; i++) {
|
for (let i = 0; i < matrix.length; i++) {
|
||||||
const rstr = matrix[i].map(v => v.toString(2).padStart(numFlag, '0')).join(' ');
|
const rstr = matrix[i]
|
||||||
str += i.toString().padStart(2) + ' | ' + rstr + '\n';
|
.map((v) => v.toString(2).padStart(numFlag, "0"))
|
||||||
}
|
.join(" ");
|
||||||
return str;
|
str += i.toString().padStart(2) + " | " + rstr + "\n";
|
||||||
|
}
|
||||||
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mulberry32(a: number) {
|
function mulberry32(a: number) {
|
||||||
return function() {
|
return function () {
|
||||||
var t = a += 0x6D2B79F5;
|
var t = (a += 0x6d2b79f5);
|
||||||
t = Math.imul(t ^ t >>> 15, t | 1);
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,11 +20,11 @@
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const isNumber = function(num: any) {
|
const isNumber = function (num: any) {
|
||||||
if (!num) {
|
if (!num) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
return Number(num).toString() === num.toString()
|
return Number(num).toString() === num.toString();
|
||||||
}
|
};
|
||||||
|
|
||||||
export { isNumber }
|
export { isNumber };
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { getCanonicalLocale } from "@nextcloud/l10n";
|
import { getCanonicalLocale } from "@nextcloud/l10n";
|
||||||
import { getCurrentUser } from '@nextcloud/auth'
|
import { getCurrentUser } from "@nextcloud/auth";
|
||||||
import { loadState } from '@nextcloud/initial-state'
|
import { loadState } from "@nextcloud/initial-state";
|
||||||
import { IPhoto } from "../types";
|
import { IPhoto } from "../types";
|
||||||
import moment from 'moment';
|
import moment from "moment";
|
||||||
|
|
||||||
// Memoize the result of short date conversions
|
// Memoize the result of short date conversions
|
||||||
// These operations are surprisingly expensive
|
// These operations are surprisingly expensive
|
||||||
|
@ -10,49 +10,54 @@ import moment from 'moment';
|
||||||
const shortDateStrMemo = new Map<number, string>();
|
const shortDateStrMemo = new Map<number, string>();
|
||||||
|
|
||||||
/** Get JS date object from dayId */
|
/** Get JS date object from dayId */
|
||||||
export function dayIdToDate(dayId: number){
|
export function dayIdToDate(dayId: number) {
|
||||||
return new Date(dayId*86400*1000);
|
return new Date(dayId * 86400 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get Day ID from JS date */
|
/** Get Day ID from JS date */
|
||||||
export function dateToDayId(date: Date){
|
export function dateToDayId(date: Date) {
|
||||||
return Math.floor(date.getTime() / (86400*1000));
|
return Math.floor(date.getTime() / (86400 * 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get month name from number */
|
/** Get month name from number */
|
||||||
export function getShortDateStr(date: Date) {
|
export function getShortDateStr(date: Date) {
|
||||||
const dayId = dateToDayId(date);
|
const dayId = dateToDayId(date);
|
||||||
if (!shortDateStrMemo.has(dayId)) {
|
if (!shortDateStrMemo.has(dayId)) {
|
||||||
shortDateStrMemo.set(dayId,
|
shortDateStrMemo.set(
|
||||||
date.toLocaleDateString(getCanonicalLocale(), {
|
dayId,
|
||||||
month: 'short',
|
date.toLocaleDateString(getCanonicalLocale(), {
|
||||||
year: 'numeric',
|
month: "short",
|
||||||
timeZone: 'UTC',
|
year: "numeric",
|
||||||
}));
|
timeZone: "UTC",
|
||||||
}
|
})
|
||||||
return shortDateStrMemo.get(dayId);
|
);
|
||||||
|
}
|
||||||
|
return shortDateStrMemo.get(dayId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get long date string with optional year if same as current */
|
/** Get long date string with optional year if same as current */
|
||||||
export function getLongDateStr(date: Date, skipYear=false, time=false) {
|
export function getLongDateStr(date: Date, skipYear = false, time = false) {
|
||||||
return date.toLocaleDateString(getCanonicalLocale(), {
|
return date.toLocaleDateString(getCanonicalLocale(), {
|
||||||
weekday: 'short',
|
weekday: "short",
|
||||||
month: 'short',
|
month: "short",
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
year: (skipYear && date.getUTCFullYear() === new Date().getUTCFullYear()) ? undefined : 'numeric',
|
year:
|
||||||
timeZone: 'UTC',
|
skipYear && date.getUTCFullYear() === new Date().getUTCFullYear()
|
||||||
hour: time ? 'numeric' : undefined,
|
? undefined
|
||||||
minute: time ? 'numeric' : undefined,
|
: "numeric",
|
||||||
});
|
timeZone: "UTC",
|
||||||
|
hour: time ? "numeric" : undefined,
|
||||||
|
minute: time ? "numeric" : undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get text like "5 years ago" from a date */
|
/** Get text like "5 years ago" from a date */
|
||||||
export function getFromNowStr(date: Date) {
|
export function getFromNowStr(date: Date) {
|
||||||
// Get fromNow in correct locale
|
// Get fromNow in correct locale
|
||||||
const text = moment(date).locale(getCanonicalLocale()).fromNow();
|
const text = moment(date).locale(getCanonicalLocale()).fromNow();
|
||||||
|
|
||||||
// Title case
|
// Title case
|
||||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -62,13 +67,13 @@ export function getFromNowStr(date: Date) {
|
||||||
* @see http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/
|
* @see http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/
|
||||||
*/
|
*/
|
||||||
export function hashCode(str: string): number {
|
export function hashCode(str: string): number {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0, len = str.length; i < len; i++) {
|
for (let i = 0, len = str.length; i < len; i++) {
|
||||||
let chr = str.charCodeAt(i);
|
let chr = str.charCodeAt(i);
|
||||||
hash = (hash << 5) - hash + chr;
|
hash = (hash << 5) - hash + chr;
|
||||||
hash |= 0; // Convert to 32bit integer
|
hash |= 0; // Convert to 32bit integer
|
||||||
}
|
}
|
||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -80,27 +85,25 @@ export function hashCode(str: string): number {
|
||||||
* @param key Key to use for comparison
|
* @param key Key to use for comparison
|
||||||
*/
|
*/
|
||||||
export function binarySearch(arr: any, elem: any, key?: string) {
|
export function binarySearch(arr: any, elem: any, key?: string) {
|
||||||
let minIndex = 0;
|
let minIndex = 0;
|
||||||
let maxIndex = arr.length - 1;
|
let maxIndex = arr.length - 1;
|
||||||
let currentIndex: number;
|
let currentIndex: number;
|
||||||
let currentElement: any;
|
let currentElement: any;
|
||||||
|
|
||||||
while (minIndex <= maxIndex) {
|
while (minIndex <= maxIndex) {
|
||||||
currentIndex = (minIndex + maxIndex) / 2 | 0;
|
currentIndex = ((minIndex + maxIndex) / 2) | 0;
|
||||||
currentElement = key ? arr[currentIndex][key] : arr[currentIndex];
|
currentElement = key ? arr[currentIndex][key] : arr[currentIndex];
|
||||||
|
|
||||||
if (currentElement < elem) {
|
if (currentElement < elem) {
|
||||||
minIndex = currentIndex + 1;
|
minIndex = currentIndex + 1;
|
||||||
}
|
} else if (currentElement > elem) {
|
||||||
else if (currentElement > elem) {
|
maxIndex = currentIndex - 1;
|
||||||
maxIndex = currentIndex - 1;
|
} else {
|
||||||
}
|
return currentIndex;
|
||||||
else {
|
|
||||||
return currentIndex;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return minIndex;
|
return minIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -109,10 +112,10 @@ export function binarySearch(arr: any, elem: any, key?: string) {
|
||||||
* @param places Number of decimal places
|
* @param places Number of decimal places
|
||||||
* @param floor If true, round down instead of to nearest
|
* @param floor If true, round down instead of to nearest
|
||||||
*/
|
*/
|
||||||
export function round(num: number, places: number, floor=false) {
|
export function round(num: number, places: number, floor = false) {
|
||||||
const pow = Math.pow(10, places);
|
const pow = Math.pow(10, places);
|
||||||
const int = num * pow;
|
const int = num * pow;
|
||||||
return (floor ? Math.floor : Math.round)(int) / pow;
|
return (floor ? Math.floor : Math.round)(int) / pow;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -120,12 +123,12 @@ export function round(num: number, places: number, floor=false) {
|
||||||
* @param num Number to round
|
* @param num Number to round
|
||||||
*/
|
*/
|
||||||
export function roundHalf(num: number) {
|
export function roundHalf(num: number) {
|
||||||
return Math.round(num * 2) / 2;
|
return Math.round(num * 2) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Choose a random element from an array */
|
/** Choose a random element from an array */
|
||||||
export function randomChoice(arr: any[]) {
|
export function randomChoice(arr: any[]) {
|
||||||
return arr[Math.floor(Math.random() * arr.length)];
|
return arr[Math.floor(Math.random() * arr.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -133,15 +136,19 @@ export function randomChoice(arr: any[]) {
|
||||||
* https://stackoverflow.com/a/11935263/4745239
|
* https://stackoverflow.com/a/11935263/4745239
|
||||||
*/
|
*/
|
||||||
export function randomSubarray(arr: any[], size: number) {
|
export function randomSubarray(arr: any[], size: number) {
|
||||||
if (arr.length <= size) return arr;
|
if (arr.length <= size) return arr;
|
||||||
var shuffled = arr.slice(0), i = arr.length, min = i - size, temp, index;
|
var shuffled = arr.slice(0),
|
||||||
while (i-- > min) {
|
i = arr.length,
|
||||||
index = Math.floor((i + 1) * Math.random());
|
min = i - size,
|
||||||
temp = shuffled[index];
|
temp,
|
||||||
shuffled[index] = shuffled[i];
|
index;
|
||||||
shuffled[i] = temp;
|
while (i-- > min) {
|
||||||
}
|
index = Math.floor((i + 1) * Math.random());
|
||||||
return shuffled.slice(min);
|
temp = shuffled[index];
|
||||||
|
shuffled[index] = shuffled[i];
|
||||||
|
shuffled[i] = temp;
|
||||||
|
}
|
||||||
|
return shuffled.slice(min);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -149,108 +156,114 @@ export function randomSubarray(arr: any[], size: number) {
|
||||||
* @param photo Photo to process
|
* @param photo Photo to process
|
||||||
*/
|
*/
|
||||||
export function convertFlags(photo: IPhoto) {
|
export function convertFlags(photo: IPhoto) {
|
||||||
if (typeof photo.flag === "undefined") {
|
if (typeof photo.flag === "undefined") {
|
||||||
photo.flag = 0; // flags
|
photo.flag = 0; // flags
|
||||||
}
|
}
|
||||||
|
|
||||||
if (photo.isvideo) {
|
if (photo.isvideo) {
|
||||||
photo.flag |= constants.c.FLAG_IS_VIDEO;
|
photo.flag |= constants.c.FLAG_IS_VIDEO;
|
||||||
delete photo.isvideo;
|
delete photo.isvideo;
|
||||||
}
|
}
|
||||||
if (photo.isfavorite) {
|
if (photo.isfavorite) {
|
||||||
photo.flag |= constants.c.FLAG_IS_FAVORITE;
|
photo.flag |= constants.c.FLAG_IS_FAVORITE;
|
||||||
delete photo.isfavorite;
|
delete photo.isfavorite;
|
||||||
}
|
}
|
||||||
if (photo.isfolder) {
|
if (photo.isfolder) {
|
||||||
photo.flag |= constants.c.FLAG_IS_FOLDER;
|
photo.flag |= constants.c.FLAG_IS_FOLDER;
|
||||||
delete photo.isfolder;
|
delete photo.isfolder;
|
||||||
}
|
}
|
||||||
if (photo.isface) {
|
if (photo.isface) {
|
||||||
photo.flag |= constants.c.FLAG_IS_FACE;
|
photo.flag |= constants.c.FLAG_IS_FACE;
|
||||||
delete photo.isface;
|
delete photo.isface;
|
||||||
}
|
}
|
||||||
if (photo.istag) {
|
if (photo.istag) {
|
||||||
photo.flag |= constants.c.FLAG_IS_TAG;
|
photo.flag |= constants.c.FLAG_IS_TAG;
|
||||||
delete photo.istag;
|
delete photo.istag;
|
||||||
}
|
}
|
||||||
if (photo.isalbum) {
|
if (photo.isalbum) {
|
||||||
photo.flag |= constants.c.FLAG_IS_ALBUM;
|
photo.flag |= constants.c.FLAG_IS_ALBUM;
|
||||||
delete photo.isalbum;
|
delete photo.isalbum;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outside for set
|
// Outside for set
|
||||||
const TagDayID = {
|
const TagDayID = {
|
||||||
START: -(1 << 30),
|
START: -(1 << 30),
|
||||||
FOLDERS: -(1 << 30) + 1,
|
FOLDERS: -(1 << 30) + 1,
|
||||||
TAGS: -(1 << 30) + 2,
|
TAGS: -(1 << 30) + 2,
|
||||||
FACES: -(1 << 30) + 3,
|
FACES: -(1 << 30) + 3,
|
||||||
ALBUMS: -(1 << 30) + 4,
|
ALBUMS: -(1 << 30) + 4,
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Global constants */
|
/** Global constants */
|
||||||
export const constants = {
|
export const constants = {
|
||||||
c: {
|
c: {
|
||||||
FLAG_PLACEHOLDER: 1 << 0,
|
FLAG_PLACEHOLDER: 1 << 0,
|
||||||
FLAG_LOAD_FAIL: 1 << 1,
|
FLAG_LOAD_FAIL: 1 << 1,
|
||||||
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_TAG: 1 << 5,
|
FLAG_IS_TAG: 1 << 5,
|
||||||
FLAG_IS_FACE: 1 << 6,
|
FLAG_IS_FACE: 1 << 6,
|
||||||
FLAG_IS_ALBUM: 1 << 7,
|
FLAG_IS_ALBUM: 1 << 7,
|
||||||
FLAG_SELECTED: 1 << 8,
|
FLAG_SELECTED: 1 << 8,
|
||||||
FLAG_LEAVING: 1 << 9,
|
FLAG_LEAVING: 1 << 9,
|
||||||
},
|
},
|
||||||
|
|
||||||
TagDayID: TagDayID,
|
TagDayID: TagDayID,
|
||||||
TagDayIDValueSet: new Set(Object.values(TagDayID)),
|
TagDayIDValueSet: new Set(Object.values(TagDayID)),
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Cache store */
|
/** Cache store */
|
||||||
let staticCache: Cache | null = null;
|
let staticCache: Cache | null = null;
|
||||||
const cacheName = `memories-${loadState('memories', 'version')}-${getCurrentUser()!.uid}`;
|
const cacheName = `memories-${loadState("memories", "version")}-${
|
||||||
openCache().then((cache) => { staticCache = cache });
|
getCurrentUser()!.uid
|
||||||
|
}`;
|
||||||
|
openCache().then((cache) => {
|
||||||
|
staticCache = cache;
|
||||||
|
});
|
||||||
|
|
||||||
// Clear all caches except the current one
|
// Clear all caches except the current one
|
||||||
window.caches?.keys().then((keys) => {
|
window.caches?.keys().then((keys) => {
|
||||||
keys.filter((key) => key.startsWith('memories-') && key !== cacheName).forEach((key) => {
|
keys
|
||||||
window.caches.delete(key);
|
.filter((key) => key.startsWith("memories-") && key !== cacheName)
|
||||||
|
.forEach((key) => {
|
||||||
|
window.caches.delete(key);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Open the cache */
|
/** Open the cache */
|
||||||
export async function openCache() {
|
export async function openCache() {
|
||||||
try {
|
try {
|
||||||
return await window.caches?.open(cacheName);
|
return await window.caches?.open(cacheName);
|
||||||
} catch {
|
} catch {
|
||||||
console.warn('Failed to get cache', cacheName);
|
console.warn("Failed to get cache", cacheName);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get data from the cache */
|
/** Get data from the cache */
|
||||||
export async function getCachedData<T>(url: string): Promise<T> {
|
export async function getCachedData<T>(url: string): Promise<T> {
|
||||||
if (!window.caches) return null;
|
if (!window.caches) return null;
|
||||||
const cache = staticCache || await openCache();
|
const cache = staticCache || (await openCache());
|
||||||
if (!cache) return null;
|
if (!cache) return null;
|
||||||
|
|
||||||
const cachedResponse = await cache.match(url);
|
const cachedResponse = await cache.match(url);
|
||||||
if (!cachedResponse || !cachedResponse.ok) return undefined;
|
if (!cachedResponse || !cachedResponse.ok) return undefined;
|
||||||
return await cachedResponse.json();
|
return await cachedResponse.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Store data in the cache */
|
/** Store data in the cache */
|
||||||
export function cacheData(url: string, data: Object) {
|
export function cacheData(url: string, data: Object) {
|
||||||
if (!window.caches) return;
|
if (!window.caches) return;
|
||||||
const str = JSON.stringify(data);
|
const str = JSON.stringify(data);
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const cache = staticCache || await openCache();
|
const cache = staticCache || (await openCache());
|
||||||
if (!cache) return;
|
if (!cache) return;
|
||||||
|
|
||||||
const response = new Response(str);
|
const response = new Response(str);
|
||||||
response.headers.set('Content-Type', 'application/json');
|
response.headers.set("Content-Type", "application/json");
|
||||||
await cache.put(url, response);
|
await cache.put(url, response);
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,87 +1,88 @@
|
||||||
import { IFileInfo, IPhoto } from "../types";
|
import { IFileInfo, IPhoto } from "../types";
|
||||||
import { showError } from '@nextcloud/dialogs'
|
import { showError } from "@nextcloud/dialogs";
|
||||||
import { subscribe } from '@nextcloud/event-bus';
|
import { subscribe } from "@nextcloud/event-bus";
|
||||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
|
||||||
import * as dav from "./DavRequests";
|
import * as dav from "./DavRequests";
|
||||||
|
|
||||||
// Key to store sidebar state
|
// Key to store sidebar state
|
||||||
const SIDEBAR_KEY = 'memories:sidebar-open';
|
const SIDEBAR_KEY = "memories:sidebar-open";
|
||||||
|
|
||||||
export class ViewerManager {
|
export class ViewerManager {
|
||||||
/** Map from fileid to Photo */
|
/** Map from fileid to Photo */
|
||||||
private photoMap = new Map<number, IPhoto>();
|
private photoMap = new Map<number, IPhoto>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
ondelete: (photos: IPhoto[]) => void,
|
ondelete: (photos: IPhoto[]) => void,
|
||||||
private updateLoading: (delta: number) => void,
|
private updateLoading: (delta: number) => void
|
||||||
) {
|
) {
|
||||||
subscribe('files:file:deleted', ({ fileid }: { fileid: number }) => {
|
subscribe("files:file:deleted", ({ fileid }: { fileid: number }) => {
|
||||||
const photo = this.photoMap.get(fileid);
|
const photo = this.photoMap.get(fileid);
|
||||||
ondelete([photo]);
|
ondelete([photo]);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async open(photo: IPhoto, list?: IPhoto[]) {
|
||||||
|
list = list || photo.d?.detail;
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
// Repopulate map
|
||||||
|
this.photoMap.clear();
|
||||||
|
for (const p of list) {
|
||||||
|
this.photoMap.set(p.fileid, p);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async open(photo: IPhoto, list?: IPhoto[]) {
|
// Get file infos
|
||||||
list = list || photo.d?.detail;
|
let fileInfos: IFileInfo[];
|
||||||
if (!list) return;
|
const ids = list.map((p) => p.fileid);
|
||||||
|
try {
|
||||||
// Repopulate map
|
this.updateLoading(1);
|
||||||
this.photoMap.clear();
|
fileInfos = await dav.getFiles(ids);
|
||||||
for (const p of list) {
|
} catch (e) {
|
||||||
this.photoMap.set(p.fileid, p);
|
console.error("Failed to load fileInfos", e);
|
||||||
}
|
showError("Failed to load fileInfos");
|
||||||
|
return;
|
||||||
// Get file infos
|
} finally {
|
||||||
let fileInfos: IFileInfo[];
|
this.updateLoading(-1);
|
||||||
const ids = list.map(p => p.fileid);
|
|
||||||
try {
|
|
||||||
this.updateLoading(1);
|
|
||||||
fileInfos = await dav.getFiles(ids);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load fileInfos', e);
|
|
||||||
showError('Failed to load fileInfos');
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
this.updateLoading(-1);
|
|
||||||
}
|
|
||||||
if (fileInfos.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix sorting of the fileInfos
|
|
||||||
const itemPositions = {};
|
|
||||||
for (const [index, id] of ids.entries()) {
|
|
||||||
itemPositions[id] = index;
|
|
||||||
}
|
|
||||||
fileInfos.sort(function (a, b) {
|
|
||||||
return itemPositions[a.fileid] - itemPositions[b.fileid];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get this photo in the fileInfos
|
|
||||||
const fInfo = fileInfos.find(d => Number(d.fileid) === photo.fileid);
|
|
||||||
if (!fInfo) {
|
|
||||||
showError(t('memories', 'Cannot find this photo anymore!'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open Nextcloud viewer
|
|
||||||
globalThis.OCA.Viewer.open({
|
|
||||||
path: fInfo.filename, // path
|
|
||||||
list: fileInfos, // file list
|
|
||||||
canLoop: false, // don't loop
|
|
||||||
onClose: () => { // on viewer close
|
|
||||||
if (globalThis.OCA.Files.Sidebar.file) {
|
|
||||||
localStorage.setItem(SIDEBAR_KEY, '1');
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(SIDEBAR_KEY);
|
|
||||||
}
|
|
||||||
globalThis.OCA.Files.Sidebar.close();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore sidebar state
|
|
||||||
if (localStorage.getItem(SIDEBAR_KEY) === '1') {
|
|
||||||
globalThis.OCA.Files.Sidebar.open(fInfo.filename);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
if (fileInfos.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix sorting of the fileInfos
|
||||||
|
const itemPositions = {};
|
||||||
|
for (const [index, id] of ids.entries()) {
|
||||||
|
itemPositions[id] = index;
|
||||||
|
}
|
||||||
|
fileInfos.sort(function (a, b) {
|
||||||
|
return itemPositions[a.fileid] - itemPositions[b.fileid];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get this photo in the fileInfos
|
||||||
|
const fInfo = fileInfos.find((d) => Number(d.fileid) === photo.fileid);
|
||||||
|
if (!fInfo) {
|
||||||
|
showError(t("memories", "Cannot find this photo anymore!"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open Nextcloud viewer
|
||||||
|
globalThis.OCA.Viewer.open({
|
||||||
|
path: fInfo.filename, // path
|
||||||
|
list: fileInfos, // file list
|
||||||
|
canLoop: false, // don't loop
|
||||||
|
onClose: () => {
|
||||||
|
// on viewer close
|
||||||
|
if (globalThis.OCA.Files.Sidebar.file) {
|
||||||
|
localStorage.setItem(SIDEBAR_KEY, "1");
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(SIDEBAR_KEY);
|
||||||
|
}
|
||||||
|
globalThis.OCA.Files.Sidebar.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore sidebar state
|
||||||
|
if (localStorage.getItem(SIDEBAR_KEY) === "1") {
|
||||||
|
globalThis.OCA.Files.Sidebar.open(fInfo.filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,51 +1,58 @@
|
||||||
import * as base from "./base";
|
import * as base from "./base";
|
||||||
import { getCurrentUser } from '@nextcloud/auth'
|
import { getCurrentUser } from "@nextcloud/auth";
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from "@nextcloud/router";
|
||||||
import { showError } from '@nextcloud/dialogs'
|
import { showError } from "@nextcloud/dialogs";
|
||||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
|
||||||
import { IAlbum, IDay, ITag } from '../../types';
|
import { IAlbum, IDay, ITag } from "../../types";
|
||||||
import { constants } from '../Utils';
|
import { constants } from "../Utils";
|
||||||
import axios from '@nextcloud/axios'
|
import axios from "@nextcloud/axios";
|
||||||
import client from '../DavClient';
|
import client from "../DavClient";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get DAV path for album
|
* Get DAV path for album
|
||||||
*/
|
*/
|
||||||
export function getAlbumPath(user: string, name: string) {
|
export function getAlbumPath(user: string, name: string) {
|
||||||
// Folder in the dav collection for user
|
// Folder in the dav collection for user
|
||||||
const cuid = getCurrentUser().uid;
|
const cuid = getCurrentUser().uid;
|
||||||
if (user === cuid) {
|
if (user === cuid) {
|
||||||
return `/photos/${cuid}/albums/${name}`;
|
return `/photos/${cuid}/albums/${name}`;
|
||||||
} else {
|
} else {
|
||||||
return `/photos/${cuid}/sharedalbums/${name} (${user})`;
|
return `/photos/${cuid}/sharedalbums/${name} (${user})`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of albums and convert to Days response
|
* Get list of albums and convert to Days response
|
||||||
* @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
|
||||||
*/
|
*/
|
||||||
export async function getAlbumsData(type: '1' | '2' | '3'): Promise<IDay[]> {
|
export async function getAlbumsData(type: "1" | "2" | "3"): Promise<IDay[]> {
|
||||||
let data: IAlbum[] = [];
|
let data: IAlbum[] = [];
|
||||||
try {
|
try {
|
||||||
const res = await axios.get<typeof data>(generateUrl(`/apps/memories/api/albums?t=${type}`));
|
const res = await axios.get<typeof data>(
|
||||||
data = res.data;
|
generateUrl(`/apps/memories/api/albums?t=${type}`)
|
||||||
} catch (e) {
|
);
|
||||||
throw e;
|
data = res.data;
|
||||||
}
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to days response
|
// Convert to days response
|
||||||
return [{
|
return [
|
||||||
dayid: constants.TagDayID.ALBUMS,
|
{
|
||||||
count: data.length,
|
dayid: constants.TagDayID.ALBUMS,
|
||||||
detail: data.map((album) => ({
|
count: data.length,
|
||||||
|
detail: data.map(
|
||||||
|
(album) =>
|
||||||
|
({
|
||||||
...album,
|
...album,
|
||||||
fileid: album.album_id,
|
fileid: album.album_id,
|
||||||
flag: constants.c.FLAG_IS_TAG & constants.c.FLAG_IS_ALBUM,
|
flag: constants.c.FLAG_IS_TAG & constants.c.FLAG_IS_ALBUM,
|
||||||
istag: true,
|
istag: true,
|
||||||
isalbum: true,
|
isalbum: true,
|
||||||
} as ITag)),
|
} as ITag)
|
||||||
}]
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -56,34 +63,37 @@ export async function getAlbumsData(type: '1' | '2' | '3'): Promise<IDay[]> {
|
||||||
* @param fileIds List of file IDs to add
|
* @param fileIds List of file IDs to add
|
||||||
* @returns Generator
|
* @returns Generator
|
||||||
*/
|
*/
|
||||||
export async function* addToAlbum(user: string, name: string, fileIds: number[]) {
|
export async function* addToAlbum(
|
||||||
// Get files data
|
user: string,
|
||||||
let fileInfos = await base.getFiles(fileIds.filter(f => f));
|
name: string,
|
||||||
|
fileIds: number[]
|
||||||
|
) {
|
||||||
|
// Get files data
|
||||||
|
let fileInfos = await base.getFiles(fileIds.filter((f) => f));
|
||||||
|
|
||||||
const albumPath = getAlbumPath(user, name);
|
const albumPath = getAlbumPath(user, name);
|
||||||
|
|
||||||
// Add each file
|
// Add each file
|
||||||
const calls = fileInfos.map((f) => async () => {
|
const calls = fileInfos.map((f) => async () => {
|
||||||
try {
|
try {
|
||||||
await client.copyFile(
|
await client.copyFile(f.originalFilename, `${albumPath}/${f.basename}`);
|
||||||
f.originalFilename,
|
return f.fileid;
|
||||||
`${albumPath}/${f.basename}`,
|
} catch (e) {
|
||||||
)
|
if (e.response?.status === 409) {
|
||||||
return f.fileid;
|
// File already exists, all good
|
||||||
} catch (e) {
|
return f.fileid;
|
||||||
if (e.response?.status === 409) {
|
}
|
||||||
// File already exists, all good
|
|
||||||
return f.fileid;
|
|
||||||
}
|
|
||||||
|
|
||||||
showError(t('memories', 'Failed to add {filename} to album.', {
|
showError(
|
||||||
filename: f.filename,
|
t("memories", "Failed to add {filename} to album.", {
|
||||||
}));
|
filename: f.filename,
|
||||||
return 0;
|
})
|
||||||
}
|
);
|
||||||
});
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
yield* base.runInParallel(calls, 10);
|
yield* base.runInParallel(calls, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -94,38 +104,46 @@ export async function* addToAlbum(user: string, name: string, fileIds: number[])
|
||||||
* @param fileIds List of file IDs to remove
|
* @param fileIds List of file IDs to remove
|
||||||
* @returns Generator
|
* @returns Generator
|
||||||
*/
|
*/
|
||||||
export async function* removeFromAlbum(user: string, name: string, fileIds: number[]) {
|
export async function* removeFromAlbum(
|
||||||
// Get files data
|
user: string,
|
||||||
let fileInfos = await base.getFiles(fileIds.filter(f => f));
|
name: string,
|
||||||
|
fileIds: number[]
|
||||||
|
) {
|
||||||
|
// Get files data
|
||||||
|
let fileInfos = await base.getFiles(fileIds.filter((f) => f));
|
||||||
|
|
||||||
// Add each file
|
// Add each file
|
||||||
const calls = fileInfos.map((f) => async () => {
|
const calls = fileInfos.map((f) => async () => {
|
||||||
try {
|
try {
|
||||||
await client.deleteFile(
|
await client.deleteFile(
|
||||||
`/photos/${user}/albums/${name}/${f.fileid}-${f.basename}`,
|
`/photos/${user}/albums/${name}/${f.fileid}-${f.basename}`
|
||||||
)
|
);
|
||||||
return f.fileid;
|
return f.fileid;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(t('memories', 'Failed to remove {filename}.', {
|
showError(
|
||||||
filename: f.filename,
|
t("memories", "Failed to remove {filename}.", {
|
||||||
}));
|
filename: f.filename,
|
||||||
return 0;
|
})
|
||||||
}
|
);
|
||||||
});
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
yield* base.runInParallel(calls, 10);
|
yield* base.runInParallel(calls, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an album.
|
* Create an album.
|
||||||
*/
|
*/
|
||||||
export async function createAlbum(albumName: string) {
|
export async function createAlbum(albumName: string) {
|
||||||
try {
|
try {
|
||||||
await client.createDirectory(`/photos/${getCurrentUser()?.uid}/albums/${albumName}`)
|
await client.createDirectory(
|
||||||
} catch (error) {
|
`/photos/${getCurrentUser()?.uid}/albums/${albumName}`
|
||||||
console.error(error);
|
);
|
||||||
showError(t('photos', 'Failed to create {albumName}.', { albumName }))
|
} catch (error) {
|
||||||
}
|
console.error(error);
|
||||||
|
showError(t("photos", "Failed to create {albumName}.", { albumName }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -137,26 +155,23 @@ export async function createAlbum(albumName: string) {
|
||||||
* @param {object} data.properties - The properties to update.
|
* @param {object} data.properties - The properties to update.
|
||||||
*/
|
*/
|
||||||
export async function updateAlbum(album: any, { albumName, properties }: any) {
|
export async function updateAlbum(album: any, { albumName, properties }: any) {
|
||||||
const stringifiedProperties = Object
|
const stringifiedProperties = Object.entries(properties)
|
||||||
.entries(properties)
|
.map(([name, value]) => {
|
||||||
.map(([name, value]) => {
|
switch (typeof value) {
|
||||||
switch (typeof value) {
|
case "string":
|
||||||
case 'string':
|
return `<nc:${name}>${value}</nc:${name}>`;
|
||||||
return `<nc:${name}>${value}</nc:${name}>`
|
case "object":
|
||||||
case 'object':
|
return `<nc:${name}>${JSON.stringify(value)}</nc:${name}>`;
|
||||||
return `<nc:${name}>${JSON.stringify(value)}</nc:${name}>`
|
default:
|
||||||
default:
|
return "";
|
||||||
return ''
|
}
|
||||||
}
|
})
|
||||||
})
|
.join();
|
||||||
.join()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.customRequest(
|
await client.customRequest(album.filename, {
|
||||||
album.filename,
|
method: "PROPPATCH",
|
||||||
{
|
data: `<?xml version="1.0"?>
|
||||||
method: 'PROPPATCH',
|
|
||||||
data: `<?xml version="1.0"?>
|
|
||||||
<d:propertyupdate xmlns:d="DAV:"
|
<d:propertyupdate xmlns:d="DAV:"
|
||||||
xmlns:oc="http://owncloud.org/ns"
|
xmlns:oc="http://owncloud.org/ns"
|
||||||
xmlns:nc="http://nextcloud.org/ns"
|
xmlns:nc="http://nextcloud.org/ns"
|
||||||
|
@ -167,15 +182,20 @@ export async function updateAlbum(album: any, { albumName, properties }: any) {
|
||||||
</d:prop>
|
</d:prop>
|
||||||
</d:set>
|
</d:set>
|
||||||
</d:propertyupdate>`,
|
</d:propertyupdate>`,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return album;
|
return album;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
showError(t('photos', 'Failed to update properties of {albumName} with {properties}.', { albumName, properties: JSON.stringify(properties) }))
|
showError(
|
||||||
return album
|
t(
|
||||||
}
|
"photos",
|
||||||
|
"Failed to update properties of {albumName} with {properties}.",
|
||||||
|
{ albumName, properties: JSON.stringify(properties) }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return album;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -184,7 +204,7 @@ export async function updateAlbum(album: any, { albumName, properties }: any) {
|
||||||
* @param name Name of album (or ID)
|
* @param name Name of album (or ID)
|
||||||
*/
|
*/
|
||||||
export async function getAlbum(user: string, name: string, extraProps = {}) {
|
export async function getAlbum(user: string, name: string, extraProps = {}) {
|
||||||
const req = `<?xml version="1.0"?>
|
const req = `<?xml version="1.0"?>
|
||||||
<d:propfind xmlns:d="DAV:"
|
<d:propfind xmlns:d="DAV:"
|
||||||
xmlns:oc="http://owncloud.org/ns"
|
xmlns:oc="http://owncloud.org/ns"
|
||||||
xmlns:nc="http://nextcloud.org/ns"
|
xmlns:nc="http://nextcloud.org/ns"
|
||||||
|
@ -198,33 +218,41 @@ export async function getAlbum(user: string, name: string, extraProps = {}) {
|
||||||
${extraProps}
|
${extraProps}
|
||||||
</d:prop>
|
</d:prop>
|
||||||
</d:propfind>`;
|
</d:propfind>`;
|
||||||
let album = await client.stat(`/photos/${user}/albums/${name}`, {
|
let album = (await client.stat(`/photos/${user}/albums/${name}`, {
|
||||||
data: req,
|
data: req,
|
||||||
details: true,
|
details: true,
|
||||||
}) as any;
|
})) as any;
|
||||||
|
|
||||||
// Post processing
|
// Post processing
|
||||||
album = {
|
album = {
|
||||||
...album.data,
|
...album.data,
|
||||||
...album.data.props,
|
...album.data.props,
|
||||||
};
|
};
|
||||||
const c = album?.collaborators?.collaborator;
|
const c = album?.collaborators?.collaborator;
|
||||||
album.collaborators = c ? (Array.isArray(c) ? c : [c]) : [];
|
album.collaborators = c ? (Array.isArray(c) ? c : [c]) : [];
|
||||||
return album;
|
return album;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rename an album */
|
/** Rename an album */
|
||||||
export async function renameAlbum(album: any, { currentAlbumName, newAlbumName }) {
|
export async function renameAlbum(
|
||||||
const newAlbum = { ...album, basename: newAlbumName }
|
album: any,
|
||||||
try {
|
{ currentAlbumName, newAlbumName }
|
||||||
await client.moveFile(
|
) {
|
||||||
`/photos/${getCurrentUser()?.uid}/albums/${currentAlbumName}`,
|
const newAlbum = { ...album, basename: newAlbumName };
|
||||||
`/photos/${getCurrentUser()?.uid}/albums/${newAlbumName}`,
|
try {
|
||||||
)
|
await client.moveFile(
|
||||||
return newAlbum
|
`/photos/${getCurrentUser()?.uid}/albums/${currentAlbumName}`,
|
||||||
} catch (error) {
|
`/photos/${getCurrentUser()?.uid}/albums/${newAlbumName}`
|
||||||
console.error(error);
|
);
|
||||||
showError(t('photos', 'Failed to rename {currentAlbumName} to {newAlbumName}.', { currentAlbumName, newAlbumName }))
|
return newAlbum;
|
||||||
return album
|
} catch (error) {
|
||||||
}
|
console.error(error);
|
||||||
|
showError(
|
||||||
|
t("photos", "Failed to rename {currentAlbumName} to {newAlbumName}.", {
|
||||||
|
currentAlbumName,
|
||||||
|
newAlbumName,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return album;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import * as base from './base';
|
import * as base from "./base";
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from "@nextcloud/router";
|
||||||
import { showError } from '@nextcloud/dialogs'
|
import { showError } from "@nextcloud/dialogs";
|
||||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
|
||||||
import axios from '@nextcloud/axios'
|
import axios from "@nextcloud/axios";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Archive or unarchive a single file
|
* Archive or unarchive a single file
|
||||||
|
@ -10,8 +10,11 @@ import axios from '@nextcloud/axios'
|
||||||
* @param fileid File id
|
* @param fileid File id
|
||||||
* @param archive Archive or unarchive
|
* @param archive Archive or unarchive
|
||||||
*/
|
*/
|
||||||
export async function archiveFile(fileid: number, archive: boolean) {
|
export async function archiveFile(fileid: number, archive: boolean) {
|
||||||
return await axios.patch(generateUrl('/apps/memories/api/archive/{fileid}', { fileid }), { archive });
|
return await axios.patch(
|
||||||
|
generateUrl("/apps/memories/api/archive/{fileid}", { fileid }),
|
||||||
|
{ archive }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -21,23 +24,24 @@ import axios from '@nextcloud/axios'
|
||||||
* @param archive Archive or unarchive
|
* @param archive Archive or unarchive
|
||||||
* @returns list of file ids that were deleted
|
* @returns list of file ids that were deleted
|
||||||
*/
|
*/
|
||||||
export async function* archiveFilesByIds(fileIds: number[], archive: boolean) {
|
export async function* archiveFilesByIds(fileIds: number[], archive: boolean) {
|
||||||
if (fileIds.length === 0) {
|
if (fileIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive each file
|
||||||
|
const calls = fileIds.map((id) => async () => {
|
||||||
|
try {
|
||||||
|
await archiveFile(id, archive);
|
||||||
|
return id as number;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to (un)archive", id, error);
|
||||||
|
const msg =
|
||||||
|
error?.response?.data?.message || t("memories", "General Failure");
|
||||||
|
showError(t("memories", "Error: {msg}", { msg }));
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Archive each file
|
yield* base.runInParallel(calls, 10);
|
||||||
const calls = fileIds.map((id) => async () => {
|
}
|
||||||
try {
|
|
||||||
await archiveFile(id, archive);
|
|
||||||
return id as number;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to (un)archive', id, error);
|
|
||||||
const msg = error?.response?.data?.message || t('memories', 'General Failure');
|
|
||||||
showError(t('memories', 'Error: {msg}', { msg }));
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
yield* base.runInParallel(calls, 10);
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { getCurrentUser } from '@nextcloud/auth';
|
import { getCurrentUser } from "@nextcloud/auth";
|
||||||
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 { IFileInfo } from '../../types';
|
import { IFileInfo } from "../../types";
|
||||||
import client from '../DavClient';
|
import client from "../DavClient";
|
||||||
import { genFileInfo } from '../FileUtils';
|
import { genFileInfo } from "../FileUtils";
|
||||||
|
|
||||||
export const props = `
|
export const props = `
|
||||||
<oc:fileid />
|
<oc:fileid />
|
||||||
|
@ -17,18 +17,18 @@ export const props = `
|
||||||
<d:resourcetype />`;
|
<d:resourcetype />`;
|
||||||
|
|
||||||
export const IMAGE_MIME_TYPES = [
|
export const IMAGE_MIME_TYPES = [
|
||||||
'image/png',
|
"image/png",
|
||||||
'image/jpeg',
|
"image/jpeg",
|
||||||
'image/heic',
|
"image/heic",
|
||||||
'image/png',
|
"image/png",
|
||||||
'image/tiff',
|
"image/tiff",
|
||||||
'image/gif',
|
"image/gif",
|
||||||
'image/bmp',
|
"image/bmp",
|
||||||
'video/mpeg',
|
"video/mpeg",
|
||||||
'video/webm',
|
"video/webm",
|
||||||
'video/mp4',
|
"video/mp4",
|
||||||
'video/quicktime',
|
"video/quicktime",
|
||||||
'video/x-matroska',
|
"video/x-matroska",
|
||||||
];
|
];
|
||||||
|
|
||||||
const GET_FILE_CHUNK_SIZE = 50;
|
const GET_FILE_CHUNK_SIZE = 50;
|
||||||
|
@ -38,17 +38,17 @@ const GET_FILE_CHUNK_SIZE = 50;
|
||||||
* @param fileIds list of file ids
|
* @param fileIds list of file ids
|
||||||
* @returns list of file infos
|
* @returns list of file infos
|
||||||
*/
|
*/
|
||||||
export async function getFiles(fileIds: number[]): Promise<IFileInfo[]> {
|
export async function getFiles(fileIds: number[]): Promise<IFileInfo[]> {
|
||||||
// Divide fileIds into chunks of GET_FILE_CHUNK_SIZE
|
// Divide fileIds into chunks of GET_FILE_CHUNK_SIZE
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
for (let i = 0; i < fileIds.length; i += GET_FILE_CHUNK_SIZE) {
|
for (let i = 0; i < fileIds.length; i += GET_FILE_CHUNK_SIZE) {
|
||||||
chunks.push(fileIds.slice(i, i + GET_FILE_CHUNK_SIZE));
|
chunks.push(fileIds.slice(i, i + GET_FILE_CHUNK_SIZE));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file infos for each chunk
|
// Get file infos for each chunk
|
||||||
const fileInfos = await Promise.all(chunks.map(getFilesInternal));
|
const fileInfos = await Promise.all(chunks.map(getFilesInternal));
|
||||||
return fileInfos.flat();
|
return fileInfos.flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get file infos for list of files given Ids
|
* Get file infos for list of files given Ids
|
||||||
|
@ -56,29 +56,33 @@ const GET_FILE_CHUNK_SIZE = 50;
|
||||||
* @returns list of file infos
|
* @returns list of file infos
|
||||||
*/
|
*/
|
||||||
async function getFilesInternal(fileIds: number[]): Promise<IFileInfo[]> {
|
async function getFilesInternal(fileIds: number[]): Promise<IFileInfo[]> {
|
||||||
const prefixPath = `/files/${getCurrentUser()!.uid}`;
|
const prefixPath = `/files/${getCurrentUser()!.uid}`;
|
||||||
|
|
||||||
// IMPORTANT: if this isn't there, then a blank
|
// IMPORTANT: if this isn't there, then a blank
|
||||||
// returns EVERYTHING on the server!
|
// returns EVERYTHING on the server!
|
||||||
if (fileIds.length === 0) {
|
if (fileIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const filter = fileIds.map(fileId => `
|
const filter = fileIds
|
||||||
|
.map(
|
||||||
|
(fileId) => `
|
||||||
<d:eq>
|
<d:eq>
|
||||||
<d:prop>
|
<d:prop>
|
||||||
<oc:fileid/>
|
<oc:fileid/>
|
||||||
</d:prop>
|
</d:prop>
|
||||||
<d:literal>${fileId}</d:literal>
|
<d:literal>${fileId}</d:literal>
|
||||||
</d:eq>
|
</d:eq>
|
||||||
`).join('');
|
`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
method: 'SEARCH',
|
method: "SEARCH",
|
||||||
headers: {
|
headers: {
|
||||||
'content-Type': 'text/xml',
|
"content-Type": "text/xml",
|
||||||
},
|
},
|
||||||
data: `<?xml version="1.0" encoding="UTF-8"?>
|
data: `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<d:searchrequest xmlns:d="DAV:"
|
<d:searchrequest xmlns:d="DAV:"
|
||||||
xmlns:oc="http://owncloud.org/ns"
|
xmlns:oc="http://owncloud.org/ns"
|
||||||
xmlns:nc="http://nextcloud.org/ns"
|
xmlns:nc="http://nextcloud.org/ns"
|
||||||
|
@ -103,34 +107,39 @@ async function getFilesInternal(fileIds: number[]): Promise<IFileInfo[]> {
|
||||||
</d:where>
|
</d:where>
|
||||||
</d:basicsearch>
|
</d:basicsearch>
|
||||||
</d:searchrequest>`,
|
</d:searchrequest>`,
|
||||||
deep: true,
|
deep: true,
|
||||||
details: true,
|
details: true,
|
||||||
responseType: 'text',
|
responseType: "text",
|
||||||
};
|
};
|
||||||
|
|
||||||
let response: any = await client.getDirectoryContents('', options);
|
let response: any = await client.getDirectoryContents("", options);
|
||||||
return response.data
|
return response.data
|
||||||
.map((data: any) => genFileInfo(data))
|
.map((data: any) => genFileInfo(data))
|
||||||
.map((data: any) => Object.assign({}, data, {
|
.map((data: any) =>
|
||||||
originalFilename: data.filename,
|
Object.assign({}, data, {
|
||||||
filename: data.filename.replace(prefixPath, '')
|
originalFilename: data.filename,
|
||||||
}));
|
filename: data.filename.replace(prefixPath, ""),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run promises in parallel, but only n at a time
|
* Run promises in parallel, but only n at a time
|
||||||
* @param promises Array of promise generator funnction (async functions)
|
* @param promises Array of promise generator funnction (async functions)
|
||||||
* @param n Number of promises to run in parallel
|
* @param n Number of promises to run in parallel
|
||||||
*/
|
*/
|
||||||
export async function* runInParallel<T>(promises: (() => Promise<T>)[], n: number) {
|
export async function* runInParallel<T>(
|
||||||
while (promises.length > 0) {
|
promises: (() => Promise<T>)[],
|
||||||
const promisesToRun = promises.splice(0, n);
|
n: number
|
||||||
const resultsForThisBatch = await Promise.all(promisesToRun.map(p => p()));
|
) {
|
||||||
yield resultsForThisBatch;
|
while (promises.length > 0) {
|
||||||
}
|
const promisesToRun = promises.splice(0, n);
|
||||||
return;
|
const resultsForThisBatch = await Promise.all(
|
||||||
|
promisesToRun.map((p) => p())
|
||||||
|
);
|
||||||
|
yield resultsForThisBatch;
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -139,8 +148,8 @@ export async function* runInParallel<T>(promises: (() => Promise<T>)[], n: numbe
|
||||||
* @param path path to the file
|
* @param path path to the file
|
||||||
*/
|
*/
|
||||||
export async function deleteFile(path: string) {
|
export async function deleteFile(path: string) {
|
||||||
const prefixPath = `/files/${getCurrentUser()!.uid}`;
|
const prefixPath = `/files/${getCurrentUser()!.uid}`;
|
||||||
return await client.deleteFile(`${prefixPath}${path}`);
|
return await client.deleteFile(`${prefixPath}${path}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -150,46 +159,34 @@ export async function deleteFile(path: string) {
|
||||||
* @returns list of file ids that were deleted
|
* @returns list of file ids that were deleted
|
||||||
*/
|
*/
|
||||||
export async function* deleteFilesByIds(fileIds: number[]) {
|
export async function* deleteFilesByIds(fileIds: number[]) {
|
||||||
const fileIdsSet = new Set(fileIds);
|
const fileIdsSet = new Set(fileIds);
|
||||||
|
|
||||||
if (fileIds.length === 0) {
|
if (fileIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get files data
|
// Get files data
|
||||||
let fileInfos: any[] = [];
|
let fileInfos: any[] = [];
|
||||||
|
try {
|
||||||
|
fileInfos = await getFiles(fileIds.filter((f) => f));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to get file info for files to delete", fileIds, e);
|
||||||
|
showError(t("memories", "Failed to delete files."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete each file
|
||||||
|
fileInfos = fileInfos.filter((f) => fileIdsSet.has(f.fileid));
|
||||||
|
const calls = fileInfos.map((fileInfo) => async () => {
|
||||||
try {
|
try {
|
||||||
fileInfos = await getFiles(fileIds.filter(f => f));
|
await deleteFile(fileInfo.filename);
|
||||||
} catch (e) {
|
return fileInfo.fileid as number;
|
||||||
console.error('Failed to get file info for files to delete', fileIds, e);
|
} catch (error) {
|
||||||
showError(t('memories', 'Failed to delete files.'));
|
console.error("Failed to delete", fileInfo, error);
|
||||||
return;
|
showError(t("memories", "Failed to delete {fileName}.", fileInfo));
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Delete each file
|
yield* runInParallel(calls, 10);
|
||||||
fileInfos = fileInfos.filter((f) => fileIdsSet.has(f.fileid));
|
|
||||||
const calls = fileInfos.map((fileInfo) => async () => {
|
|
||||||
try {
|
|
||||||
await deleteFile(fileInfo.filename);
|
|
||||||
return fileInfo.fileid as number;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete', fileInfo, error);
|
|
||||||
showError(t('memories', 'Failed to delete {fileName}.', fileInfo));
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
yield* runInParallel(calls, 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import * as base from './base';
|
import * as base from "./base";
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from "@nextcloud/router";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download a file
|
* Download a file
|
||||||
|
@ -7,32 +7,32 @@ import { generateUrl } from '@nextcloud/router'
|
||||||
* @param fileNames - The file's names
|
* @param fileNames - The file's names
|
||||||
*/
|
*/
|
||||||
export async function downloadFiles(fileNames: string[]): Promise<boolean> {
|
export async function downloadFiles(fileNames: string[]): Promise<boolean> {
|
||||||
const randomToken = Math.random().toString(36).substring(2)
|
const randomToken = Math.random().toString(36).substring(2);
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams();
|
||||||
params.append('files', JSON.stringify(fileNames))
|
params.append("files", JSON.stringify(fileNames));
|
||||||
params.append('downloadStartSecret', randomToken)
|
params.append("downloadStartSecret", randomToken);
|
||||||
|
|
||||||
const downloadURL = generateUrl(`/apps/files/ajax/download.php?${params}`)
|
const downloadURL = generateUrl(`/apps/files/ajax/download.php?${params}`);
|
||||||
|
|
||||||
window.location.href = `${downloadURL}downloadStartSecret=${randomToken}`
|
window.location.href = `${downloadURL}downloadStartSecret=${randomToken}`;
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const waitForCookieInterval = setInterval(
|
const waitForCookieInterval = setInterval(() => {
|
||||||
() => {
|
const cookieIsSet = document.cookie
|
||||||
const cookieIsSet = document.cookie
|
.split(";")
|
||||||
.split(';')
|
.map((cookie) => cookie.split("="))
|
||||||
.map(cookie => cookie.split('='))
|
.findIndex(
|
||||||
.findIndex(([cookieName, cookieValue]) => cookieName === 'ocDownloadStarted' && cookieValue === randomToken)
|
([cookieName, cookieValue]) =>
|
||||||
|
cookieName === "ocDownloadStarted" && cookieValue === randomToken
|
||||||
|
);
|
||||||
|
|
||||||
if (cookieIsSet) {
|
if (cookieIsSet) {
|
||||||
clearInterval(waitForCookieInterval)
|
clearInterval(waitForCookieInterval);
|
||||||
resolve(true)
|
resolve(true);
|
||||||
}
|
}
|
||||||
},
|
}, 50);
|
||||||
50
|
});
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -40,11 +40,11 @@ export async function downloadFiles(fileNames: string[]): Promise<boolean> {
|
||||||
* @param fileIds list of file ids
|
* @param fileIds list of file ids
|
||||||
*/
|
*/
|
||||||
export async function downloadFilesByIds(fileIds: number[]) {
|
export async function downloadFilesByIds(fileIds: number[]) {
|
||||||
if (fileIds.length === 0) {
|
if (fileIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get files to download
|
// Get files to download
|
||||||
const fileInfos = await base.getFiles(fileIds);
|
const fileInfos = await base.getFiles(fileIds);
|
||||||
await downloadFiles(fileInfos.map(f => f.filename));
|
await downloadFiles(fileInfos.map((f) => f.filename));
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,44 +1,51 @@
|
||||||
import axios from '@nextcloud/axios';
|
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 { IDay, IPhoto } from "../../types";
|
||||||
import client from '../DavClient';
|
import client from "../DavClient";
|
||||||
import { constants } from '../Utils';
|
import { constants } from "../Utils";
|
||||||
import * as base from './base';
|
import * as base from "./base";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of tags and convert to Days response
|
* Get list of tags and convert to Days response
|
||||||
*/
|
*/
|
||||||
export async function getPeopleData(): Promise<IDay[]> {
|
export async function getPeopleData(): Promise<IDay[]> {
|
||||||
// Query for photos
|
// Query for photos
|
||||||
let data: {
|
let data: {
|
||||||
id: number;
|
id: number;
|
||||||
count: number;
|
count: number;
|
||||||
name: string;
|
name: string;
|
||||||
previews: IPhoto[];
|
previews: IPhoto[];
|
||||||
}[] = [];
|
}[] = [];
|
||||||
try {
|
try {
|
||||||
const res = await axios.get<typeof data>(generateUrl('/apps/memories/api/faces'));
|
const res = await axios.get<typeof data>(
|
||||||
data = res.data;
|
generateUrl("/apps/memories/api/faces")
|
||||||
} catch (e) {
|
);
|
||||||
throw e;
|
data = res.data;
|
||||||
}
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
// Add flag to previews
|
// Add flag to previews
|
||||||
data.forEach(t => t.previews?.forEach((preview) => preview.flag = 0));
|
data.forEach((t) => t.previews?.forEach((preview) => (preview.flag = 0)));
|
||||||
|
|
||||||
// Convert to days response
|
// Convert to days response
|
||||||
return [{
|
return [
|
||||||
dayid: constants.TagDayID.FACES,
|
{
|
||||||
count: data.length,
|
dayid: constants.TagDayID.FACES,
|
||||||
detail: data.map((face) => ({
|
count: data.length,
|
||||||
|
detail: data.map(
|
||||||
|
(face) =>
|
||||||
|
({
|
||||||
...face,
|
...face,
|
||||||
fileid: face.id,
|
fileid: face.id,
|
||||||
istag: true,
|
istag: true,
|
||||||
isface: true,
|
isface: true,
|
||||||
} as any)),
|
} as any)
|
||||||
}]
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -49,23 +56,31 @@ export async function getPeopleData(): Promise<IDay[]> {
|
||||||
* @param fileIds List of file IDs to remove
|
* @param fileIds List of file IDs to remove
|
||||||
* @returns Generator
|
* @returns Generator
|
||||||
*/
|
*/
|
||||||
export async function* removeFaceImages(user: string, name: string, fileIds: number[]) {
|
export async function* removeFaceImages(
|
||||||
// Get files data
|
user: string,
|
||||||
let fileInfos = await base.getFiles(fileIds.filter(f => f));
|
name: string,
|
||||||
|
fileIds: number[]
|
||||||
|
) {
|
||||||
|
// Get files data
|
||||||
|
let fileInfos = await base.getFiles(fileIds.filter((f) => f));
|
||||||
|
|
||||||
// Remove each file
|
// Remove each file
|
||||||
const calls = fileInfos.map((f) => async () => {
|
const calls = fileInfos.map((f) => async () => {
|
||||||
try {
|
try {
|
||||||
await client.deleteFile(`/recognize/${user}/faces/${name}/${f.fileid}-${f.basename}`)
|
await client.deleteFile(
|
||||||
return f.fileid;
|
`/recognize/${user}/faces/${name}/${f.fileid}-${f.basename}`
|
||||||
} catch (e) {
|
);
|
||||||
console.error(e)
|
return f.fileid;
|
||||||
showError(t('memories', 'Failed to remove {filename} from face.', {
|
} catch (e) {
|
||||||
filename: f.filename,
|
console.error(e);
|
||||||
}));
|
showError(
|
||||||
return 0;
|
t("memories", "Failed to remove {filename} from face.", {
|
||||||
}
|
filename: f.filename,
|
||||||
});
|
})
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
yield* base.runInParallel(calls, 10);
|
yield* base.runInParallel(calls, 10);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import * as base from './base';
|
import * as base from "./base";
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from "@nextcloud/router";
|
||||||
import { encodePath } from '@nextcloud/paths'
|
import { encodePath } from "@nextcloud/paths";
|
||||||
import { showError } from '@nextcloud/dialogs'
|
import { showError } from "@nextcloud/dialogs";
|
||||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
|
||||||
import axios from '@nextcloud/axios'
|
import axios from "@nextcloud/axios";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Favorite a file
|
* Favorite a file
|
||||||
|
@ -13,22 +13,22 @@ import axios from '@nextcloud/axios'
|
||||||
* @param favoriteState - The new favorite state
|
* @param favoriteState - The new favorite state
|
||||||
*/
|
*/
|
||||||
export async function favoriteFile(fileName: string, favoriteState: boolean) {
|
export async function favoriteFile(fileName: string, favoriteState: boolean) {
|
||||||
let encodedPath = encodePath(fileName)
|
let encodedPath = encodePath(fileName);
|
||||||
while (encodedPath[0] === '/') {
|
while (encodedPath[0] === "/") {
|
||||||
encodedPath = encodedPath.substring(1)
|
encodedPath = encodedPath.substring(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return axios.post(
|
return axios.post(
|
||||||
`${generateUrl('/apps/files/api/v1/files/')}${encodedPath}`,
|
`${generateUrl("/apps/files/api/v1/files/")}${encodedPath}`,
|
||||||
{
|
{
|
||||||
tags: favoriteState ? ['_$!<Favorite>!$_'] : [],
|
tags: favoriteState ? ["_$!<Favorite>!$_"] : [],
|
||||||
},
|
}
|
||||||
)
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to favorite', fileName, error)
|
console.error("Failed to favorite", fileName, error);
|
||||||
showError(t('memories', 'Failed to favorite {fileName}.', { fileName }))
|
showError(t("memories", "Failed to favorite {fileName}.", { fileName }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -38,35 +38,38 @@ export async function favoriteFile(fileName: string, favoriteState: boolean) {
|
||||||
* @param favoriteState the new favorite state
|
* @param favoriteState the new favorite state
|
||||||
* @returns generator of lists of file ids that were state-changed
|
* @returns generator of lists of file ids that were state-changed
|
||||||
*/
|
*/
|
||||||
export async function* favoriteFilesByIds(fileIds: number[], favoriteState: boolean) {
|
export async function* favoriteFilesByIds(
|
||||||
const fileIdsSet = new Set(fileIds);
|
fileIds: number[],
|
||||||
|
favoriteState: boolean
|
||||||
|
) {
|
||||||
|
const fileIdsSet = new Set(fileIds);
|
||||||
|
|
||||||
if (fileIds.length === 0) {
|
if (fileIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get files data
|
// Get files data
|
||||||
let fileInfos: any[] = [];
|
let fileInfos: any[] = [];
|
||||||
|
try {
|
||||||
|
fileInfos = await base.getFiles(fileIds.filter((f) => f));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to get file info", fileIds, e);
|
||||||
|
showError(t("memories", "Failed to favorite files."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorite each file
|
||||||
|
fileInfos = fileInfos.filter((f) => fileIdsSet.has(f.fileid));
|
||||||
|
const calls = fileInfos.map((fileInfo) => async () => {
|
||||||
try {
|
try {
|
||||||
fileInfos = await base.getFiles(fileIds.filter(f => f));
|
await favoriteFile(fileInfo.filename, favoriteState);
|
||||||
} catch (e) {
|
return fileInfo.fileid as number;
|
||||||
console.error('Failed to get file info', fileIds, e);
|
} catch (error) {
|
||||||
showError(t('memories', 'Failed to favorite files.'));
|
console.error("Failed to favorite", fileInfo, error);
|
||||||
return;
|
showError(t("memories", "Failed to favorite {fileName}.", fileInfo));
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Favorite each file
|
yield* base.runInParallel(calls, 10);
|
||||||
fileInfos = fileInfos.filter((f) => fileIdsSet.has(f.fileid));
|
}
|
||||||
const calls = fileInfos.map((fileInfo) => async () => {
|
|
||||||
try {
|
|
||||||
await favoriteFile(fileInfo.filename, favoriteState);
|
|
||||||
return fileInfo.fileid as number;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to favorite', fileInfo, error);
|
|
||||||
showError(t('memories', 'Failed to favorite {fileName}.', fileInfo));
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
yield* base.runInParallel(calls, 10);
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,32 +1,37 @@
|
||||||
import * as base from './base';
|
import * as base from "./base";
|
||||||
import { getCurrentUser } from '@nextcloud/auth'
|
import { getCurrentUser } from "@nextcloud/auth";
|
||||||
import { genFileInfo } from '../FileUtils'
|
import { genFileInfo } from "../FileUtils";
|
||||||
import { IFileInfo } from '../../types';
|
import { IFileInfo } from "../../types";
|
||||||
import client from '../DavClient';
|
import client from "../DavClient";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get file infos for files in folder path
|
* Get file infos for files in folder path
|
||||||
* @param folderPath Path to folder
|
* @param folderPath Path to folder
|
||||||
* @param limit Max number of files to return
|
* @param limit Max number of files to return
|
||||||
*/
|
*/
|
||||||
export async function getFolderPreviewFileIds(folderPath: string, limit: number): Promise<IFileInfo[]> {
|
export async function getFolderPreviewFileIds(
|
||||||
const prefixPath = `/files/${getCurrentUser()!.uid}`;
|
folderPath: string,
|
||||||
|
limit: number
|
||||||
|
): Promise<IFileInfo[]> {
|
||||||
|
const prefixPath = `/files/${getCurrentUser()!.uid}`;
|
||||||
|
|
||||||
const filter = base.IMAGE_MIME_TYPES.map(mime => `
|
const filter = base.IMAGE_MIME_TYPES.map(
|
||||||
|
(mime) => `
|
||||||
<d:like>
|
<d:like>
|
||||||
<d:prop>
|
<d:prop>
|
||||||
<d:getcontenttype/>
|
<d:getcontenttype/>
|
||||||
</d:prop>
|
</d:prop>
|
||||||
<d:literal>${mime}</d:literal>
|
<d:literal>${mime}</d:literal>
|
||||||
</d:like>
|
</d:like>
|
||||||
`).join('');
|
`
|
||||||
|
).join("");
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
method: 'SEARCH',
|
method: "SEARCH",
|
||||||
headers: {
|
headers: {
|
||||||
'content-Type': 'text/xml',
|
"content-Type": "text/xml",
|
||||||
},
|
},
|
||||||
data: `<?xml version="1.0" encoding="UTF-8"?>
|
data: `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<d:searchrequest xmlns:d="DAV:"
|
<d:searchrequest xmlns:d="DAV:"
|
||||||
xmlns:oc="http://owncloud.org/ns"
|
xmlns:oc="http://owncloud.org/ns"
|
||||||
xmlns:nc="http://nextcloud.org/ns"
|
xmlns:nc="http://nextcloud.org/ns"
|
||||||
|
@ -54,16 +59,18 @@ export async function getFolderPreviewFileIds(folderPath: string, limit: number)
|
||||||
</d:limit>
|
</d:limit>
|
||||||
</d:basicsearch>
|
</d:basicsearch>
|
||||||
</d:searchrequest>`,
|
</d:searchrequest>`,
|
||||||
deep: true,
|
deep: true,
|
||||||
details: true,
|
details: true,
|
||||||
responseType: 'text',
|
responseType: "text",
|
||||||
};
|
};
|
||||||
|
|
||||||
let response:any = await client.getDirectoryContents('', options);
|
let response: any = await client.getDirectoryContents("", options);
|
||||||
return response.data
|
return response.data
|
||||||
.map((data: any) => genFileInfo(data))
|
.map((data: any) => genFileInfo(data))
|
||||||
.map((data: any) => Object.assign({}, data, {
|
.map((data: any) =>
|
||||||
filename: data.filename.replace(prefixPath, ''),
|
Object.assign({}, data, {
|
||||||
etag: data.etag.replace(/"/g, ''), // remove quotes
|
filename: data.filename.replace(prefixPath, ""),
|
||||||
}));
|
etag: data.etag.replace(/"/g, ""), // remove quotes
|
||||||
}
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,30 +1,32 @@
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from "@nextcloud/router";
|
||||||
import { IDay, IPhoto } from '../../types';
|
import { IDay, IPhoto } from "../../types";
|
||||||
import axios from '@nextcloud/axios'
|
import axios from "@nextcloud/axios";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get original onThisDay response.
|
* Get original onThisDay response.
|
||||||
*/
|
*/
|
||||||
export async function getOnThisDayRaw() {
|
export async function getOnThisDayRaw() {
|
||||||
const dayIds: number[] = [];
|
const dayIds: number[] = [];
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const nowUTC = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
const nowUTC = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
||||||
|
|
||||||
// Populate dayIds
|
// Populate dayIds
|
||||||
for (let i = 1; i <= 120; i++) {
|
for (let i = 1; i <= 120; i++) {
|
||||||
// +- 3 days from this day
|
// +- 3 days from this day
|
||||||
for (let j = -3; j <= 3; j++) {
|
for (let j = -3; j <= 3; j++) {
|
||||||
const d = new Date(nowUTC);
|
const d = new Date(nowUTC);
|
||||||
d.setFullYear(d.getFullYear() - i);
|
d.setFullYear(d.getFullYear() - i);
|
||||||
d.setDate(d.getDate() + j);
|
d.setDate(d.getDate() + j);
|
||||||
const dayId = Math.floor(d.getTime() / 1000 / 86400)
|
const dayId = Math.floor(d.getTime() / 1000 / 86400);
|
||||||
dayIds.push(dayId);
|
dayIds.push(dayId);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (await axios.post<IPhoto[]>(generateUrl('/apps/memories/api/days'), {
|
return (
|
||||||
body_ids: dayIds.join(','),
|
await axios.post<IPhoto[]>(generateUrl("/apps/memories/api/days"), {
|
||||||
})).data;
|
body_ids: dayIds.join(","),
|
||||||
|
})
|
||||||
|
).data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -32,30 +34,30 @@ export async function getOnThisDayRaw() {
|
||||||
* Query for last 120 years; should be enough
|
* Query for last 120 years; should be enough
|
||||||
*/
|
*/
|
||||||
export async function getOnThisDayData(): Promise<IDay[]> {
|
export async function getOnThisDayData(): Promise<IDay[]> {
|
||||||
// Query for photos
|
// Query for photos
|
||||||
let data = await getOnThisDayRaw();
|
let data = await getOnThisDayRaw();
|
||||||
|
|
||||||
// Group photos by day
|
// Group photos by day
|
||||||
const ans: IDay[] = [];
|
const ans: IDay[] = [];
|
||||||
let prevDayId = Number.MIN_SAFE_INTEGER;
|
let prevDayId = Number.MIN_SAFE_INTEGER;
|
||||||
for (const photo of data) {
|
for (const photo of data) {
|
||||||
if (!photo.dayid) continue;
|
if (!photo.dayid) continue;
|
||||||
|
|
||||||
// This works because the response is sorted by date taken
|
// This works because the response is sorted by date taken
|
||||||
if (photo.dayid !== prevDayId) {
|
if (photo.dayid !== prevDayId) {
|
||||||
ans.push({
|
ans.push({
|
||||||
dayid: photo.dayid,
|
dayid: photo.dayid,
|
||||||
count: 0,
|
count: 0,
|
||||||
detail: [],
|
detail: [],
|
||||||
});
|
});
|
||||||
prevDayId = photo.dayid;
|
prevDayId = photo.dayid;
|
||||||
}
|
|
||||||
|
|
||||||
// Add to last day
|
|
||||||
const day = ans[ans.length - 1];
|
|
||||||
day.detail.push(photo);
|
|
||||||
day.count++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ans;
|
// Add to last day
|
||||||
}
|
const day = ans[ans.length - 1];
|
||||||
|
day.detail.push(photo);
|
||||||
|
day.count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ans;
|
||||||
|
}
|
||||||
|
|
|
@ -1,38 +1,45 @@
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from "@nextcloud/router";
|
||||||
import { IDay, IPhoto, ITag } from '../../types';
|
import { IDay, IPhoto, ITag } from "../../types";
|
||||||
import { constants, hashCode } from '../Utils';
|
import { constants, hashCode } from "../Utils";
|
||||||
import axios from '@nextcloud/axios'
|
import axios from "@nextcloud/axios";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of tags and convert to Days response
|
* Get list of tags and convert to Days response
|
||||||
*/
|
*/
|
||||||
export async function getTagsData(): Promise<IDay[]> {
|
export async function getTagsData(): Promise<IDay[]> {
|
||||||
// Query for photos
|
// Query for photos
|
||||||
let data: {
|
let data: {
|
||||||
id: number;
|
id: number;
|
||||||
count: number;
|
count: number;
|
||||||
name: string;
|
name: string;
|
||||||
previews: IPhoto[];
|
previews: IPhoto[];
|
||||||
}[] = [];
|
}[] = [];
|
||||||
try {
|
try {
|
||||||
const res = await axios.get<typeof data>(generateUrl('/apps/memories/api/tags'));
|
const res = await axios.get<typeof data>(
|
||||||
data = res.data;
|
generateUrl("/apps/memories/api/tags")
|
||||||
} catch (e) {
|
);
|
||||||
throw e;
|
data = res.data;
|
||||||
}
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
// Add flag to previews
|
// Add flag to previews
|
||||||
data.forEach(t => t.previews?.forEach((preview) => preview.flag = 0));
|
data.forEach((t) => t.previews?.forEach((preview) => (preview.flag = 0)));
|
||||||
|
|
||||||
// Convert to days response
|
// Convert to days response
|
||||||
return [{
|
return [
|
||||||
dayid: constants.TagDayID.TAGS,
|
{
|
||||||
count: data.length,
|
dayid: constants.TagDayID.TAGS,
|
||||||
detail: data.map((tag) => ({
|
count: data.length,
|
||||||
|
detail: data.map(
|
||||||
|
(tag) =>
|
||||||
|
({
|
||||||
...tag,
|
...tag,
|
||||||
fileid: hashCode(tag.name),
|
fileid: hashCode(tag.name),
|
||||||
flag: constants.c.FLAG_IS_TAG,
|
flag: constants.c.FLAG_IS_TAG,
|
||||||
istag: true,
|
istag: true,
|
||||||
} as ITag)),
|
} as ITag)
|
||||||
}]
|
),
|
||||||
}
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
328
src/types.ts
328
src/types.ts
|
@ -1,201 +1,201 @@
|
||||||
import { VueConstructor } from "vue";
|
import { VueConstructor } from "vue";
|
||||||
|
|
||||||
export type IFileInfo = {
|
export type IFileInfo = {
|
||||||
/** Database file ID */
|
/** Database file ID */
|
||||||
fileid: number;
|
fileid: number;
|
||||||
/** Full file name, e.g. /pi/test/Qx0dq7dvEXA.jpg */
|
/** Full file name, e.g. /pi/test/Qx0dq7dvEXA.jpg */
|
||||||
filename: string;
|
filename: string;
|
||||||
/** Original file name, e.g. /files/admin/pi/test/Qx0dq7dvEXA.jpg */
|
/** Original file name, e.g. /files/admin/pi/test/Qx0dq7dvEXA.jpg */
|
||||||
originalFilename: string;
|
originalFilename: string;
|
||||||
/** Base name of file e.g. Qx0dq7dvEXA.jpg */
|
/** Base name of file e.g. Qx0dq7dvEXA.jpg */
|
||||||
basename: string;
|
basename: string;
|
||||||
/** Etag identifier */
|
/** Etag identifier */
|
||||||
etag: string;
|
etag: string;
|
||||||
/** File has preview available */
|
/** File has preview available */
|
||||||
hasPreview: boolean;
|
hasPreview: boolean;
|
||||||
/** File is marked favorite */
|
/** File is marked favorite */
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
/** Vue flags */
|
/** Vue flags */
|
||||||
flag?: number;
|
flag?: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type IDay = {
|
export type IDay = {
|
||||||
/** Day ID */
|
/** Day ID */
|
||||||
dayid: number;
|
dayid: number;
|
||||||
/** Number of photos in this day */
|
/** Number of photos in this day */
|
||||||
count: number;
|
count: number;
|
||||||
/** Rows in the day */
|
/** Rows in the day */
|
||||||
rows?: IRow[];
|
rows?: IRow[];
|
||||||
/** List of photos for this day */
|
/** List of photos for this day */
|
||||||
detail?: IPhoto[];
|
detail?: IPhoto[];
|
||||||
}
|
};
|
||||||
|
|
||||||
export type IPhoto = {
|
export type IPhoto = {
|
||||||
/** Nextcloud ID of file */
|
/** Nextcloud ID of file */
|
||||||
fileid: number;
|
fileid: number;
|
||||||
/** Etag from server */
|
/** Etag from server */
|
||||||
etag?: string;
|
etag?: string;
|
||||||
/** Bit flags */
|
/** Bit flags */
|
||||||
flag: number;
|
flag: number;
|
||||||
/** DayID from server */
|
/** DayID from server */
|
||||||
dayid?: number;
|
dayid?: number;
|
||||||
/** Width of full image */
|
/** Width of full image */
|
||||||
w?: number;
|
w?: number;
|
||||||
/** Height of full image */
|
/** Height of full image */
|
||||||
h?: number;
|
h?: number;
|
||||||
|
|
||||||
/** Grid display width px */
|
/** Grid display width px */
|
||||||
dispW?: number;
|
dispW?: number;
|
||||||
/** Grid display height px */
|
/** Grid display height px */
|
||||||
dispH?: number;
|
dispH?: number;
|
||||||
/** Grid display X px */
|
/** Grid display X px */
|
||||||
dispX?: number;
|
dispX?: number;
|
||||||
/** Grid display Y px */
|
/** Grid display Y px */
|
||||||
dispY?: number;
|
dispY?: number;
|
||||||
/** Grid display row id (relative to head) */
|
/** Grid display row id (relative to head) */
|
||||||
dispRowNum?: number;
|
dispRowNum?: number;
|
||||||
|
|
||||||
/** Reference to day object */
|
/** Reference to day object */
|
||||||
d?: IDay;
|
d?: IDay;
|
||||||
|
|
||||||
/** Face dimensions */
|
/** Face dimensions */
|
||||||
facerect?: IFaceRect;
|
facerect?: IFaceRect;
|
||||||
|
|
||||||
/** Video flag from server */
|
/** Video flag from server */
|
||||||
isvideo?: boolean;
|
isvideo?: boolean;
|
||||||
/** Favorite flag from server */
|
/** Favorite flag from server */
|
||||||
isfavorite?: boolean;
|
isfavorite?: boolean;
|
||||||
/** Is this a folder */
|
/** Is this a folder */
|
||||||
isfolder?: boolean;
|
isfolder?: boolean;
|
||||||
/** Is this a tag */
|
/** Is this a tag */
|
||||||
istag?: boolean;
|
istag?: boolean;
|
||||||
/** Is this an album */
|
/** Is this an album */
|
||||||
isalbum?: boolean;
|
isalbum?: boolean;
|
||||||
/** Is this a face */
|
/** Is this a face */
|
||||||
isface?: boolean;
|
isface?: boolean;
|
||||||
/** Optional datetaken epoch */
|
/** Optional datetaken epoch */
|
||||||
datetaken?: number;
|
datetaken?: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface IFolder extends IPhoto {
|
export interface IFolder extends IPhoto {
|
||||||
/** Path to folder */
|
/** Path to folder */
|
||||||
path: string;
|
path: string;
|
||||||
/** FileInfos for preview images */
|
/** FileInfos for preview images */
|
||||||
previewFileInfos?: IFileInfo[];
|
previewFileInfos?: IFileInfo[];
|
||||||
/** Name of folder */
|
/** Name of folder */
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITag extends IPhoto {
|
export interface ITag extends IPhoto {
|
||||||
/** Name of tag */
|
/** Name of tag */
|
||||||
name: string;
|
name: string;
|
||||||
/** Number of images in this tag */
|
/** Number of images in this tag */
|
||||||
count: number;
|
count: number;
|
||||||
/** User for face if face */
|
/** User for face if face */
|
||||||
user_id?: string;
|
user_id?: string;
|
||||||
/** Cache of previews */
|
/** Cache of previews */
|
||||||
previews?: IPhoto[];
|
previews?: IPhoto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAlbum extends ITag {
|
export interface IAlbum extends ITag {
|
||||||
/** ID of album */
|
/** ID of album */
|
||||||
album_id: number;
|
album_id: number;
|
||||||
/** Owner of album */
|
/** Owner of album */
|
||||||
user: string;
|
user: string;
|
||||||
/** Created timestamp */
|
/** Created timestamp */
|
||||||
created: number;
|
created: number;
|
||||||
/** Location string */
|
/** Location string */
|
||||||
location: string;
|
location: string;
|
||||||
/** File ID of last added photo */
|
/** File ID of last added photo */
|
||||||
last_added_photo: number;
|
last_added_photo: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFaceRect {
|
export interface IFaceRect {
|
||||||
w: number;
|
w: number;
|
||||||
h: number;
|
h: number;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IRow = {
|
export type IRow = {
|
||||||
/** Vue Recycler identifier */
|
/** Vue Recycler identifier */
|
||||||
id?: string;
|
id?: string;
|
||||||
/** Row ID from head */
|
/** Row ID from head */
|
||||||
num: number;
|
num: number;
|
||||||
/** Day ID */
|
/** Day ID */
|
||||||
dayId: number;
|
dayId: number;
|
||||||
/** Refrence to day object */
|
/** Refrence to day object */
|
||||||
day: IDay;
|
day: IDay;
|
||||||
/** Whether this is a head row */
|
/** Whether this is a head row */
|
||||||
type: IRowType;
|
type: IRowType;
|
||||||
/** [Head only] Title of the header */
|
/** [Head only] Title of the header */
|
||||||
name?: string;
|
name?: string;
|
||||||
/** [Head only] Boolean if the entire day is selected */
|
/** [Head only] Boolean if the entire day is selected */
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
/** Main list of photo items */
|
/** Main list of photo items */
|
||||||
photos?: IPhoto[];
|
photos?: IPhoto[];
|
||||||
/** Height in px of the row */
|
/** Height in px of the row */
|
||||||
size?: number;
|
size?: number;
|
||||||
/** Count of placeholders to create */
|
/** Count of placeholders to create */
|
||||||
pct?: number;
|
pct?: number;
|
||||||
}
|
};
|
||||||
export type IHeadRow = IRow & {
|
export type IHeadRow = IRow & {
|
||||||
type: IRowType.HEAD;
|
type: IRowType.HEAD;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
super?: string;
|
super?: string;
|
||||||
}
|
};
|
||||||
export enum IRowType {
|
export enum IRowType {
|
||||||
HEAD = 0,
|
HEAD = 0,
|
||||||
PHOTOS = 1,
|
PHOTOS = 1,
|
||||||
FOLDERS = 2,
|
FOLDERS = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ITick = {
|
export type ITick = {
|
||||||
/** Day ID */
|
/** Day ID */
|
||||||
dayId: number;
|
dayId: number;
|
||||||
/** Display top position */
|
/** Display top position */
|
||||||
topF: number;
|
topF: number;
|
||||||
/** Display top position (truncated to 1 decimal pt) */
|
/** Display top position (truncated to 1 decimal pt) */
|
||||||
top: number;
|
top: number;
|
||||||
/** Y coordinate on recycler */
|
/** Y coordinate on recycler */
|
||||||
y: number;
|
y: number;
|
||||||
/** Cumulative number of photos before this tick */
|
/** Cumulative number of photos before this tick */
|
||||||
count: number;
|
count: number;
|
||||||
/** Is a new month */
|
/** Is a new month */
|
||||||
isMonth: boolean;
|
isMonth: boolean;
|
||||||
/** Text if any (e.g. year) */
|
/** Text if any (e.g. year) */
|
||||||
text?: string | number;
|
text?: string | number;
|
||||||
/** Whether this tick should be shown */
|
/** Whether this tick should be shown */
|
||||||
s?: boolean;
|
s?: boolean;
|
||||||
/** Key for vue component */
|
/** Key for vue component */
|
||||||
key?: number
|
key?: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type TopMatter = {
|
export type TopMatter = {
|
||||||
type: TopMatterType;
|
type: TopMatterType;
|
||||||
}
|
};
|
||||||
export enum TopMatterType {
|
export enum TopMatterType {
|
||||||
NONE = 0,
|
NONE = 0,
|
||||||
FOLDER = 1,
|
FOLDER = 1,
|
||||||
TAG = 2,
|
TAG = 2,
|
||||||
FACE = 3,
|
FACE = 3,
|
||||||
ALBUM = 4,
|
ALBUM = 4,
|
||||||
}
|
}
|
||||||
export type TopMatterFolder = TopMatter & {
|
export type TopMatterFolder = TopMatter & {
|
||||||
type: TopMatterType.FOLDER;
|
type: TopMatterType.FOLDER;
|
||||||
list: {
|
list: {
|
||||||
text: string;
|
text: string;
|
||||||
path: string;
|
path: string;
|
||||||
}[];
|
}[];
|
||||||
}
|
};
|
||||||
|
|
||||||
export type ISelectionAction = {
|
export type ISelectionAction = {
|
||||||
/** Display text */
|
/** Display text */
|
||||||
name: string;
|
name: string;
|
||||||
/** Icon component */
|
/** Icon component */
|
||||||
icon: VueConstructor;
|
icon: VueConstructor;
|
||||||
/** Action to perform */
|
/** Action to perform */
|
||||||
callback: (selection: Map<number, IPhoto>) => Promise<void>;
|
callback: (selection: Map<number, IPhoto>) => Promise<void>;
|
||||||
/** Condition to check for including */
|
/** Condition to check for including */
|
||||||
if?: (self?: any) => boolean;
|
if?: (self?: any) => boolean;
|
||||||
}
|
};
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
declare module "*.vue" {
|
declare module "*.vue" {
|
||||||
import Vue from "vue"
|
import Vue from "vue";
|
||||||
export default Vue
|
export default Vue;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '*.svg' {
|
declare module "*.svg" {
|
||||||
import Vue, {VueConstructor} from 'vue';
|
import Vue, { VueConstructor } from "vue";
|
||||||
const content: VueConstructor<Vue>;
|
const content: VueConstructor<Vue>;
|
||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue