Make the code prettier
parent
64b50deb40
commit
fc6a4fc244
363
src/App.vue
363
src/App.vue
|
@ -1,221 +1,254 @@
|
|||
<template>
|
||||
<FirstStart v-if="isFirstStart" />
|
||||
<FirstStart v-if="isFirstStart" />
|
||||
|
||||
<NcContent app-name="memories" v-else :class="{
|
||||
'remove-gap': removeOuterGap,
|
||||
}">
|
||||
<NcAppNavigation>
|
||||
<template id="app-memories-navigation" #list>
|
||||
<NcAppNavigationItem :to="{name: 'timeline'}"
|
||||
:title="t('memories', 'Timeline')"
|
||||
exact>
|
||||
<ImageMultiple slot="icon" :size="20" />
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem :to="{name: 'folders'}"
|
||||
:title="t('memories', 'Folders')">
|
||||
<FolderIcon slot="icon" :size="20" />
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem :to="{name: 'favorites'}"
|
||||
:title="t('memories', 'Favorites')">
|
||||
<Star slot="icon" :size="20" />
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem :to="{name: 'videos'}"
|
||||
:title="t('memories', 'Videos')">
|
||||
<Video slot="icon" :size="20" />
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem :to="{name: 'albums'}"
|
||||
:title="t('memories', 'Albums')" v-if="showAlbums">
|
||||
<AlbumIcon slot="icon" :size="20" />
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem :to="{name: 'people'}"
|
||||
:title="t('memories', 'People')" v-if="showPeople">
|
||||
<PeopleIcon slot="icon" :size="20" />
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem :to="{name: 'archive'}"
|
||||
:title="t('memories', 'Archive')">
|
||||
<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>
|
||||
<NcContent
|
||||
app-name="memories"
|
||||
v-else
|
||||
:class="{
|
||||
'remove-gap': removeOuterGap,
|
||||
}"
|
||||
>
|
||||
<NcAppNavigation>
|
||||
<template id="app-memories-navigation" #list>
|
||||
<NcAppNavigationItem
|
||||
:to="{ name: 'timeline' }"
|
||||
:title="t('memories', 'Timeline')"
|
||||
exact
|
||||
>
|
||||
<ImageMultiple slot="icon" :size="20" />
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem
|
||||
:to="{ name: 'folders' }"
|
||||
:title="t('memories', 'Folders')"
|
||||
>
|
||||
<FolderIcon slot="icon" :size="20" />
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem
|
||||
:to="{ name: 'favorites' }"
|
||||
:title="t('memories', 'Favorites')"
|
||||
>
|
||||
<Star slot="icon" :size="20" />
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem
|
||||
:to="{ name: 'videos' }"
|
||||
:title="t('memories', 'Videos')"
|
||||
>
|
||||
<Video slot="icon" :size="20" />
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem
|
||||
:to="{ name: 'albums' }"
|
||||
:title="t('memories', 'Albums')"
|
||||
v-if="showAlbums"
|
||||
>
|
||||
<AlbumIcon slot="icon" :size="20" />
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem
|
||||
:to="{ name: 'people' }"
|
||||
:title="t('memories', 'People')"
|
||||
v-if="showPeople"
|
||||
>
|
||||
<PeopleIcon slot="icon" :size="20" />
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem
|
||||
:to="{ name: 'archive' }"
|
||||
:title="t('memories', 'Archive')"
|
||||
>
|
||||
<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>
|
||||
<div class="outer">
|
||||
<router-view />
|
||||
</div>
|
||||
</NcAppContent>
|
||||
</NcContent>
|
||||
<NcAppContent>
|
||||
<div class="outer">
|
||||
<router-view />
|
||||
</div>
|
||||
</NcAppContent>
|
||||
</NcContent>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Mixins } from 'vue-property-decorator';
|
||||
import { Component, Mixins } from "vue-property-decorator";
|
||||
import {
|
||||
NcContent, NcAppContent, NcAppNavigation,
|
||||
NcAppNavigationItem, NcAppNavigationSettings,
|
||||
} from '@nextcloud/vue';
|
||||
import { generateUrl } from '@nextcloud/router';
|
||||
import { getCurrentUser } from '@nextcloud/auth';
|
||||
NcContent,
|
||||
NcAppContent,
|
||||
NcAppNavigation,
|
||||
NcAppNavigationItem,
|
||||
NcAppNavigationSettings,
|
||||
} from "@nextcloud/vue";
|
||||
import { generateUrl } from "@nextcloud/router";
|
||||
import { getCurrentUser } from "@nextcloud/auth";
|
||||
|
||||
import Timeline from './components/Timeline.vue'
|
||||
import Settings from './components/Settings.vue'
|
||||
import FirstStart from './components/FirstStart.vue'
|
||||
import GlobalMixin from './mixins/GlobalMixin';
|
||||
import UserConfig from './mixins/UserConfig';
|
||||
import Timeline from "./components/Timeline.vue";
|
||||
import Settings from "./components/Settings.vue";
|
||||
import FirstStart from "./components/FirstStart.vue";
|
||||
import GlobalMixin from "./mixins/GlobalMixin";
|
||||
import UserConfig from "./mixins/UserConfig";
|
||||
|
||||
import ImageMultiple from 'vue-material-design-icons/ImageMultiple.vue'
|
||||
import FolderIcon from 'vue-material-design-icons/Folder.vue'
|
||||
import Star from 'vue-material-design-icons/Star.vue'
|
||||
import Video from 'vue-material-design-icons/Video.vue'
|
||||
import AlbumIcon from 'vue-material-design-icons/ImageAlbum.vue';
|
||||
import ArchiveIcon from 'vue-material-design-icons/PackageDown.vue';
|
||||
import CalendarIcon from 'vue-material-design-icons/Calendar.vue';
|
||||
import PeopleIcon from 'vue-material-design-icons/AccountBoxMultiple.vue';
|
||||
import TagsIcon from 'vue-material-design-icons/Tag.vue';
|
||||
import MapIcon from 'vue-material-design-icons/Map.vue';
|
||||
import ImageMultiple from "vue-material-design-icons/ImageMultiple.vue";
|
||||
import FolderIcon from "vue-material-design-icons/Folder.vue";
|
||||
import Star from "vue-material-design-icons/Star.vue";
|
||||
import Video from "vue-material-design-icons/Video.vue";
|
||||
import AlbumIcon from "vue-material-design-icons/ImageAlbum.vue";
|
||||
import ArchiveIcon from "vue-material-design-icons/PackageDown.vue";
|
||||
import CalendarIcon from "vue-material-design-icons/Calendar.vue";
|
||||
import PeopleIcon from "vue-material-design-icons/AccountBoxMultiple.vue";
|
||||
import TagsIcon from "vue-material-design-icons/Tag.vue";
|
||||
import MapIcon from "vue-material-design-icons/Map.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcContent,
|
||||
NcAppContent,
|
||||
NcAppNavigation,
|
||||
NcAppNavigationItem,
|
||||
NcAppNavigationSettings,
|
||||
components: {
|
||||
NcContent,
|
||||
NcAppContent,
|
||||
NcAppNavigation,
|
||||
NcAppNavigationItem,
|
||||
NcAppNavigationSettings,
|
||||
|
||||
Timeline,
|
||||
Settings,
|
||||
FirstStart,
|
||||
Timeline,
|
||||
Settings,
|
||||
FirstStart,
|
||||
|
||||
ImageMultiple,
|
||||
FolderIcon,
|
||||
Star,
|
||||
Video,
|
||||
AlbumIcon,
|
||||
ArchiveIcon,
|
||||
CalendarIcon,
|
||||
PeopleIcon,
|
||||
TagsIcon,
|
||||
MapIcon,
|
||||
},
|
||||
ImageMultiple,
|
||||
FolderIcon,
|
||||
Star,
|
||||
Video,
|
||||
AlbumIcon,
|
||||
ArchiveIcon,
|
||||
CalendarIcon,
|
||||
PeopleIcon,
|
||||
TagsIcon,
|
||||
MapIcon,
|
||||
},
|
||||
})
|
||||
export default class App extends Mixins(GlobalMixin, UserConfig) {
|
||||
// Outer element
|
||||
// Outer element
|
||||
|
||||
get ncVersion() {
|
||||
const version = (<any>window.OC).config.version.split('.');
|
||||
return Number(version[0]);
|
||||
get ncVersion() {
|
||||
const version = (<any>window.OC).config.version.split(".");
|
||||
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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.outer {
|
||||
padding: 0 0 0 44px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0 0 0 44px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.outer {
|
||||
padding: 0px;
|
||||
.outer {
|
||||
padding: 0px;
|
||||
|
||||
// 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
|
||||
margin-left: -1px;
|
||||
width: calc(100% + 3px); // 1px extra here because ... reasons
|
||||
}
|
||||
// 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
|
||||
margin-left: -1px;
|
||||
width: calc(100% + 3px); // 1px extra here because ... reasons
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
body {
|
||||
overflow: hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Nextcloud 25+: get rid of gap and border radius at right
|
||||
#content-vue.remove-gap {
|
||||
// was var(--body-container-radius)
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
// was var(--body-container-radius)
|
||||
border-top-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
|
||||
#content-vue {
|
||||
max-height: 100vh;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
// Patch viewer to remove the title and
|
||||
// make the image fill the entire screen
|
||||
.viewer {
|
||||
.modal-title {
|
||||
display: none;
|
||||
}
|
||||
.modal-wrapper .modal-container {
|
||||
top: 0 !important;
|
||||
bottom: 0 !important;
|
||||
}
|
||||
.modal-title {
|
||||
display: none;
|
||||
}
|
||||
.modal-wrapper .modal-container {
|
||||
top: 0 !important;
|
||||
bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Hide horizontal scrollbar on mobile
|
||||
// For the padding removal above
|
||||
#app-content-vue {
|
||||
overflow-x: hidden;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
// Fill all available space
|
||||
.fill-block {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,176 +1,189 @@
|
|||
<template>
|
||||
<NcContent app-name="memories">
|
||||
<NcAppContent>
|
||||
<div class="outer fill-block" :class="{ show }">
|
||||
<div class="title">
|
||||
<img :src="banner" />
|
||||
</div>
|
||||
<NcContent app-name="memories">
|
||||
<NcAppContent>
|
||||
<div class="outer fill-block" :class="{ show }">
|
||||
<div class="title">
|
||||
<img :src="banner" />
|
||||
</div>
|
||||
|
||||
<div class="text">
|
||||
{{ t('memories', 'A better photos experience awaits you') }} <br/>
|
||||
{{ t('memories', 'Choose the root folder of your timeline to begin') }}
|
||||
</div>
|
||||
<div class="text">
|
||||
{{ t("memories", "A better photos experience awaits you") }} <br />
|
||||
{{
|
||||
t("memories", "Choose the root folder of your timeline to begin")
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="admin-text" v-if="isAdmin">
|
||||
{{ t('memories', 'If you just installed Memories, run:') }}
|
||||
<br/>
|
||||
<code>occ memories:index</code>
|
||||
</div>
|
||||
<div class="admin-text" v-if="isAdmin">
|
||||
{{ t("memories", "If you just installed Memories, run:") }}
|
||||
<br />
|
||||
<code>occ memories:index</code>
|
||||
</div>
|
||||
|
||||
<div class="error" v-if="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div class="error" v-if="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="info" v-if="info">
|
||||
{{ info }} <br/>
|
||||
<div class="info" v-if="info">
|
||||
{{ info }} <br />
|
||||
|
||||
<NcButton @click="finish" class="button" type="primary">
|
||||
{{ t('memories', 'Continue to Memories') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
<NcButton @click="finish" class="button" type="primary">
|
||||
{{ t("memories", "Continue to Memories") }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<NcButton @click="begin" class="button" v-if="info">
|
||||
{{ t('memories', 'Choose again') }}
|
||||
</NcButton>
|
||||
<NcButton @click="begin" class="button" type="primary" v-else>
|
||||
{{ t('memories', 'Click here to start') }}
|
||||
</NcButton>
|
||||
<NcButton @click="begin" class="button" v-if="info">
|
||||
{{ t("memories", "Choose again") }}
|
||||
</NcButton>
|
||||
<NcButton @click="begin" class="button" type="primary" v-else>
|
||||
{{ t("memories", "Click here to start") }}
|
||||
</NcButton>
|
||||
|
||||
<div class="footer">
|
||||
{{ t('memories', 'You can always change this later in settings') }}
|
||||
</div>
|
||||
</div>
|
||||
</NcAppContent>
|
||||
</NcContent>
|
||||
<div class="footer">
|
||||
{{ t("memories", "You can always change this later in settings") }}
|
||||
</div>
|
||||
</div>
|
||||
</NcAppContent>
|
||||
</NcContent>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Mixins } from 'vue-property-decorator';
|
||||
import { NcContent, NcAppContent, NcButton } from '@nextcloud/vue';
|
||||
import { getFilePickerBuilder } from '@nextcloud/dialogs'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { getCurrentUser } from '@nextcloud/auth';
|
||||
import axios from '@nextcloud/axios'
|
||||
import { Component, Mixins } from "vue-property-decorator";
|
||||
import { NcContent, NcAppContent, NcButton } from "@nextcloud/vue";
|
||||
import { getFilePickerBuilder } from "@nextcloud/dialogs";
|
||||
import { generateUrl } from "@nextcloud/router";
|
||||
import { getCurrentUser } from "@nextcloud/auth";
|
||||
import axios from "@nextcloud/axios";
|
||||
|
||||
import GlobalMixin from '../mixins/GlobalMixin';
|
||||
import UserConfig from '../mixins/UserConfig';
|
||||
import GlobalMixin from "../mixins/GlobalMixin";
|
||||
import UserConfig from "../mixins/UserConfig";
|
||||
|
||||
import banner from "../assets/banner.svg";
|
||||
import { IDay } from '../types';
|
||||
import { IDay } from "../types";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcContent,
|
||||
NcAppContent,
|
||||
NcButton,
|
||||
},
|
||||
components: {
|
||||
NcContent,
|
||||
NcAppContent,
|
||||
NcButton,
|
||||
},
|
||||
})
|
||||
export default class FirstStart extends Mixins(GlobalMixin, UserConfig) {
|
||||
banner = banner;
|
||||
error = '';
|
||||
info = ''
|
||||
show = false;
|
||||
chosenPath = '';
|
||||
banner = banner;
|
||||
error = "";
|
||||
info = "";
|
||||
show = false;
|
||||
chosenPath = "";
|
||||
|
||||
mounted() {
|
||||
window.setTimeout(() => {
|
||||
this.show = true;
|
||||
}, 300);
|
||||
mounted() {
|
||||
window.setTimeout(() => {
|
||||
this.show = true;
|
||||
}, 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() {
|
||||
return getCurrentUser().isAdmin;
|
||||
}
|
||||
// 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 begin() {
|
||||
const path = await this.chooseFolder(this.t('memories', 'Choose the root of your timeline'), '/');
|
||||
async finish() {
|
||||
this.show = false;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
this.config_timelinePath = this.chosenPath;
|
||||
await this.updateSetting("timelinePath");
|
||||
}
|
||||
|
||||
// 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);
|
||||
async chooseFolder(title: string, initial: string) {
|
||||
const picker = getFilePickerBuilder(title)
|
||||
.setMultiSelect(false)
|
||||
.setModal(true)
|
||||
.setType(1)
|
||||
.addMimeTypeFilter("httpd/unix-directory")
|
||||
.allowDirectories()
|
||||
.startAt(initial)
|
||||
.build();
|
||||
|
||||
// Check response
|
||||
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();
|
||||
}
|
||||
return await picker.pick();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.outer {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
|
||||
transition: opacity 1s ease;
|
||||
opacity: 0;
|
||||
&.show { opacity: 1; }
|
||||
transition: opacity 1s ease;
|
||||
opacity: 0;
|
||||
&.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2.8em;
|
||||
line-height: 1.1em;
|
||||
font-family: cursive;
|
||||
font-weight: 500;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
filter: var(--background-invert-if-dark);
|
||||
.title {
|
||||
font-size: 2.8em;
|
||||
line-height: 1.1em;
|
||||
font-family: cursive;
|
||||
font-weight: 500;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
filter: var(--background-invert-if-dark);
|
||||
|
||||
> img {
|
||||
max-width: calc(100vw - 40px);
|
||||
}
|
||||
> img {
|
||||
max-width: calc(100vw - 40px);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-text {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.admin-text {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
}
|
||||
.error {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.info {
|
||||
margin-top: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.info {
|
||||
margin-top: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
margin: 15px;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
margin: 15px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.footer {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
</style>
|
File diff suppressed because it is too large
Load Diff
|
@ -1,444 +1,513 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="selection.size > 0" class="top-bar">
|
||||
<NcActions>
|
||||
<NcActionButton
|
||||
:aria-label="t('memories', 'Cancel')"
|
||||
@click="clearSelection()">
|
||||
{{ t('memories', 'Cancel') }}
|
||||
<template #icon> <CloseIcon :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
<div>
|
||||
<div v-if="selection.size > 0" class="top-bar">
|
||||
<NcActions>
|
||||
<NcActionButton
|
||||
:aria-label="t('memories', 'Cancel')"
|
||||
@click="clearSelection()"
|
||||
>
|
||||
{{ t("memories", "Cancel") }}
|
||||
<template #icon> <CloseIcon :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
|
||||
<div class="text">
|
||||
{{ n("memories", "{n} selected", "{n} selected", selection.size, { n: selection.size }) }}
|
||||
</div>
|
||||
<div class="text">
|
||||
{{
|
||||
n("memories", "{n} selected", "{n} selected", selection.size, {
|
||||
n: selection.size,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
|
||||
<NcActions :inline="1">
|
||||
<NcActionButton v-for="action of getActions()" :key="action.name"
|
||||
:aria-label="action.name" close-after-click
|
||||
@click="click(action)">
|
||||
{{ action.name }}
|
||||
<template #icon> <component :is="action.icon" :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
</div>
|
||||
|
||||
<!-- Selection Modals -->
|
||||
<EditDate ref="editDate" @refresh="refresh" />
|
||||
<FaceMoveModal ref="faceMoveModal" @moved="deletePhotos" :updateLoading="updateLoading" />
|
||||
<AddToAlbumModal ref="addToAlbumModal" @added="clearSelection" />
|
||||
<NcActions :inline="1">
|
||||
<NcActionButton
|
||||
v-for="action of getActions()"
|
||||
:key="action.name"
|
||||
:aria-label="action.name"
|
||||
close-after-click
|
||||
@click="click(action)"
|
||||
>
|
||||
{{ action.name }}
|
||||
<template #icon>
|
||||
<component :is="action.icon" :size="20" />
|
||||
</template>
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
</div>
|
||||
|
||||
<!-- Selection Modals -->
|
||||
<EditDate ref="editDate" @refresh="refresh" />
|
||||
<FaceMoveModal
|
||||
ref="faceMoveModal"
|
||||
@moved="deletePhotos"
|
||||
:updateLoading="updateLoading"
|
||||
/>
|
||||
<AddToAlbumModal ref="addToAlbumModal" @added="clearSelection" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator';
|
||||
import GlobalMixin from '../mixins/GlobalMixin';
|
||||
import UserConfig from '../mixins/UserConfig';
|
||||
import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
|
||||
import GlobalMixin from "../mixins/GlobalMixin";
|
||||
import UserConfig from "../mixins/UserConfig";
|
||||
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { NcActions, NcActionButton } from '@nextcloud/vue';
|
||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
||||
import { IHeadRow, IPhoto, ISelectionAction } from '../types';
|
||||
import { getCurrentUser } from '@nextcloud/auth';
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
import { generateUrl } from "@nextcloud/router";
|
||||
import { NcActions, NcActionButton } from "@nextcloud/vue";
|
||||
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
|
||||
import { IHeadRow, IPhoto, ISelectionAction } from "../types";
|
||||
import { getCurrentUser } from "@nextcloud/auth";
|
||||
|
||||
import * as dav from "../services/DavRequests";
|
||||
import EditDate from "./modal/EditDate.vue"
|
||||
import FaceMoveModal from "./modal/FaceMoveModal.vue"
|
||||
import AddToAlbumModal from "./modal/AddToAlbumModal.vue"
|
||||
import EditDate from "./modal/EditDate.vue";
|
||||
import FaceMoveModal from "./modal/FaceMoveModal.vue";
|
||||
import AddToAlbumModal from "./modal/AddToAlbumModal.vue";
|
||||
|
||||
import StarIcon from 'vue-material-design-icons/Star.vue';
|
||||
import DownloadIcon from 'vue-material-design-icons/Download.vue';
|
||||
import DeleteIcon from 'vue-material-design-icons/Delete.vue';
|
||||
import EditIcon from 'vue-material-design-icons/ClockEdit.vue';
|
||||
import ArchiveIcon from 'vue-material-design-icons/PackageDown.vue';
|
||||
import UnarchiveIcon from 'vue-material-design-icons/PackageUp.vue';
|
||||
import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue';
|
||||
import CloseIcon from 'vue-material-design-icons/Close.vue';
|
||||
import MoveIcon from 'vue-material-design-icons/ImageMove.vue';
|
||||
import AlbumsIcon from 'vue-material-design-icons/ImageAlbum.vue';
|
||||
import AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue';
|
||||
import StarIcon from "vue-material-design-icons/Star.vue";
|
||||
import DownloadIcon from "vue-material-design-icons/Download.vue";
|
||||
import DeleteIcon from "vue-material-design-icons/Delete.vue";
|
||||
import EditIcon from "vue-material-design-icons/ClockEdit.vue";
|
||||
import ArchiveIcon from "vue-material-design-icons/PackageDown.vue";
|
||||
import UnarchiveIcon from "vue-material-design-icons/PackageUp.vue";
|
||||
import OpenInNewIcon from "vue-material-design-icons/OpenInNew.vue";
|
||||
import CloseIcon from "vue-material-design-icons/Close.vue";
|
||||
import MoveIcon from "vue-material-design-icons/ImageMove.vue";
|
||||
import AlbumsIcon from "vue-material-design-icons/ImageAlbum.vue";
|
||||
import AlbumRemoveIcon from "vue-material-design-icons/BookRemove.vue";
|
||||
|
||||
type Selection = Map<number, IPhoto>;
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
EditDate,
|
||||
FaceMoveModal,
|
||||
AddToAlbumModal,
|
||||
components: {
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
EditDate,
|
||||
FaceMoveModal,
|
||||
AddToAlbumModal,
|
||||
|
||||
CloseIcon,
|
||||
},
|
||||
CloseIcon,
|
||||
},
|
||||
})
|
||||
export default class SelectionHandler extends Mixins(GlobalMixin, UserConfig) {
|
||||
@Prop() public selection: Selection;
|
||||
@Prop() public heads: { [dayid: number]: IHeadRow };
|
||||
@Prop() public selection: Selection;
|
||||
@Prop() public heads: { [dayid: number]: IHeadRow };
|
||||
|
||||
private readonly defaultActions: ISelectionAction[];
|
||||
private readonly defaultActions: ISelectionAction[];
|
||||
|
||||
@Emit('refresh')
|
||||
refresh() {}
|
||||
@Emit("refresh")
|
||||
refresh() {}
|
||||
|
||||
@Emit('delete')
|
||||
deletePhotos(photos: IPhoto[]) {}
|
||||
@Emit("delete")
|
||||
deletePhotos(photos: IPhoto[]) {}
|
||||
|
||||
@Emit('updateLoading')
|
||||
updateLoading(delta: number) {}
|
||||
@Emit("updateLoading")
|
||||
updateLoading(delta: number) {}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Make default actions
|
||||
this.defaultActions = [
|
||||
{ // This is at the top because otherwise it is confusing
|
||||
name: t('memories', 'Remove from album'),
|
||||
icon: AlbumRemoveIcon,
|
||||
callback: this.removeFromAlbum.bind(this),
|
||||
if: () => this.$route.name === 'albums',
|
||||
},
|
||||
{
|
||||
name: t('memories', 'Delete'),
|
||||
icon: DeleteIcon,
|
||||
callback: this.deleteSelection.bind(this),
|
||||
},
|
||||
{
|
||||
name: t('memories', 'Download'),
|
||||
icon: DownloadIcon,
|
||||
callback: this.downloadSelection.bind(this),
|
||||
},
|
||||
{
|
||||
name: t('memories', 'Favorite'),
|
||||
icon: StarIcon,
|
||||
callback: this.favoriteSelection.bind(this),
|
||||
},
|
||||
{
|
||||
name: t('memories', 'Archive'),
|
||||
icon: ArchiveIcon,
|
||||
callback: this.archiveSelection.bind(this),
|
||||
if: () => this.allowArchive() && !this.routeIsArchive(),
|
||||
},
|
||||
{
|
||||
name: t('memories', 'Unarchive'),
|
||||
icon: UnarchiveIcon,
|
||||
callback: this.archiveSelection.bind(this),
|
||||
if: () => this.allowArchive() && this.routeIsArchive(),
|
||||
},
|
||||
{
|
||||
name: t('memories', 'Edit Date/Time'),
|
||||
icon: EditIcon,
|
||||
callback: this.editDateSelection.bind(this),
|
||||
},
|
||||
{
|
||||
name: t('memories', 'View in folder'),
|
||||
icon: OpenInNewIcon,
|
||||
callback: this.viewInFolder.bind(this),
|
||||
if: () => this.selection.size === 1,
|
||||
},
|
||||
{
|
||||
name: t('memories', 'Add to album'),
|
||||
icon: AlbumsIcon,
|
||||
callback: this.addToAlbum.bind(this),
|
||||
if: (self: any) => self.config_albumsEnabled,
|
||||
},
|
||||
{
|
||||
name: t('memories', 'Move to another person'),
|
||||
icon: MoveIcon,
|
||||
callback: this.moveSelectionToPerson.bind(this),
|
||||
if: () => this.$route.name === 'people',
|
||||
},
|
||||
{
|
||||
name: t('memories', 'Remove from person'),
|
||||
icon: CloseIcon,
|
||||
callback: this.removeSelectionFromPerson.bind(this),
|
||||
if: () => this.$route.name === 'people',
|
||||
},
|
||||
];
|
||||
// Make default actions
|
||||
this.defaultActions = [
|
||||
{
|
||||
// This is at the top because otherwise it is confusing
|
||||
name: t("memories", "Remove from album"),
|
||||
icon: AlbumRemoveIcon,
|
||||
callback: this.removeFromAlbum.bind(this),
|
||||
if: () => this.$route.name === "albums",
|
||||
},
|
||||
{
|
||||
name: t("memories", "Delete"),
|
||||
icon: DeleteIcon,
|
||||
callback: this.deleteSelection.bind(this),
|
||||
},
|
||||
{
|
||||
name: t("memories", "Download"),
|
||||
icon: DownloadIcon,
|
||||
callback: this.downloadSelection.bind(this),
|
||||
},
|
||||
{
|
||||
name: t("memories", "Favorite"),
|
||||
icon: StarIcon,
|
||||
callback: this.favoriteSelection.bind(this),
|
||||
},
|
||||
{
|
||||
name: t("memories", "Archive"),
|
||||
icon: ArchiveIcon,
|
||||
callback: this.archiveSelection.bind(this),
|
||||
if: () => this.allowArchive() && !this.routeIsArchive(),
|
||||
},
|
||||
{
|
||||
name: t("memories", "Unarchive"),
|
||||
icon: UnarchiveIcon,
|
||||
callback: this.archiveSelection.bind(this),
|
||||
if: () => this.allowArchive() && this.routeIsArchive(),
|
||||
},
|
||||
{
|
||||
name: t("memories", "Edit Date/Time"),
|
||||
icon: EditIcon,
|
||||
callback: this.editDateSelection.bind(this),
|
||||
},
|
||||
{
|
||||
name: t("memories", "View in folder"),
|
||||
icon: OpenInNewIcon,
|
||||
callback: this.viewInFolder.bind(this),
|
||||
if: () => this.selection.size === 1,
|
||||
},
|
||||
{
|
||||
name: t("memories", "Add to album"),
|
||||
icon: AlbumsIcon,
|
||||
callback: this.addToAlbum.bind(this),
|
||||
if: (self: any) => self.config_albumsEnabled,
|
||||
},
|
||||
{
|
||||
name: t("memories", "Move to another person"),
|
||||
icon: MoveIcon,
|
||||
callback: this.moveSelectionToPerson.bind(this),
|
||||
if: () => this.$route.name === "people",
|
||||
},
|
||||
{
|
||||
name: t("memories", "Remove from person"),
|
||||
icon: CloseIcon,
|
||||
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 */
|
||||
private async click(action: ISelectionAction) {
|
||||
try {
|
||||
this.updateLoading(1);
|
||||
await action.callback(this.selection);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.updateLoading(-1);
|
||||
/** Get the actions list */
|
||||
private getActions(): ISelectionAction[] {
|
||||
return this.defaultActions.filter((a) => !a.if || a.if(this));
|
||||
}
|
||||
|
||||
/** Clear all selected photos */
|
||||
public clearSelection(only?: IPhoto[]) {
|
||||
const heads = new Set<IHeadRow>();
|
||||
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 */
|
||||
private getActions(): ISelectionAction[] {
|
||||
return this.defaultActions.filter(a => !a.if || a.if(this));
|
||||
// Update head
|
||||
head.selected = selected;
|
||||
}
|
||||
|
||||
/** 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 */
|
||||
public clearSelection(only?: IPhoto[]) {
|
||||
const heads = new Set<IHeadRow>();
|
||||
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();
|
||||
const nval = val ?? !this.selection.has(photo.fileid);
|
||||
if (nval) {
|
||||
photo.flag |= this.c.FLAG_SELECTED;
|
||||
this.selection.set(photo.fileid, photo);
|
||||
} else {
|
||||
photo.flag &= ~this.c.FLAG_SELECTED;
|
||||
this.selection.delete(photo.fileid);
|
||||
}
|
||||
|
||||
/** Check if the day for a photo is selected entirely */
|
||||
private updateHeadSelected(head: IHeadRow) {
|
||||
let selected = true;
|
||||
if (!noUpdate) {
|
||||
this.updateHeadSelected(this.heads[photo.d.dayid]);
|
||||
this.$forceUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
/** Select or deselect all photos in a head */
|
||||
public selectHead(head: IHeadRow) {
|
||||
head.selected = !head.selected;
|
||||
for (const row of head.day.rows) {
|
||||
for (const photo of row.photos) {
|
||||
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
|
||||
head.selected = selected;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
if (val) {
|
||||
photo.flag |= this.c.FLAG_IS_FAVORITE;
|
||||
} else {
|
||||
photo.flag &= ~this.c.FLAG_SELECTED;
|
||||
this.selection.delete(photo.fileid);
|
||||
photo.flag &= ~this.c.FLAG_IS_FAVORITE;
|
||||
}
|
||||
});
|
||||
}
|
||||
this.clearSelection();
|
||||
}
|
||||
|
||||
if (!noUpdate) {
|
||||
this.updateHeadSelected(this.heads[photo.d.dayid]);
|
||||
this.$forceUpdate();
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Select or deselect all photos in a head */
|
||||
public selectHead(head: IHeadRow) {
|
||||
head.selected = !head.selected;
|
||||
for (const row of head.day.rows) {
|
||||
for (const photo of row.photos) {
|
||||
this.selectPhoto(photo, head.selected, true);
|
||||
}
|
||||
}
|
||||
this.$forceUpdate();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()));
|
||||
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 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);
|
||||
// Check photo ownership
|
||||
if (this.$route.params.user !== getCurrentUser().uid) {
|
||||
showError(
|
||||
this.t("memories", 'Only user "{user}" can update this person', {
|
||||
user,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
// 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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.top-bar {
|
||||
position: absolute;
|
||||
top: 10px; right: 60px;
|
||||
padding: 8px;
|
||||
width: 400px;
|
||||
max-width: calc(100vw - 30px);
|
||||
background-color: var(--color-main-background);
|
||||
box-shadow: 0 0 2px gray;
|
||||
border-radius: 10px;
|
||||
opacity: 0.95;
|
||||
display: flex;
|
||||
vertical-align: middle;
|
||||
z-index: 100;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 60px;
|
||||
padding: 8px;
|
||||
width: 400px;
|
||||
max-width: calc(100vw - 30px);
|
||||
background-color: var(--color-main-background);
|
||||
box-shadow: 0 0 2px gray;
|
||||
border-radius: 10px;
|
||||
opacity: 0.95;
|
||||
display: flex;
|
||||
vertical-align: middle;
|
||||
z-index: 100;
|
||||
|
||||
> .text {
|
||||
flex-grow: 1;
|
||||
line-height: 40px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
> .text {
|
||||
flex-grow: 1;
|
||||
line-height: 40px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
top: 35px; right: 15px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
top: 35px;
|
||||
right: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -21,89 +21,103 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<label for="timeline-path">{{ t('memories', 'Timeline Path') }}</label>
|
||||
<input id="timeline-path"
|
||||
@click="chooseTimelinePath"
|
||||
v-model="config_timelinePath"
|
||||
type="text">
|
||||
<div>
|
||||
<label for="timeline-path">{{ t("memories", "Timeline Path") }}</label>
|
||||
<input
|
||||
id="timeline-path"
|
||||
@click="chooseTimelinePath"
|
||||
v-model="config_timelinePath"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<label for="folders-path">{{ t('memories', 'Folders Path') }}</label>
|
||||
<input id="folders-path"
|
||||
@click="chooseFoldersPath"
|
||||
v-model="config_foldersPath"
|
||||
type="text">
|
||||
<label for="folders-path">{{ t("memories", "Folders Path") }}</label>
|
||||
<input
|
||||
id="folders-path"
|
||||
@click="chooseFoldersPath"
|
||||
v-model="config_foldersPath"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<NcCheckboxRadioSwitch :checked.sync="config_showHidden"
|
||||
@update:checked="updateShowHidden"
|
||||
type="switch">
|
||||
{{ t('memories', 'Show hidden folders') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<NcCheckboxRadioSwitch
|
||||
:checked.sync="config_showHidden"
|
||||
@update:checked="updateShowHidden"
|
||||
type="switch"
|
||||
>
|
||||
{{ t("memories", "Show hidden folders") }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
|
||||
<NcCheckboxRadioSwitch :checked.sync="config_squareThumbs"
|
||||
@update:checked="updateSquareThumbs"
|
||||
type="switch">
|
||||
{{ t('memories', 'Square grid mode') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
<NcCheckboxRadioSwitch
|
||||
:checked.sync="config_squareThumbs"
|
||||
@update:checked="updateSquareThumbs"
|
||||
type="switch"
|
||||
>
|
||||
{{ t("memories", "Square grid mode") }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
input[type=text] {
|
||||
width: 100%;
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Mixins } from 'vue-property-decorator';
|
||||
import GlobalMixin from '../mixins/GlobalMixin';
|
||||
import { Component, Mixins } from "vue-property-decorator";
|
||||
import GlobalMixin from "../mixins/GlobalMixin";
|
||||
|
||||
import { getFilePickerBuilder } from '@nextcloud/dialogs'
|
||||
import UserConfig from '../mixins/UserConfig'
|
||||
import { getFilePickerBuilder } from "@nextcloud/dialogs";
|
||||
import UserConfig from "../mixins/UserConfig";
|
||||
|
||||
import { NcCheckboxRadioSwitch } from '@nextcloud/vue'
|
||||
import { NcCheckboxRadioSwitch } from "@nextcloud/vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcCheckboxRadioSwitch,
|
||||
},
|
||||
components: {
|
||||
NcCheckboxRadioSwitch,
|
||||
},
|
||||
})
|
||||
export default class Settings extends Mixins(UserConfig, GlobalMixin) {
|
||||
async chooseFolder(title: string, initial: string) {
|
||||
const picker = getFilePickerBuilder(title)
|
||||
.setMultiSelect(false)
|
||||
.setModal(true)
|
||||
.setType(1)
|
||||
.addMimeTypeFilter('httpd/unix-directory')
|
||||
.allowDirectories()
|
||||
.startAt(initial)
|
||||
.build()
|
||||
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();
|
||||
}
|
||||
return await picker.pick();
|
||||
}
|
||||
|
||||
async chooseTimelinePath() {
|
||||
const newPath = await this.chooseFolder(this.t('memories', 'Choose the root of your timeline'), this.config_timelinePath);
|
||||
if (newPath !== this.config_timelinePath) {
|
||||
this.config_timelinePath = newPath;
|
||||
await this.updateSetting('timelinePath');
|
||||
}
|
||||
async chooseTimelinePath() {
|
||||
const newPath = await this.chooseFolder(
|
||||
this.t("memories", "Choose the root of your timeline"),
|
||||
this.config_timelinePath
|
||||
);
|
||||
if (newPath !== this.config_timelinePath) {
|
||||
this.config_timelinePath = newPath;
|
||||
await this.updateSetting("timelinePath");
|
||||
}
|
||||
}
|
||||
|
||||
async chooseFoldersPath() {
|
||||
const newPath = await this.chooseFolder(this.t('memories', 'Choose the root for the folders view'), this.config_foldersPath);
|
||||
if (newPath !== this.config_foldersPath) {
|
||||
this.config_foldersPath = newPath;
|
||||
await this.updateSetting('foldersPath');
|
||||
}
|
||||
async chooseFoldersPath() {
|
||||
const newPath = await this.chooseFolder(
|
||||
this.t("memories", "Choose the root for the folders view"),
|
||||
this.config_foldersPath
|
||||
);
|
||||
if (newPath !== this.config_foldersPath) {
|
||||
this.config_foldersPath = newPath;
|
||||
await this.updateSetting("foldersPath");
|
||||
}
|
||||
}
|
||||
|
||||
async updateSquareThumbs() {
|
||||
await this.updateSetting('squareThumbs');
|
||||
}
|
||||
async updateSquareThumbs() {
|
||||
await this.updateSetting("squareThumbs");
|
||||
}
|
||||
|
||||
async updateShowHidden() {
|
||||
await this.updateSetting('showHidden');
|
||||
}
|
||||
async updateShowHidden() {
|
||||
await this.updateSetting("showHidden");
|
||||
}
|
||||
}
|
||||
</script>
|
File diff suppressed because it is too large
Load Diff
|
@ -1,202 +1,238 @@
|
|||
<template>
|
||||
<router-link class="folder fill-block" :class="{
|
||||
hasPreview: previewFileInfos.length > 0,
|
||||
onePreview: previewFileInfos.length === 1,
|
||||
hasError: error,
|
||||
<router-link
|
||||
class="folder fill-block"
|
||||
:class="{
|
||||
hasPreview: previewFileInfos.length > 0,
|
||||
onePreview: previewFileInfos.length === 1,
|
||||
hasError: error,
|
||||
}"
|
||||
:to="target">
|
||||
<div class="big-icon fill-block">
|
||||
<FolderIcon class="icon" />
|
||||
<div class="name">{{ data.name }}</div>
|
||||
</div>
|
||||
:to="target"
|
||||
>
|
||||
<div class="big-icon fill-block">
|
||||
<FolderIcon class="icon" />
|
||||
<div class="name">{{ data.name }}</div>
|
||||
</div>
|
||||
|
||||
<div class="previews fill-block">
|
||||
<div class="img-outer" v-for="info of previewFileInfos" :key="info.fileid">
|
||||
<img
|
||||
class="fill-block"
|
||||
:class="{ 'error': info.flag & c.FLAG_LOAD_FAIL }"
|
||||
:key="'fpreview-' + info.fileid"
|
||||
:src="getPreviewUrl(info.fileid, info.etag, true, 256)"
|
||||
@error="info.flag |= c.FLAG_LOAD_FAIL" />
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
<div class="previews fill-block">
|
||||
<div
|
||||
class="img-outer"
|
||||
v-for="info of previewFileInfos"
|
||||
:key="info.fileid"
|
||||
>
|
||||
<img
|
||||
class="fill-block"
|
||||
:class="{ error: info.flag & c.FLAG_LOAD_FAIL }"
|
||||
:key="'fpreview-' + info.fileid"
|
||||
:src="getPreviewUrl(info.fileid, info.etag, true, 256)"
|
||||
@error="info.flag |= c.FLAG_LOAD_FAIL"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Watch, Mixins } from 'vue-property-decorator';
|
||||
import { IFileInfo, IFolder } from '../../types';
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import UserConfig from '../../mixins/UserConfig';
|
||||
import { Component, Prop, Watch, Mixins } from "vue-property-decorator";
|
||||
import { IFileInfo, IFolder } from "../../types";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
import UserConfig from "../../mixins/UserConfig";
|
||||
|
||||
import * as dav from "../../services/DavRequests";
|
||||
import { getPreviewUrl } from "../../services/FileUtils";
|
||||
|
||||
import FolderIcon from 'vue-material-design-icons/Folder.vue';
|
||||
import FolderIcon from "vue-material-design-icons/Folder.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
FolderIcon,
|
||||
},
|
||||
components: {
|
||||
FolderIcon,
|
||||
},
|
||||
})
|
||||
export default class Folder extends Mixins(GlobalMixin, UserConfig) {
|
||||
@Prop() data: IFolder;
|
||||
@Prop() data: IFolder;
|
||||
|
||||
// Separate property because the one on data isn't reactive
|
||||
private previewFileInfos: IFileInfo[] = [];
|
||||
// Separate property because the one on data isn't reactive
|
||||
private previewFileInfos: IFileInfo[] = [];
|
||||
|
||||
// Error occured fetching thumbs
|
||||
private error = false;
|
||||
// Error occured fetching thumbs
|
||||
private error = false;
|
||||
|
||||
/** Passthrough */
|
||||
private getPreviewUrl = getPreviewUrl;
|
||||
/** Passthrough */
|
||||
private getPreviewUrl = getPreviewUrl;
|
||||
|
||||
mounted() {
|
||||
this.refreshPreviews();
|
||||
mounted() {
|
||||
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')
|
||||
dataChanged() {
|
||||
this.refreshPreviews();
|
||||
// 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);
|
||||
}
|
||||
|
||||
/** Refresh previews */
|
||||
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 }};
|
||||
}
|
||||
return { name: "folders", params: { path: path as any } };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.folder {
|
||||
cursor: pointer;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
top: 0; left: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
top: 65%;
|
||||
}
|
||||
|
||||
:deep .material-design-icon__svg {
|
||||
width: 50%; height: 50%;
|
||||
// Make it white if there is a preview
|
||||
.folder.hasPreview > & {
|
||||
.folder-icon {
|
||||
opacity: 1;
|
||||
filter: invert(1) brightness(100);
|
||||
}
|
||||
|
||||
> .name {
|
||||
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%;
|
||||
.name {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
// Make it white if there is a preview
|
||||
.folder.hasPreview > & {
|
||||
.folder-icon {
|
||||
opacity: 1;
|
||||
filter: invert(1) brightness(100);
|
||||
}
|
||||
.name { color: white; }
|
||||
// Show it on hover if not a preview
|
||||
.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%);
|
||||
}
|
||||
|
||||
// Show it on hover if not a preview
|
||||
.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; }
|
||||
.name {
|
||||
color: #bb0000;
|
||||
}
|
||||
}
|
||||
|
||||
> .folder-icon {
|
||||
cursor: pointer;
|
||||
height: 90%; width: 100%;
|
||||
opacity: 0.3;
|
||||
}
|
||||
> .folder-icon {
|
||||
cursor: pointer;
|
||||
height: 90%;
|
||||
width: 100%;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.previews {
|
||||
z-index: 3;
|
||||
line-height: 0;
|
||||
position: absolute;
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
@media (max-width: 768px) { padding: 1px; }
|
||||
z-index: 3;
|
||||
line-height: 0;
|
||||
position: absolute;
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
@media (max-width: 768px) {
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
> .img-outer {
|
||||
background-color: var(--color-background-dark);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
display: inline-block;
|
||||
> .img-outer {
|
||||
background-color: var(--color-background-dark);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
display: inline-block;
|
||||
|
||||
.folder.onePreview > & {
|
||||
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%); }
|
||||
}
|
||||
.folder.onePreview > & {
|
||||
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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,194 +1,210 @@
|
|||
<template>
|
||||
<div class="p-outer fill-block"
|
||||
:class="{
|
||||
'selected': (data.flag & c.FLAG_SELECTED),
|
||||
'placeholder': (data.flag & c.FLAG_PLACEHOLDER),
|
||||
'leaving': (data.flag & c.FLAG_LEAVING),
|
||||
'error': (data.flag & c.FLAG_LOAD_FAIL),
|
||||
}">
|
||||
<div
|
||||
class="p-outer fill-block"
|
||||
:class="{
|
||||
selected: data.flag & c.FLAG_SELECTED,
|
||||
placeholder: data.flag & c.FLAG_PLACEHOLDER,
|
||||
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"
|
||||
v-if="!(data.flag & c.FLAG_PLACEHOLDER)"
|
||||
@click="toggleSelect" />
|
||||
<Video :size="20" v-if="data.flag & c.FLAG_IS_VIDEO" />
|
||||
<Star :size="20" v-if="data.flag & c.FLAG_IS_FAVORITE" />
|
||||
|
||||
<Video :size="20" v-if="data.flag & c.FLAG_IS_VIDEO" />
|
||||
<Star :size="20" v-if="data.flag & c.FLAG_IS_FAVORITE" />
|
||||
|
||||
<div class="img-outer fill-block"
|
||||
@click="emitClick"
|
||||
@contextmenu="contextmenu"
|
||||
@touchstart="touchstart"
|
||||
@touchmove="touchend"
|
||||
@touchend="touchend"
|
||||
@touchcancel="touchend" >
|
||||
<img
|
||||
ref="img"
|
||||
class="fill-block"
|
||||
:src="src"
|
||||
:key="data.fileid"
|
||||
@load="load"
|
||||
@error="error" />
|
||||
</div>
|
||||
<div
|
||||
class="img-outer fill-block"
|
||||
@click="emitClick"
|
||||
@contextmenu="contextmenu"
|
||||
@touchstart="touchstart"
|
||||
@touchmove="touchend"
|
||||
@touchend="touchend"
|
||||
@touchcancel="touchend"
|
||||
>
|
||||
<img
|
||||
ref="img"
|
||||
class="fill-block"
|
||||
:src="src"
|
||||
:key="data.fileid"
|
||||
@load="load"
|
||||
@error="error"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 { getPreviewUrl } from "../../services/FileUtils";
|
||||
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 Video from 'vue-material-design-icons/Video.vue';
|
||||
import Star from 'vue-material-design-icons/Star.vue';
|
||||
import Check from "vue-material-design-icons/Check.vue";
|
||||
import Video from "vue-material-design-icons/Video.vue";
|
||||
import Star from "vue-material-design-icons/Star.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
Check,
|
||||
Video,
|
||||
Star,
|
||||
},
|
||||
components: {
|
||||
Check,
|
||||
Video,
|
||||
Star,
|
||||
},
|
||||
})
|
||||
export default class Photo extends Mixins(GlobalMixin) {
|
||||
private touchTimer = 0;
|
||||
private src = null;
|
||||
private hasFaceRect = false;
|
||||
private touchTimer = 0;
|
||||
private src = null;
|
||||
private hasFaceRect = false;
|
||||
|
||||
@Prop() data: IPhoto;
|
||||
@Prop() day: IDay;
|
||||
@Prop() data: IPhoto;
|
||||
@Prop() day: IDay;
|
||||
|
||||
@Emit('select') emitSelect(data: IPhoto) {}
|
||||
@Emit('click') emitClick() {}
|
||||
@Emit("select") emitSelect(data: IPhoto) {}
|
||||
@Emit("click") emitClick() {}
|
||||
|
||||
@Watch('data')
|
||||
onDataChange(newData: IPhoto, oldData: IPhoto) {
|
||||
// Copy flags relevant to this component
|
||||
if (oldData && newData) {
|
||||
newData.flag |= oldData.flag & (this.c.FLAG_SELECTED | this.c.FLAG_LOAD_FAIL);
|
||||
}
|
||||
@Watch("data")
|
||||
onDataChange(newData: IPhoto, oldData: IPhoto) {
|
||||
// Copy flags relevant to this component
|
||||
if (oldData && newData) {
|
||||
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() {
|
||||
this.hasFaceRect = false;
|
||||
this.refresh();
|
||||
// Make the shorter dimension equal to base
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Make the shorter dimension equal to base
|
||||
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;
|
||||
}
|
||||
|
||||
return getPreviewUrl(this.data.fileid, this.data.etag, false, size)
|
||||
}
|
||||
|
||||
/** Set src with overlay face rect */
|
||||
async addFaceRect() {
|
||||
if (!this.data.facerect || this.hasFaceRect) return;
|
||||
this.hasFaceRect = true;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
const img = this.$refs.img as HTMLImageElement;
|
||||
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
context.drawImage(img, 0, 0);
|
||||
context.strokeStyle = '#00ff00';
|
||||
context.lineWidth = 2;
|
||||
context.strokeRect(
|
||||
this.data.facerect.x * img.naturalWidth,
|
||||
this.data.facerect.y * img.naturalHeight,
|
||||
this.data.facerect.w * img.naturalWidth,
|
||||
this.data.facerect.h * img.naturalHeight,
|
||||
);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
this.src = URL.createObjectURL(blob);
|
||||
}, 'image/jpeg', 0.95)
|
||||
}
|
||||
|
||||
/** Post load tasks */
|
||||
load() {
|
||||
this.addFaceRect();
|
||||
}
|
||||
|
||||
/** Error in loading image */
|
||||
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;
|
||||
}
|
||||
return getPreviewUrl(this.data.fileid, this.data.etag, false, size);
|
||||
}
|
||||
|
||||
/** Set src with overlay face rect */
|
||||
async addFaceRect() {
|
||||
if (!this.data.facerect || this.hasFaceRect) return;
|
||||
this.hasFaceRect = true;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d");
|
||||
const img = this.$refs.img as HTMLImageElement;
|
||||
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
context.drawImage(img, 0, 0);
|
||||
context.strokeStyle = "#00ff00";
|
||||
context.lineWidth = 2;
|
||||
context.strokeRect(
|
||||
this.data.facerect.x * img.naturalWidth,
|
||||
this.data.facerect.y * img.naturalHeight,
|
||||
this.data.facerect.w * img.naturalWidth,
|
||||
this.data.facerect.h * img.naturalHeight
|
||||
);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
this.src = URL.createObjectURL(blob);
|
||||
},
|
||||
"image/jpeg",
|
||||
0.95
|
||||
);
|
||||
}
|
||||
|
||||
/** Post load tasks */
|
||||
load() {
|
||||
this.addFaceRect();
|
||||
}
|
||||
|
||||
/** Error in loading image */
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* Container and selection */
|
||||
.p-outer {
|
||||
&.leaving {
|
||||
transition: all 0.2s ease-in;
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
&.leaving {
|
||||
transition: all 0.2s ease-in;
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Distance of icon from border
|
||||
|
@ -196,56 +212,75 @@ $icon-dist: min(10px, 6%);
|
|||
|
||||
/* Extra icons */
|
||||
.check-icon.select {
|
||||
position: absolute;
|
||||
top: $icon-dist; left: $icon-dist;
|
||||
z-index: 100;
|
||||
background-color: var(--color-main-background);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: $icon-dist;
|
||||
left: $icon-dist;
|
||||
z-index: 100;
|
||||
background-color: var(--color-main-background);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
|
||||
.p-outer:hover > & { display: flex; }
|
||||
.selected > & { display: flex; filter: invert(1); }
|
||||
.p-outer:hover > & {
|
||||
display: flex;
|
||||
}
|
||||
.selected > & {
|
||||
display: flex;
|
||||
filter: invert(1);
|
||||
}
|
||||
}
|
||||
.video-icon, .star-icon {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
filter: invert(1) brightness(100);
|
||||
.video-icon,
|
||||
.star-icon {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
filter: invert(1) brightness(100);
|
||||
}
|
||||
.video-icon {
|
||||
top: $icon-dist; right: $icon-dist;
|
||||
top: $icon-dist;
|
||||
right: $icon-dist;
|
||||
}
|
||||
.star-icon {
|
||||
bottom: $icon-dist; left: $icon-dist;
|
||||
bottom: $icon-dist;
|
||||
left: $icon-dist;
|
||||
}
|
||||
|
||||
/* Actual image */
|
||||
div.img-outer {
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
@media (max-width: 768px) { padding: 1px; }
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
@media (max-width: 768px) {
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
transition: padding 0.1s ease;
|
||||
background-clip: content-box, padding-box;
|
||||
background-color: var(--color-background-dark);
|
||||
transition: padding 0.1s ease;
|
||||
background-clip: content-box, padding-box;
|
||||
background-color: var(--color-background-dark);
|
||||
|
||||
.selected > & { padding: calc($icon-dist - 2px); }
|
||||
.selected > & {
|
||||
padding: calc($icon-dist - 2px);
|
||||
}
|
||||
|
||||
> img {
|
||||
filter: contrast(1.05); // most real world images are a bit overexposed
|
||||
background-clip: content-box;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
> img {
|
||||
filter: contrast(1.05); // most real world images are a bit overexposed
|
||||
background-clip: content-box;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
user-select: none;
|
||||
transition: box-shadow 0.1s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
user-select: none;
|
||||
transition: box-shadow 0.1s ease;
|
||||
|
||||
.selected > & { box-shadow: 0 0 4px 2px var(--color-primary); }
|
||||
.p-outer.placeholder > & { display: none; }
|
||||
.p-outer.error & { object-fit: contain; }
|
||||
.selected > & {
|
||||
box-shadow: 0 0 4px 2px var(--color-primary);
|
||||
}
|
||||
.p-outer.placeholder > & {
|
||||
display: none;
|
||||
}
|
||||
.p-outer.error & {
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,255 +1,276 @@
|
|||
<template>
|
||||
<router-link class="tag fill-block" :class="{
|
||||
hasPreview: previews.length > 0,
|
||||
onePreview: previews.length === 1,
|
||||
hasError: error,
|
||||
isFace: isFace,
|
||||
<router-link
|
||||
class="tag fill-block"
|
||||
:class="{
|
||||
hasPreview: previews.length > 0,
|
||||
onePreview: previews.length === 1,
|
||||
hasError: error,
|
||||
isFace: isFace,
|
||||
}"
|
||||
: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="name">
|
||||
{{ data.name }}
|
||||
<span class="subtitle" v-if="subtitle"> {{ subtitle }} </span>
|
||||
</div>
|
||||
|
||||
<div class="previews fill-block" ref="previews">
|
||||
<div class="img-outer" v-for="info of previews" :key="info.fileid">
|
||||
<img
|
||||
class="fill-block"
|
||||
:class="{ 'error': info.flag & c.FLAG_LOAD_FAIL }"
|
||||
:key="'fpreview-' + info.fileid"
|
||||
:src="getPreviewUrl(info.fileid, info.etag)"
|
||||
@error="info.flag |= c.FLAG_LOAD_FAIL" />
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
<div class="previews fill-block" ref="previews">
|
||||
<div class="img-outer" v-for="info of previews" :key="info.fileid">
|
||||
<img
|
||||
class="fill-block"
|
||||
:class="{ error: info.flag & c.FLAG_LOAD_FAIL }"
|
||||
:key="'fpreview-' + info.fileid"
|
||||
:src="getPreviewUrl(info.fileid, info.etag)"
|
||||
@error="info.flag |= c.FLAG_LOAD_FAIL"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Watch, Mixins, Emit } from 'vue-property-decorator';
|
||||
import { IAlbum, IPhoto, ITag } from '../../types';
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { Component, Prop, Watch, Mixins, Emit } from "vue-property-decorator";
|
||||
import { IAlbum, IPhoto, ITag } from "../../types";
|
||||
import { generateUrl } from "@nextcloud/router";
|
||||
import { getPreviewUrl } from "../../services/FileUtils";
|
||||
import { getCurrentUser } from '@nextcloud/auth';
|
||||
import { getCurrentUser } from "@nextcloud/auth";
|
||||
|
||||
import { NcCounterBubble } from '@nextcloud/vue';
|
||||
import axios from '@nextcloud/axios';
|
||||
import { NcCounterBubble } from "@nextcloud/vue";
|
||||
import axios from "@nextcloud/axios";
|
||||
import * as utils from "../../services/Utils";
|
||||
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import { constants } from '../../services/Utils';
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
import { constants } from "../../services/Utils";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcCounterBubble,
|
||||
},
|
||||
components: {
|
||||
NcCounterBubble,
|
||||
},
|
||||
})
|
||||
export default class Tag extends Mixins(GlobalMixin) {
|
||||
@Prop() data: ITag;
|
||||
@Prop() noNavigate: boolean;
|
||||
@Prop() data: ITag;
|
||||
@Prop() noNavigate: boolean;
|
||||
|
||||
// Separate property because the one on data isn't reactive
|
||||
private previews: IPhoto[] = [];
|
||||
// Separate property because the one on data isn't reactive
|
||||
private previews: IPhoto[] = [];
|
||||
|
||||
// Error occured fetching thumbs
|
||||
private error = false;
|
||||
// Error occured fetching thumbs
|
||||
private error = false;
|
||||
|
||||
// Smaller subtitle
|
||||
private subtitle = '';
|
||||
// Smaller subtitle
|
||||
private subtitle = "";
|
||||
|
||||
/**
|
||||
* Open tag event
|
||||
* Unless noNavigate is set, the tag will be opened
|
||||
*/
|
||||
@Emit('open')
|
||||
openTag(tag: ITag) {}
|
||||
/**
|
||||
* Open tag event
|
||||
* Unless noNavigate is set, the tag will be opened
|
||||
*/
|
||||
@Emit("open")
|
||||
openTag(tag: ITag) {}
|
||||
|
||||
mounted() {
|
||||
this.refreshPreviews();
|
||||
mounted() {
|
||||
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')
|
||||
dataChanged() {
|
||||
this.refreshPreviews();
|
||||
// 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;
|
||||
}
|
||||
|
||||
getPreviewUrl(fileid: number, etag: string) {
|
||||
if (this.isFace) {
|
||||
return generateUrl('/apps/memories/api/faces/preview/' + this.data.fileid);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
return getPreviewUrl(fileid, etag, true, 256);
|
||||
} catch (e) {
|
||||
this.error = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
get isFace() {
|
||||
return this.data.flag & constants.c.FLAG_IS_FACE;
|
||||
// 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 } };
|
||||
}
|
||||
|
||||
get isAlbum() {
|
||||
return this.data.flag & constants.c.FLAG_IS_ALBUM;
|
||||
if (this.isAlbum) {
|
||||
const user = (<IAlbum>this.data).user;
|
||||
const name = this.data.name;
|
||||
return { name: "albums", params: { user, name } };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 }};
|
||||
}
|
||||
return { name: "tags", params: { name: this.data.name } };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tag, .name, .bubble, img {
|
||||
cursor: pointer;
|
||||
.tag,
|
||||
.name,
|
||||
.bubble,
|
||||
img {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// Get rid of color of the bubble
|
||||
.tag .bbl :deep .counter-bubble__counter {
|
||||
color: unset !important;
|
||||
color: unset !important;
|
||||
}
|
||||
|
||||
.name {
|
||||
z-index: 100;
|
||||
position: absolute;
|
||||
top: 50%; width: 100%;
|
||||
transform: translateY(-50%);
|
||||
color: white;
|
||||
padding: 0 5%;
|
||||
text-align: center;
|
||||
font-size: 1.2em;
|
||||
word-wrap: break-word;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1em;
|
||||
z-index: 100;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 100%;
|
||||
transform: translateY(-50%);
|
||||
color: white;
|
||||
padding: 0 5%;
|
||||
text-align: center;
|
||||
font-size: 1.2em;
|
||||
word-wrap: break-word;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1em;
|
||||
|
||||
> .subtitle {
|
||||
font-size: 0.7em;
|
||||
margin-top: 2px;
|
||||
display: block;
|
||||
}
|
||||
> .subtitle {
|
||||
font-size: 0.7em;
|
||||
margin-top: 2px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.isFace > & {
|
||||
top: unset;
|
||||
bottom: 10%;
|
||||
transform: unset;
|
||||
}
|
||||
.isFace > & {
|
||||
top: unset;
|
||||
bottom: 10%;
|
||||
transform: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.bbl {
|
||||
z-index: 100;
|
||||
position: absolute;
|
||||
top: 6px; right: 5px;
|
||||
z-index: 100;
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.previews {
|
||||
z-index: 3;
|
||||
line-height: 0;
|
||||
position: absolute;
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
@media (max-width: 768px) { padding: 1px; }
|
||||
z-index: 3;
|
||||
line-height: 0;
|
||||
position: absolute;
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
@media (max-width: 768px) {
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.tag:not(.hasPreview) & {
|
||||
background-color: #444;
|
||||
background-clip: content-box;
|
||||
.tag:not(.hasPreview) & {
|
||||
background-color: #444;
|
||||
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 {
|
||||
background-color: var(--color-background-dark);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
> img {
|
||||
object-fit: cover;
|
||||
padding: 0;
|
||||
filter: brightness(60%);
|
||||
cursor: pointer;
|
||||
transition: filter 0.2s ease-in-out;
|
||||
|
||||
.tag.onePreview > & {
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
|
||||
> img {
|
||||
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%); }
|
||||
}
|
||||
&.error {
|
||||
display: none;
|
||||
}
|
||||
.tag:hover & {
|
||||
filter: brightness(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,84 +1,92 @@
|
|||
<template>
|
||||
<Modal @close="close" size="normal" v-if="show">
|
||||
<template #title>
|
||||
{{ t('memories', 'Add to album') }}
|
||||
</template>
|
||||
<Modal @close="close" size="normal" v-if="show">
|
||||
<template #title>
|
||||
{{ t("memories", "Add to album") }}
|
||||
</template>
|
||||
|
||||
<div class="outer">
|
||||
<AlbumPicker @select="selectAlbum" />
|
||||
<div class="outer">
|
||||
<AlbumPicker @select="selectAlbum" />
|
||||
|
||||
<div v-if="processing" class="info-pad">
|
||||
{{ t('memories', 'Processing … {n}/{m}', {
|
||||
n: photosDone,
|
||||
m: photos.length,
|
||||
}) }}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<div v-if="processing" class="info-pad">
|
||||
{{
|
||||
t("memories", "Processing … {n}/{m}", {
|
||||
n: photosDone,
|
||||
m: photos.length,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Mixins } from 'vue-property-decorator';
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import { Component, Emit, Mixins } from "vue-property-decorator";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
|
||||
import * as dav from '../../services/DavRequests';
|
||||
import { showInfo } from '@nextcloud/dialogs';
|
||||
import { IAlbum, IPhoto } from '../../types';
|
||||
import * as dav from "../../services/DavRequests";
|
||||
import { showInfo } from "@nextcloud/dialogs";
|
||||
import { IAlbum, IPhoto } from "../../types";
|
||||
|
||||
import AlbumPicker from './AlbumPicker.vue';
|
||||
import Modal from './Modal.vue';
|
||||
import AlbumPicker from "./AlbumPicker.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
Modal,
|
||||
AlbumPicker,
|
||||
}
|
||||
components: {
|
||||
Modal,
|
||||
AlbumPicker,
|
||||
},
|
||||
})
|
||||
export default class AddToAlbumModal extends Mixins(GlobalMixin) {
|
||||
private show = false;
|
||||
private photos: IPhoto[] = [];
|
||||
private photosDone: number = 0;
|
||||
private processing: boolean = false;
|
||||
private show = false;
|
||||
private photos: IPhoto[] = [];
|
||||
private photosDone: number = 0;
|
||||
private processing: boolean = false;
|
||||
|
||||
public open(photos: IPhoto[]) {
|
||||
this.photosDone = 0;
|
||||
this.processing = false;
|
||||
this.show = true;
|
||||
this.photos = photos;
|
||||
public open(photos: IPhoto[]) {
|
||||
this.photosDone = 0;
|
||||
this.processing = false;
|
||||
this.show = true;
|
||||
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')
|
||||
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)));
|
||||
}
|
||||
|
||||
showInfo(this.t('memories', '{n} photos added to album', { n: this.photosDone }));
|
||||
this.close();
|
||||
}
|
||||
showInfo(
|
||||
this.t("memories", "{n} photos added to album", { n: this.photosDone })
|
||||
);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.outer {
|
||||
margin-top: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.info-pad {
|
||||
margin-top: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
|
@ -20,452 +20,533 @@
|
|||
-
|
||||
-->
|
||||
<template>
|
||||
<div class="manage-collaborators">
|
||||
<div class="manage-collaborators__subtitle">
|
||||
{{ t('photos', 'Add people or groups who can edit your album') }}
|
||||
</div>
|
||||
<div class="manage-collaborators">
|
||||
<div class="manage-collaborators__subtitle">
|
||||
{{ t("photos", "Add people or groups who can edit your album") }}
|
||||
</div>
|
||||
|
||||
<form class="manage-collaborators__form" @submit.prevent>
|
||||
<NcPopover ref="popover"
|
||||
:auto-size="true"
|
||||
:distance="0">
|
||||
<label slot="trigger" class="manage-collaborators__form__input">
|
||||
<NcTextField :value.sync="searchText"
|
||||
autocomplete="off"
|
||||
type="search"
|
||||
name="search"
|
||||
:aria-label="t('photos', 'Search for collaborators')"
|
||||
aria-autocomplete="list"
|
||||
:aria-controls="`manage-collaborators__form__selection-${randomId} manage-collaborators__form__list-${randomId}`"
|
||||
:placeholder="t('photos', 'Search people or groups')"
|
||||
@input="searchCollaborators">
|
||||
<Magnify :size="16" />
|
||||
</NcTextField>
|
||||
<NcLoadingIcon v-if="loadingCollaborators" />
|
||||
</label>
|
||||
<form class="manage-collaborators__form" @submit.prevent>
|
||||
<NcPopover ref="popover" :auto-size="true" :distance="0">
|
||||
<label slot="trigger" class="manage-collaborators__form__input">
|
||||
<NcTextField
|
||||
:value.sync="searchText"
|
||||
autocomplete="off"
|
||||
type="search"
|
||||
name="search"
|
||||
:aria-label="t('photos', 'Search for collaborators')"
|
||||
aria-autocomplete="list"
|
||||
:aria-controls="`manage-collaborators__form__selection-${randomId} manage-collaborators__form__list-${randomId}`"
|
||||
:placeholder="t('photos', 'Search people or groups')"
|
||||
@input="searchCollaborators"
|
||||
>
|
||||
<Magnify :size="16" />
|
||||
</NcTextField>
|
||||
<NcLoadingIcon v-if="loadingCollaborators" />
|
||||
</label>
|
||||
|
||||
<ul v-if="searchResults.length !== 0" :id="`manage-collaborators__form__list-${randomId}`" class="manage-collaborators__form__list">
|
||||
<li v-for="collaboratorKey of searchResults" :key="collaboratorKey">
|
||||
<NcListItemIcon :id="availableCollaborators[collaboratorKey].id"
|
||||
class="manage-collaborators__form__list__result"
|
||||
:title="availableCollaborators[collaboratorKey].id"
|
||||
:search="searchText"
|
||||
:user="availableCollaborators[collaboratorKey].id"
|
||||
:display-name="availableCollaborators[collaboratorKey].label"
|
||||
:aria-label="t('photos', 'Add {collaboratorLabel} to the collaborators list', {collaboratorLabel: 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
|
||||
v-if="searchResults.length !== 0"
|
||||
:id="`manage-collaborators__form__list-${randomId}`"
|
||||
class="manage-collaborators__form__list"
|
||||
>
|
||||
<li v-for="collaboratorKey of searchResults" :key="collaboratorKey">
|
||||
<NcListItemIcon
|
||||
:id="availableCollaborators[collaboratorKey].id"
|
||||
class="manage-collaborators__form__list__result"
|
||||
:title="availableCollaborators[collaboratorKey].id"
|
||||
:search="searchText"
|
||||
:user="availableCollaborators[collaboratorKey].id"
|
||||
:display-name="availableCollaborators[collaboratorKey].label"
|
||||
:aria-label="
|
||||
t(
|
||||
'photos',
|
||||
'Add {collaboratorLabel} to the collaborators list',
|
||||
{
|
||||
collaboratorLabel:
|
||||
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">
|
||||
<li v-for="collaboratorKey of listableSelectedCollaboratorsKeys"
|
||||
:key="collaboratorKey"
|
||||
class="manage-collaborators__selection__item">
|
||||
<NcListItemIcon :id="availableCollaborators[collaboratorKey].id"
|
||||
:display-name="availableCollaborators[collaboratorKey].label"
|
||||
:title="availableCollaborators[collaboratorKey].id"
|
||||
:user="availableCollaborators[collaboratorKey].id">
|
||||
<NcButton type="tertiary"
|
||||
:aria-label="t('photos', 'Remove {collaboratorLabel} from the collaborators list', {collaboratorLabel: availableCollaborators[collaboratorKey].label})"
|
||||
@click="unselectEntity(collaboratorKey)">
|
||||
<Close slot="icon" :size="20" />
|
||||
</NcButton>
|
||||
</NcListItemIcon>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="manage-collaborators__selection">
|
||||
<li
|
||||
v-for="collaboratorKey of listableSelectedCollaboratorsKeys"
|
||||
:key="collaboratorKey"
|
||||
class="manage-collaborators__selection__item"
|
||||
>
|
||||
<NcListItemIcon
|
||||
:id="availableCollaborators[collaboratorKey].id"
|
||||
:display-name="availableCollaborators[collaboratorKey].label"
|
||||
:title="availableCollaborators[collaboratorKey].id"
|
||||
:user="availableCollaborators[collaboratorKey].id"
|
||||
>
|
||||
<NcButton
|
||||
type="tertiary"
|
||||
:aria-label="
|
||||
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 v-if="allowPublicLink" class="actions__public-link">
|
||||
<template v-if="isPublicLinkSelected">
|
||||
<NcButton class="manage-collaborators__public-link-button"
|
||||
:aria-label="t('photos', 'Copy the public link')"
|
||||
:disabled="publicLink.id === ''"
|
||||
@click="copyPublicLink">
|
||||
<template v-if="publicLinkCopied">
|
||||
{{ t('photos', 'Public link copied!') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('photos', 'Copy public link') }}
|
||||
</template>
|
||||
<template #icon>
|
||||
<Check v-if="publicLinkCopied" />
|
||||
<ContentCopy v-else />
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcButton type="tertiary"
|
||||
:aria-label="t('photos', 'Delete the public link')"
|
||||
:disabled="publicLink.id === ''"
|
||||
@click="deletePublicLink">
|
||||
<NcLoadingIcon v-if="publicLink.id === ''" slot="icon" />
|
||||
<Close v-else slot="icon" />
|
||||
</NcButton>
|
||||
</template>
|
||||
<NcButton v-else
|
||||
class="manage-collaborators__public-link-button"
|
||||
@click="createPublicLinkForAlbum">
|
||||
<Earth slot="icon" />
|
||||
{{ t('photos', 'Share via public link') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div v-if="allowPublicLink" class="actions__public-link">
|
||||
<template v-if="isPublicLinkSelected">
|
||||
<NcButton
|
||||
class="manage-collaborators__public-link-button"
|
||||
:aria-label="t('photos', 'Copy the public link')"
|
||||
:disabled="publicLink.id === ''"
|
||||
@click="copyPublicLink"
|
||||
>
|
||||
<template v-if="publicLinkCopied">
|
||||
{{ t("photos", "Public link copied!") }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t("photos", "Copy public link") }}
|
||||
</template>
|
||||
<template #icon>
|
||||
<Check v-if="publicLinkCopied" />
|
||||
<ContentCopy v-else />
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcButton
|
||||
type="tertiary"
|
||||
:aria-label="t('photos', 'Delete the public link')"
|
||||
:disabled="publicLink.id === ''"
|
||||
@click="deletePublicLink"
|
||||
>
|
||||
<NcLoadingIcon v-if="publicLink.id === ''" slot="icon" />
|
||||
<Close v-else slot="icon" />
|
||||
</NcButton>
|
||||
</template>
|
||||
<NcButton
|
||||
v-else
|
||||
class="manage-collaborators__public-link-button"
|
||||
@click="createPublicLinkForAlbum"
|
||||
>
|
||||
<Earth slot="icon" />
|
||||
{{ t("photos", "Share via public link") }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<div class="actions__slot">
|
||||
<slot :collaborators="selectedCollaborators" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions__slot">
|
||||
<slot :collaborators="selectedCollaborators" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Mixins, Prop, Watch } from 'vue-property-decorator';
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import { Component, Mixins, Prop, Watch } from "vue-property-decorator";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
|
||||
import Magnify from 'vue-material-design-icons/Magnify.vue'
|
||||
import Close from 'vue-material-design-icons/Close.vue'
|
||||
import Check from 'vue-material-design-icons/Check.vue'
|
||||
import ContentCopy from 'vue-material-design-icons/ContentCopy.vue'
|
||||
import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
|
||||
import Earth from 'vue-material-design-icons/Earth.vue'
|
||||
import Magnify from "vue-material-design-icons/Magnify.vue";
|
||||
import Close from "vue-material-design-icons/Close.vue";
|
||||
import Check from "vue-material-design-icons/Check.vue";
|
||||
import ContentCopy from "vue-material-design-icons/ContentCopy.vue";
|
||||
import AccountGroup from "vue-material-design-icons/AccountGroup.vue";
|
||||
import Earth from "vue-material-design-icons/Earth.vue";
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import * as dav from '../../services/DavRequests';
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import { generateOcsUrl, generateUrl } from '@nextcloud/router'
|
||||
import { NcButton, NcListItemIcon, NcLoadingIcon, NcPopover, NcTextField, NcEmptyContent } from '@nextcloud/vue'
|
||||
import axios from "@nextcloud/axios";
|
||||
import * as dav from "../../services/DavRequests";
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
import { getCurrentUser } from "@nextcloud/auth";
|
||||
import { generateOcsUrl, generateUrl } from "@nextcloud/router";
|
||||
import {
|
||||
NcButton,
|
||||
NcListItemIcon,
|
||||
NcLoadingIcon,
|
||||
NcPopover,
|
||||
NcTextField,
|
||||
NcEmptyContent,
|
||||
} from "@nextcloud/vue";
|
||||
import { Type } from "@nextcloud/sharing";
|
||||
|
||||
type Collaborator = {
|
||||
id: string,
|
||||
label: string,
|
||||
type: Type,
|
||||
}
|
||||
id: string;
|
||||
label: string;
|
||||
type: Type;
|
||||
};
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
Magnify,
|
||||
Close,
|
||||
AccountGroup,
|
||||
ContentCopy,
|
||||
Check,
|
||||
Earth,
|
||||
NcLoadingIcon,
|
||||
NcButton,
|
||||
NcListItemIcon,
|
||||
NcTextField,
|
||||
NcPopover,
|
||||
NcEmptyContent,
|
||||
}
|
||||
components: {
|
||||
Magnify,
|
||||
Close,
|
||||
AccountGroup,
|
||||
ContentCopy,
|
||||
Check,
|
||||
Earth,
|
||||
NcLoadingIcon,
|
||||
NcButton,
|
||||
NcListItemIcon,
|
||||
NcTextField,
|
||||
NcPopover,
|
||||
NcEmptyContent,
|
||||
},
|
||||
})
|
||||
export default class AddToAlbumModal extends Mixins(GlobalMixin) {
|
||||
@Prop() private albumName: string;
|
||||
@Prop() collaborators: Collaborator[];
|
||||
@Prop() allowPublicLink: boolean;
|
||||
|
||||
@Prop() private albumName: string;
|
||||
@Prop() collaborators: Collaborator[];
|
||||
@Prop() allowPublicLink: boolean;
|
||||
private searchText = "";
|
||||
private availableCollaborators: { [key: string]: Collaborator } = {};
|
||||
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 = '';
|
||||
private availableCollaborators: { [key: string]: Collaborator } = {};
|
||||
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,
|
||||
get searchResults(): string[] {
|
||||
return this.currentSearchResults
|
||||
.filter(({ id }) => id !== getCurrentUser().uid)
|
||||
.map(({ type, id }) => `${type}:${id}`)
|
||||
.filter(
|
||||
(collaboratorKey) =>
|
||||
!this.selectedCollaboratorsKeys.includes(collaboratorKey)
|
||||
);
|
||||
}
|
||||
|
||||
get listableSelectedCollaboratorsKeys(): string[] {
|
||||
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
|
||||
.filter(({ id }) => id !== getCurrentUser().uid)
|
||||
.map(({ type, id }) => `${type}:${id}`)
|
||||
.filter(collaboratorKey => !this.selectedCollaboratorsKeys.includes(collaboratorKey))
|
||||
}
|
||||
|
||||
get listableSelectedCollaboratorsKeys(): string[] {
|
||||
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)
|
||||
/**
|
||||
* @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,
|
||||
};
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.searchCollaborators()
|
||||
this.populateCollaborators(this.collaborators)
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch possible collaborators.
|
||||
*/
|
||||
async searchCollaborators() {
|
||||
if (this.searchText.length >= 1) {
|
||||
(<any>this.$refs.popover).$refs.popover.show()
|
||||
}
|
||||
showError(this.t("photos", "Failed to fetch album."));
|
||||
} finally {
|
||||
this.loadingAlbum = false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.searchText.length < this.config.minSearchStringLength) {
|
||||
return
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
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,
|
||||
],
|
||||
},
|
||||
})
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
})
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
selectEntity(collaboratorKey) {
|
||||
if (this.selectedCollaboratorsKeys.includes(collaboratorKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
(<any>this.$refs.popover).$refs.popover.hide();
|
||||
this.selectedCollaboratorsKeys.push(collaboratorKey);
|
||||
}
|
||||
|
||||
unselectEntity(collaboratorKey) {
|
||||
const index = this.selectedCollaboratorsKeys.indexOf(collaboratorKey);
|
||||
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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)
|
||||
}
|
||||
this.selectedCollaboratorsKeys.splice(index, 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.manage-collaborators {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 500px;
|
||||
|
||||
&__title {
|
||||
font-weight: bold;
|
||||
}
|
||||
&__title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
color: var(--color-text-lighter);
|
||||
}
|
||||
&__subtitle {
|
||||
color: var(--color-text-lighter);
|
||||
}
|
||||
|
||||
&__public-link-button {
|
||||
margin: 4px 0;
|
||||
}
|
||||
&__public-link-button {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
&__form {
|
||||
margin-top: 4px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
&__form {
|
||||
margin-top: 4px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__input {
|
||||
position: relative;
|
||||
display: block;
|
||||
&__input {
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding-left: 34px;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding-left: 34px;
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
position: absolute;
|
||||
top: calc(36px / 2 - 20px / 2);
|
||||
right: 8px;
|
||||
}
|
||||
}
|
||||
.loading-icon {
|
||||
position: absolute;
|
||||
top: calc(36px / 2 - 20px / 2);
|
||||
right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
padding: 8px;
|
||||
height: 350px;
|
||||
overflow: scroll;
|
||||
&__list {
|
||||
padding: 8px;
|
||||
height: 350px;
|
||||
overflow: scroll;
|
||||
|
||||
&__result {
|
||||
padding: 8px;
|
||||
border-radius: 100px;
|
||||
box-sizing: border-box;
|
||||
&__result {
|
||||
padding: 8px;
|
||||
border-radius: 100px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&, & * {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
&,
|
||||
& * {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-dark);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background: var(--color-background-dark);
|
||||
}
|
||||
}
|
||||
|
||||
&--empty {
|
||||
margin: 100px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
&--empty {
|
||||
margin: 100px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
flex-grow: 1;
|
||||
&__selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
flex-grow: 1;
|
||||
|
||||
&__item {
|
||||
border-radius: var(--border-radius-pill);
|
||||
padding: 0 8px;
|
||||
&__item {
|
||||
border-radius: var(--border-radius-pill);
|
||||
padding: 0 8px;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background: var(--color-background-dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
.actions {
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
|
||||
&__public-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&__public-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&__slot {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
&__slot {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,87 +1,90 @@
|
|||
<template>
|
||||
<Modal @close="close" size="normal" v-if="show">
|
||||
<template #title>
|
||||
<template v-if="!album">
|
||||
{{ t('memories', 'Create new album') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('memories', 'Edit album details') }}
|
||||
</template>
|
||||
</template>
|
||||
<Modal @close="close" size="normal" v-if="show">
|
||||
<template #title>
|
||||
<template v-if="!album">
|
||||
{{ t("memories", "Create new album") }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t("memories", "Edit album details") }}
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div class="outer">
|
||||
<AlbumForm
|
||||
:album="album"
|
||||
:display-back-button="false"
|
||||
:title="t('photos', 'New album')"
|
||||
@done="done" />
|
||||
</div>
|
||||
</Modal>
|
||||
<div class="outer">
|
||||
<AlbumForm
|
||||
:album="album"
|
||||
:display-back-button="false"
|
||||
:title="t('photos', 'New album')"
|
||||
@done="done"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Mixins } from 'vue-property-decorator';
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import { Component, Emit, Mixins } from "vue-property-decorator";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import * as dav from '../../services/DavRequests';
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
import * as dav from "../../services/DavRequests";
|
||||
|
||||
import Modal from './Modal.vue';
|
||||
import AlbumForm from './AlbumForm.vue';
|
||||
import Modal from "./Modal.vue";
|
||||
import AlbumForm from "./AlbumForm.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
Modal,
|
||||
AlbumForm,
|
||||
}
|
||||
components: {
|
||||
Modal,
|
||||
AlbumForm,
|
||||
},
|
||||
})
|
||||
export default class AlbumCreateModal extends Mixins(GlobalMixin) {
|
||||
private show = false;
|
||||
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;
|
||||
private show = false;
|
||||
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;
|
||||
}
|
||||
|
||||
@Emit('close')
|
||||
public close() {
|
||||
this.show = false;
|
||||
}
|
||||
this.show = true;
|
||||
}
|
||||
|
||||
public done({ album }: any) {
|
||||
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();
|
||||
@Emit("close")
|
||||
public close() {
|
||||
this.show = false;
|
||||
}
|
||||
|
||||
public done({ album }: any) {
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.outer {
|
||||
margin-top: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.info-pad {
|
||||
margin-top: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
|
@ -1,81 +1,91 @@
|
|||
<template>
|
||||
<Modal @close="close" v-if="show">
|
||||
<template #title>
|
||||
{{ t('memories', 'Remove Album') }}
|
||||
</template>
|
||||
<Modal @close="close" v-if="show">
|
||||
<template #title>
|
||||
{{ t("memories", "Remove Album") }}
|
||||
</template>
|
||||
|
||||
<span>
|
||||
{{ t('memories', 'Are you sure you want to permanently remove album "{name}"?', { name }) }}
|
||||
</span>
|
||||
<span>
|
||||
{{
|
||||
t(
|
||||
"memories",
|
||||
'Are you sure you want to permanently remove album "{name}"?',
|
||||
{ name }
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
|
||||
<template #buttons>
|
||||
<NcButton @click="save" class="button" type="error">
|
||||
{{ t('memories', 'Delete') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</Modal>
|
||||
<template #buttons>
|
||||
<NcButton @click="save" class="button" type="error">
|
||||
{{ t("memories", "Delete") }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Mixins, Watch } from 'vue-property-decorator';
|
||||
import { NcButton, NcTextField } from '@nextcloud/vue';
|
||||
import { showError } from '@nextcloud/dialogs';
|
||||
import { getCurrentUser } from '@nextcloud/auth';
|
||||
import Modal from './Modal.vue';
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import client from '../../services/DavClient';
|
||||
import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
|
||||
import { NcButton, NcTextField } from "@nextcloud/vue";
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
import { getCurrentUser } from "@nextcloud/auth";
|
||||
import Modal from "./Modal.vue";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
import client from "../../services/DavClient";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcButton,
|
||||
NcTextField,
|
||||
Modal,
|
||||
}
|
||||
components: {
|
||||
NcButton,
|
||||
NcTextField,
|
||||
Modal,
|
||||
},
|
||||
})
|
||||
export default class AlbumDeleteModal extends Mixins(GlobalMixin) {
|
||||
private user: string = "";
|
||||
private name: string = "";
|
||||
private show = false;
|
||||
private user: string = "";
|
||||
private name: string = "";
|
||||
private show = false;
|
||||
|
||||
@Emit('close')
|
||||
public close() {
|
||||
this.show = false;
|
||||
}
|
||||
@Emit("close")
|
||||
public close() {
|
||||
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 delete this album', { user }));
|
||||
return;
|
||||
}
|
||||
this.show = true;
|
||||
public open() {
|
||||
const user = this.$route.params.user || "";
|
||||
if (this.$route.params.user !== getCurrentUser().uid) {
|
||||
showError(
|
||||
this.t("memories", 'Only user "{user}" can delete this album', { user })
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.show = true;
|
||||
}
|
||||
|
||||
@Watch('$route')
|
||||
async routeChange(from: any, to: any) {
|
||||
this.refreshParams();
|
||||
}
|
||||
@Watch("$route")
|
||||
async routeChange(from: any, to: any) {
|
||||
this.refreshParams();
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.refreshParams();
|
||||
}
|
||||
mounted() {
|
||||
this.refreshParams();
|
||||
}
|
||||
|
||||
public refreshParams() {
|
||||
this.user = this.$route.params.user || '';
|
||||
this.name = this.$route.params.name || '';
|
||||
}
|
||||
public refreshParams() {
|
||||
this.user = this.$route.params.user || "";
|
||||
this.name = this.$route.params.name || "";
|
||||
}
|
||||
|
||||
public async save() {
|
||||
try {
|
||||
await client.deleteFile(`/photos/${this.user}/albums/${this.name}`)
|
||||
this.$router.push({ name: 'albums' });
|
||||
this.close();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
showError(this.t('photos', 'Failed to delete {name}.', {
|
||||
name: this.name,
|
||||
}));
|
||||
}
|
||||
public async save() {
|
||||
try {
|
||||
await client.deleteFile(`/photos/${this.user}/albums/${this.name}`);
|
||||
this.$router.push({ name: "albums" });
|
||||
this.close();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
showError(
|
||||
this.t("photos", "Failed to delete {name}.", {
|
||||
name: this.name,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -20,244 +20,274 @@
|
|||
-
|
||||
-->
|
||||
<template>
|
||||
<form v-if="!showCollaboratorView" class="album-form" @submit.prevent="submit">
|
||||
<div class="form-inputs">
|
||||
<NcTextField ref="nameInput"
|
||||
:value.sync="albumName"
|
||||
type="text"
|
||||
name="name"
|
||||
:required="true"
|
||||
autofocus="true"
|
||||
:placeholder="t('photos', 'Name of the album')" />
|
||||
<label>
|
||||
<NcTextField :value.sync="albumLocation"
|
||||
name="location"
|
||||
type="text"
|
||||
:placeholder="t('photos', 'Location of the album')" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-buttons">
|
||||
<span class="left-buttons">
|
||||
<NcButton v-if="displayBackButton"
|
||||
:aria-label="t('photos', 'Go back to the previous view.')"
|
||||
type="tertiary"
|
||||
@click="back">
|
||||
{{ t('photos', 'Back') }}
|
||||
</NcButton>
|
||||
</span>
|
||||
<span class="right-buttons">
|
||||
<NcButton v-if="sharingEnabled && !editMode"
|
||||
:aria-label="t('photos', 'Go to the add collaborators view.')"
|
||||
type="secondary"
|
||||
:disabled="albumName.trim() === '' || loading"
|
||||
@click="showCollaboratorView = true">
|
||||
<template #icon>
|
||||
<AccountMultiplePlus />
|
||||
</template>
|
||||
{{ t('photos', 'Add collaborators') }}
|
||||
</NcButton>
|
||||
<NcButton :aria-label="editMode ? t('photos', 'Save.') : t('photos', 'Create the album.')"
|
||||
type="primary"
|
||||
:disabled="albumName === '' || loading"
|
||||
@click="submit()">
|
||||
<template #icon>
|
||||
<NcLoadingIcon v-if="loading" />
|
||||
<Send v-else />
|
||||
</template>
|
||||
{{ editMode ? t('photos', 'Save') : t('photos', 'Create album') }}
|
||||
</NcButton>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
<AlbumCollaborators v-else
|
||||
:album-name="albumName"
|
||||
:allow-public-link="false"
|
||||
:collaborators="[]">
|
||||
<template slot-scope="{collaborators}">
|
||||
<span class="left-buttons">
|
||||
<NcButton :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>
|
||||
<form
|
||||
v-if="!showCollaboratorView"
|
||||
class="album-form"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<div class="form-inputs">
|
||||
<NcTextField
|
||||
ref="nameInput"
|
||||
:value.sync="albumName"
|
||||
type="text"
|
||||
name="name"
|
||||
:required="true"
|
||||
autofocus="true"
|
||||
:placeholder="t('photos', 'Name of the album')"
|
||||
/>
|
||||
<label>
|
||||
<NcTextField
|
||||
:value.sync="albumLocation"
|
||||
name="location"
|
||||
type="text"
|
||||
:placeholder="t('photos', 'Location of the album')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-buttons">
|
||||
<span class="left-buttons">
|
||||
<NcButton
|
||||
v-if="displayBackButton"
|
||||
:aria-label="t('photos', 'Go back to the previous view.')"
|
||||
type="tertiary"
|
||||
@click="back"
|
||||
>
|
||||
{{ t("photos", "Back") }}
|
||||
</NcButton>
|
||||
</span>
|
||||
<span class="right-buttons">
|
||||
<NcButton
|
||||
v-if="sharingEnabled && !editMode"
|
||||
:aria-label="t('photos', 'Go to the add collaborators view.')"
|
||||
type="secondary"
|
||||
:disabled="albumName.trim() === '' || loading"
|
||||
@click="showCollaboratorView = true"
|
||||
>
|
||||
<template #icon>
|
||||
<AccountMultiplePlus />
|
||||
</template>
|
||||
{{ t("photos", "Add collaborators") }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
:aria-label="
|
||||
editMode ? t('photos', 'Save.') : t('photos', 'Create the album.')
|
||||
"
|
||||
type="primary"
|
||||
:disabled="albumName === '' || loading"
|
||||
@click="submit()"
|
||||
>
|
||||
<template #icon>
|
||||
<NcLoadingIcon v-if="loading" />
|
||||
<Send v-else />
|
||||
</template>
|
||||
{{ editMode ? t("photos", "Save") : t("photos", "Create album") }}
|
||||
</NcButton>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
<AlbumCollaborators
|
||||
v-else
|
||||
:album-name="albumName"
|
||||
:allow-public-link="false"
|
||||
:collaborators="[]"
|
||||
>
|
||||
<template slot-scope="{ collaborators }">
|
||||
<span class="left-buttons">
|
||||
<NcButton
|
||||
: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>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator';
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import { NcButton, NcLoadingIcon, NcTextField } from '@nextcloud/vue'
|
||||
import moment from 'moment';
|
||||
import * as dav from '../../services/DavRequests';
|
||||
import { getCurrentUser } from "@nextcloud/auth";
|
||||
import { NcButton, NcLoadingIcon, NcTextField } from "@nextcloud/vue";
|
||||
import moment from "moment";
|
||||
import * as dav from "../../services/DavRequests";
|
||||
|
||||
import AlbumCollaborators from './AlbumCollaborators.vue'
|
||||
|
||||
import Send from 'vue-material-design-icons/Send.vue'
|
||||
import AccountMultiplePlus from 'vue-material-design-icons/AccountMultiplePlus.vue'
|
||||
import AlbumCollaborators from "./AlbumCollaborators.vue";
|
||||
|
||||
import Send from "vue-material-design-icons/Send.vue";
|
||||
import AccountMultiplePlus from "vue-material-design-icons/AccountMultiplePlus.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcButton,
|
||||
NcLoadingIcon,
|
||||
NcTextField,
|
||||
AlbumCollaborators,
|
||||
components: {
|
||||
NcButton,
|
||||
NcLoadingIcon,
|
||||
NcTextField,
|
||||
AlbumCollaborators,
|
||||
|
||||
Send,
|
||||
AccountMultiplePlus,
|
||||
},
|
||||
Send,
|
||||
AccountMultiplePlus,
|
||||
},
|
||||
})
|
||||
export default class AlbumForm extends Mixins(GlobalMixin) {
|
||||
@Prop() private album: any;
|
||||
@Prop() private displayBackButton: boolean;
|
||||
@Prop() private album: any;
|
||||
@Prop() private displayBackButton: boolean;
|
||||
|
||||
private showCollaboratorView = false;
|
||||
private albumName = '';
|
||||
private albumLocation = '';
|
||||
private loading = false;
|
||||
private showCollaboratorView = false;
|
||||
private albumName = "";
|
||||
private albumLocation = "";
|
||||
private loading = false;
|
||||
|
||||
/**
|
||||
* @return Whether sharing is enabled.
|
||||
*/
|
||||
get editMode(): boolean {
|
||||
return Boolean(this.album);
|
||||
/**
|
||||
* @return Whether sharing is enabled.
|
||||
*/
|
||||
get editMode(): boolean {
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Whether sharing is enabled.
|
||||
*/
|
||||
get sharingEnabled(): boolean {
|
||||
return window.OC.Share !== undefined
|
||||
submit(collaborators = []) {
|
||||
if (this.albumName === "" || this.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 = []) {
|
||||
if (this.albumName === '' || this.loading) {
|
||||
return
|
||||
}
|
||||
if (this.editMode) {
|
||||
this.handleUpdateAlbum()
|
||||
} else {
|
||||
this.handleCreateAlbum(collaborators)
|
||||
}
|
||||
if (this.editMode) {
|
||||
this.handleUpdateAlbum();
|
||||
} else {
|
||||
this.handleCreateAlbum(collaborators);
|
||||
}
|
||||
}
|
||||
|
||||
async handleCreateAlbum(collaborators = []) {
|
||||
try {
|
||||
this.loading = true
|
||||
let album = {
|
||||
basename: this.albumName,
|
||||
filename: `/photos/${getCurrentUser().uid}/albums/${this.albumName}`,
|
||||
nbItems: 0,
|
||||
location: this.albumLocation,
|
||||
lastPhoto: -1,
|
||||
date: moment().format('MMMM YYYY'),
|
||||
collaborators,
|
||||
}
|
||||
await dav.createAlbum(album.basename);
|
||||
async handleCreateAlbum(collaborators = []) {
|
||||
try {
|
||||
this.loading = true;
|
||||
let album = {
|
||||
basename: this.albumName,
|
||||
filename: `/photos/${getCurrentUser().uid}/albums/${this.albumName}`,
|
||||
nbItems: 0,
|
||||
location: this.albumLocation,
|
||||
lastPhoto: -1,
|
||||
date: moment().format("MMMM YYYY"),
|
||||
collaborators,
|
||||
};
|
||||
await dav.createAlbum(album.basename);
|
||||
|
||||
if (this.albumLocation !== '' || collaborators.length !== 0) {
|
||||
album = await dav.updateAlbum(album, {
|
||||
albumName: this.albumName,
|
||||
properties: {
|
||||
location: this.albumLocation,
|
||||
collaborators,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (this.albumLocation !== "" || collaborators.length !== 0) {
|
||||
album = await dav.updateAlbum(album, {
|
||||
albumName: this.albumName,
|
||||
properties: {
|
||||
location: this.albumLocation,
|
||||
collaborators,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.$emit('done', { album })
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
this.$emit("done", { album });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async handleUpdateAlbum() {
|
||||
try {
|
||||
this.loading = true
|
||||
let album = { ...this.album }
|
||||
if (this.album.basename !== this.albumName) {
|
||||
album = await dav.renameAlbum(this.album, { currentAlbumName: this.album.basename, newAlbumName: this.albumName })
|
||||
}
|
||||
if (this.album.location !== this.albumLocation) {
|
||||
album.location = await dav.updateAlbum(this.album, { albumName: this.albumName, properties: { location: this.albumLocation } })
|
||||
}
|
||||
this.$emit('done', { album })
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
async handleUpdateAlbum() {
|
||||
try {
|
||||
this.loading = true;
|
||||
let album = { ...this.album };
|
||||
if (this.album.basename !== this.albumName) {
|
||||
album = await dav.renameAlbum(this.album, {
|
||||
currentAlbumName: this.album.basename,
|
||||
newAlbumName: this.albumName,
|
||||
});
|
||||
}
|
||||
if (this.album.location !== this.albumLocation) {
|
||||
album.location = await dav.updateAlbum(this.album, {
|
||||
albumName: this.albumName,
|
||||
properties: { location: this.albumLocation },
|
||||
});
|
||||
}
|
||||
this.$emit("done", { album });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Emit('back')
|
||||
back() {}
|
||||
@Emit("back")
|
||||
back() {}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.album-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 350px;
|
||||
padding: 16px;
|
||||
.form-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
.form-subtitle {
|
||||
color: var(--color-text-lighter);
|
||||
}
|
||||
.form-inputs {
|
||||
flex-grow: 1;
|
||||
justify-items: flex-end;
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
margin-top: 16px;
|
||||
:deep svg {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.form-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.left-buttons, .right-buttons {
|
||||
display: flex;
|
||||
}
|
||||
.right-buttons {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
button {
|
||||
margin-right: 16px;
|
||||
}
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 350px;
|
||||
padding: 16px;
|
||||
.form-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
.form-subtitle {
|
||||
color: var(--color-text-lighter);
|
||||
}
|
||||
.form-inputs {
|
||||
flex-grow: 1;
|
||||
justify-items: flex-end;
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
margin-top: 16px;
|
||||
:deep svg {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.form-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.left-buttons,
|
||||
.right-buttons {
|
||||
display: flex;
|
||||
}
|
||||
.right-buttons {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
button {
|
||||
margin-right: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.left-buttons {
|
||||
flex-grow: 1;
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
|
@ -20,168 +20,185 @@
|
|||
-
|
||||
-->
|
||||
<template>
|
||||
<div v-if="!showAlbumCreationForm" class="album-picker">
|
||||
<NcLoadingIcon v-if="loadingAlbums" class="loading-icon" />
|
||||
<div v-if="!showAlbumCreationForm" class="album-picker">
|
||||
<NcLoadingIcon v-if="loadingAlbums" class="loading-icon" />
|
||||
|
||||
<ul class="albums-container">
|
||||
<NcListItem v-for="album in albums"
|
||||
:key="album.album_id"
|
||||
class="album"
|
||||
:title="getAlbumName(album)"
|
||||
:aria-label="t('photos', 'Add selection to album {albumName}', {albumName: getAlbumName(album)})"
|
||||
@click="pickAlbum(album)">
|
||||
<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>
|
||||
<ul class="albums-container">
|
||||
<NcListItem
|
||||
v-for="album in albums"
|
||||
:key="album.album_id"
|
||||
class="album"
|
||||
:title="getAlbumName(album)"
|
||||
:aria-label="
|
||||
t('photos', 'Add selection to album {albumName}', {
|
||||
albumName: getAlbumName(album),
|
||||
})
|
||||
"
|
||||
@click="pickAlbum(album)"
|
||||
>
|
||||
<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">
|
||||
{{ n('photos', '%n item', '%n items', album.count) }}
|
||||
<!-- TODO: finish collaboration -->
|
||||
<!--⸱ {{ n('photos', 'Share with %n user', 'Share with %n users', album.isShared) }}-->
|
||||
</template>
|
||||
</NcListItem>
|
||||
</ul>
|
||||
<template slot="subtitle">
|
||||
{{ n("photos", "%n item", "%n items", album.count) }}
|
||||
<!-- TODO: finish collaboration -->
|
||||
<!--⸱ {{ n('photos', 'Share with %n user', 'Share with %n users', album.isShared) }}-->
|
||||
</template>
|
||||
</NcListItem>
|
||||
</ul>
|
||||
|
||||
<NcButton :aria-label="t('photos', 'Create a new album.')"
|
||||
class="new-album-button"
|
||||
type="tertiary"
|
||||
@click="showAlbumCreationForm = true">
|
||||
<template #icon>
|
||||
<Plus />
|
||||
</template>
|
||||
{{ t('photos', 'Create new album') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
<NcButton
|
||||
:aria-label="t('photos', 'Create a new album.')"
|
||||
class="new-album-button"
|
||||
type="tertiary"
|
||||
@click="showAlbumCreationForm = true"
|
||||
>
|
||||
<template #icon>
|
||||
<Plus />
|
||||
</template>
|
||||
{{ t("photos", "Create new album") }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<AlbumForm v-else
|
||||
:display-back-button="true"
|
||||
:title="t('photos', 'New album')"
|
||||
@back="showAlbumCreationForm = false"
|
||||
@done="albumCreatedHandler" />
|
||||
<AlbumForm
|
||||
v-else
|
||||
:display-back-button="true"
|
||||
:title="t('photos', 'New album')"
|
||||
@back="showAlbumCreationForm = false"
|
||||
@done="albumCreatedHandler"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Mixins } from 'vue-property-decorator';
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import { getCurrentUser } from '@nextcloud/auth';
|
||||
import { Component, Emit, Mixins } from "vue-property-decorator";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
import { getCurrentUser } from "@nextcloud/auth";
|
||||
|
||||
import AlbumForm from './AlbumForm.vue'
|
||||
import Plus from 'vue-material-design-icons/Plus.vue'
|
||||
import ImageMultiple from 'vue-material-design-icons/ImageMultiple.vue'
|
||||
import AlbumForm from "./AlbumForm.vue";
|
||||
import Plus from "vue-material-design-icons/Plus.vue";
|
||||
import ImageMultiple from "vue-material-design-icons/ImageMultiple.vue";
|
||||
|
||||
import { NcButton, NcListItem, NcLoadingIcon } from '@nextcloud/vue'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { IAlbum } from '../../types';
|
||||
import axios from '@nextcloud/axios'
|
||||
import { NcButton, NcListItem, NcLoadingIcon } from "@nextcloud/vue";
|
||||
import { generateUrl } from "@nextcloud/router";
|
||||
import { IAlbum } from "../../types";
|
||||
import axios from "@nextcloud/axios";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
AlbumForm,
|
||||
Plus,
|
||||
ImageMultiple,
|
||||
NcButton,
|
||||
NcListItem,
|
||||
NcLoadingIcon,
|
||||
components: {
|
||||
AlbumForm,
|
||||
Plus,
|
||||
ImageMultiple,
|
||||
NcButton,
|
||||
NcListItem,
|
||||
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) {
|
||||
private showAlbumCreationForm = false;
|
||||
private albums: IAlbum[] = [];
|
||||
private loadingAlbums = true;
|
||||
private showAlbumCreationForm = false;
|
||||
private albums: IAlbum[] = [];
|
||||
private loadingAlbums = true;
|
||||
|
||||
mounted() {
|
||||
this.loadAlbums();
|
||||
mounted() {
|
||||
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() {
|
||||
this.showAlbumCreationForm = false
|
||||
this.loadAlbums();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
getAlbumName(album: IAlbum) {
|
||||
if (album.user === getCurrentUser()?.uid) {
|
||||
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) {}
|
||||
@Emit("select")
|
||||
pickAlbum(album: IAlbum) {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.album-picker {
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
|
||||
.loading-icon {
|
||||
margin-left: 32px;
|
||||
}
|
||||
}
|
||||
.loading-icon {
|
||||
margin-left: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.albums-container {
|
||||
min-height: 150px;
|
||||
max-height: 350px;
|
||||
overflow-x: scroll;
|
||||
padding: 2px;
|
||||
.albums-container {
|
||||
min-height: 150px;
|
||||
max-height: 350px;
|
||||
overflow-x: scroll;
|
||||
padding: 2px;
|
||||
|
||||
.album {
|
||||
.album {
|
||||
:deep .list-item {
|
||||
padding: 8px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:deep .list-item {
|
||||
padding: 8px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
&__image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
&__image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--border-radius);
|
||||
&--placeholder {
|
||||
background: var(--color-primary-light);
|
||||
|
||||
&--placeholder {
|
||||
background: var(--color-primary-light);
|
||||
:deep .material-design-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
:deep .material-design-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.material-design-icon__svg {
|
||||
fill: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.material-design-icon__svg {
|
||||
fill: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.new-album-button {
|
||||
margin-top: 32px;
|
||||
}
|
||||
.new-album-button {
|
||||
margin-top: 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,76 +1,83 @@
|
|||
<template>
|
||||
<Modal @close="close" v-if="show">
|
||||
<template #title>
|
||||
{{ t('memories', 'Share Album') }}
|
||||
</template>
|
||||
<Modal @close="close" v-if="show">
|
||||
<template #title>
|
||||
{{ t("memories", "Share Album") }}
|
||||
</template>
|
||||
|
||||
<AlbumCollaborators v-if="album"
|
||||
:album-name="album.basename"
|
||||
:collaborators="album.collaborators"
|
||||
:public-link="album.publicLink">
|
||||
<template slot-scope="{collaborators}">
|
||||
<NcButton :aria-label="t('photos', 'Save collaborators for this album.')"
|
||||
type="primary"
|
||||
:disabled="loadingAddCollaborators"
|
||||
@click="handleSetCollaborators(collaborators)">
|
||||
<template #icon>
|
||||
<NcLoadingIcon v-if="loadingAddCollaborators" />
|
||||
</template>
|
||||
{{ t('photos', 'Save') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</AlbumCollaborators>
|
||||
</Modal>
|
||||
<AlbumCollaborators
|
||||
v-if="album"
|
||||
:album-name="album.basename"
|
||||
:collaborators="album.collaborators"
|
||||
:public-link="album.publicLink"
|
||||
>
|
||||
<template slot-scope="{ collaborators }">
|
||||
<NcButton
|
||||
:aria-label="t('photos', 'Save collaborators for this album.')"
|
||||
type="primary"
|
||||
:disabled="loadingAddCollaborators"
|
||||
@click="handleSetCollaborators(collaborators)"
|
||||
>
|
||||
<template #icon>
|
||||
<NcLoadingIcon v-if="loadingAddCollaborators" />
|
||||
</template>
|
||||
{{ t("photos", "Save") }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</AlbumCollaborators>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Mixins } from 'vue-property-decorator';
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import { Component, Emit, Mixins } from "vue-property-decorator";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
|
||||
import { NcButton, NcLoadingIcon } from '@nextcloud/vue';
|
||||
import * as dav from '../../services/DavRequests';
|
||||
import { NcButton, NcLoadingIcon } from "@nextcloud/vue";
|
||||
import * as dav from "../../services/DavRequests";
|
||||
|
||||
import Modal from './Modal.vue';
|
||||
import AlbumCollaborators from './AlbumCollaborators.vue';
|
||||
import Modal from "./Modal.vue";
|
||||
import AlbumCollaborators from "./AlbumCollaborators.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcButton,
|
||||
NcLoadingIcon,
|
||||
Modal,
|
||||
AlbumCollaborators,
|
||||
}
|
||||
components: {
|
||||
NcButton,
|
||||
NcLoadingIcon,
|
||||
Modal,
|
||||
AlbumCollaborators,
|
||||
},
|
||||
})
|
||||
export default class AlbumShareModal extends Mixins(GlobalMixin) {
|
||||
private album: any = null;
|
||||
private show = false;
|
||||
private loadingAddCollaborators = false;
|
||||
private album: any = null;
|
||||
private show = false;
|
||||
private loadingAddCollaborators = false;
|
||||
|
||||
@Emit('close')
|
||||
public close() {
|
||||
this.show = false;
|
||||
this.album = null;
|
||||
}
|
||||
@Emit("close")
|
||||
public close() {
|
||||
this.show = false;
|
||||
this.album = null;
|
||||
}
|
||||
|
||||
public async open() {
|
||||
this.show = true;
|
||||
this.loadingAddCollaborators = true;
|
||||
const user = this.$route.params.user || '';
|
||||
const name = this.$route.params.name || '';
|
||||
this.album = await dav.getAlbum(user, name);
|
||||
this.loadingAddCollaborators = false;
|
||||
}
|
||||
public async open() {
|
||||
this.show = true;
|
||||
this.loadingAddCollaborators = true;
|
||||
const user = this.$route.params.user || "";
|
||||
const name = this.$route.params.name || "";
|
||||
this.album = await dav.getAlbum(user, name);
|
||||
this.loadingAddCollaborators = false;
|
||||
}
|
||||
|
||||
async handleSetCollaborators(collaborators: any[]) {
|
||||
try {
|
||||
this.loadingAddCollaborators = true
|
||||
await dav.updateAlbum(this.album, { albumName: this.album.basename, properties: { collaborators } })
|
||||
this.close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.loadingAddCollaborators = false
|
||||
}
|
||||
async handleSetCollaborators(collaborators: any[]) {
|
||||
try {
|
||||
this.loadingAddCollaborators = true;
|
||||
await dav.updateAlbum(this.album, {
|
||||
albumName: this.album.basename,
|
||||
properties: { collaborators },
|
||||
});
|
||||
this.close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.loadingAddCollaborators = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -1,425 +1,463 @@
|
|||
<template>
|
||||
<Modal
|
||||
v-if="photos.length > 0"
|
||||
@close="close">
|
||||
<Modal v-if="photos.length > 0" @close="close">
|
||||
<template #title>
|
||||
{{ t("memories", "Edit Date/Time") }}
|
||||
</template>
|
||||
|
||||
<template #title>
|
||||
{{ t('memories', 'Edit Date/Time') }}
|
||||
</template>
|
||||
<template #buttons>
|
||||
<NcButton @click="save" class="button" type="error" v-if="longDateStr">
|
||||
{{ t("memories", "Update Exif") }}
|
||||
</NcButton>
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<NcButton @click="save" class="button" type="error" v-if="longDateStr">
|
||||
{{ t('memories', 'Update Exif') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
<div v-if="longDateStr">
|
||||
<span v-if="photos.length > 1"> [{{ t("memories", "Newest") }}] </span>
|
||||
{{ longDateStr }}
|
||||
|
||||
<div v-if="longDateStr">
|
||||
<span v-if="photos.length > 1">
|
||||
[{{ t('memories', 'Newest') }}]
|
||||
</span>
|
||||
{{ longDateStr }}
|
||||
<div class="fields">
|
||||
<NcTextField
|
||||
:value.sync="year"
|
||||
class="field"
|
||||
@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">
|
||||
<NcTextField :value.sync="year"
|
||||
class="field"
|
||||
@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">
|
||||
<span> [{{ t("memories", "Oldest") }}] </span>
|
||||
{{ longDateStrLast }}
|
||||
|
||||
<div v-if="photos.length > 1" class="oldest">
|
||||
<span>
|
||||
[{{ t('memories', 'Oldest') }}]
|
||||
</span>
|
||||
{{ longDateStrLast }}
|
||||
|
||||
<div class="fields">
|
||||
<NcTextField :value.sync="yearLast"
|
||||
class="field"
|
||||
:label="t('memories', 'Year')" :label-visible="true"
|
||||
:placeholder="t('memories', 'Year')" />
|
||||
<NcTextField :value.sync="monthLast"
|
||||
class="field"
|
||||
:label="t('memories', 'Month')" :label-visible="true"
|
||||
:placeholder="t('memories', 'Month')" />
|
||||
<NcTextField :value.sync="dayLast"
|
||||
class="field"
|
||||
:label="t('memories', 'Day')" :label-visible="true"
|
||||
:placeholder="t('memories', 'Day')" />
|
||||
<NcTextField :value.sync="hourLast"
|
||||
class="field"
|
||||
:label="t('memories', 'Time')" :label-visible="true"
|
||||
:placeholder="t('memories', 'Hour')" />
|
||||
<NcTextField :value.sync="minuteLast"
|
||||
class="field"
|
||||
:label="t('memories', 'Minute')"
|
||||
:placeholder="t('memories', 'Minute')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="processing" class="info-pad">
|
||||
{{ t('memories', 'Processing … {n}/{m}', {
|
||||
n: photosDone,
|
||||
m: photos.length,
|
||||
}) }}
|
||||
</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 class="fields">
|
||||
<NcTextField
|
||||
:value.sync="yearLast"
|
||||
class="field"
|
||||
:label="t('memories', 'Year')"
|
||||
:label-visible="true"
|
||||
:placeholder="t('memories', 'Year')"
|
||||
/>
|
||||
<NcTextField
|
||||
:value.sync="monthLast"
|
||||
class="field"
|
||||
:label="t('memories', 'Month')"
|
||||
:label-visible="true"
|
||||
:placeholder="t('memories', 'Month')"
|
||||
/>
|
||||
<NcTextField
|
||||
:value.sync="dayLast"
|
||||
class="field"
|
||||
:label="t('memories', 'Day')"
|
||||
:label-visible="true"
|
||||
:placeholder="t('memories', 'Day')"
|
||||
/>
|
||||
<NcTextField
|
||||
:value.sync="hourLast"
|
||||
class="field"
|
||||
:label="t('memories', 'Time')"
|
||||
:label-visible="true"
|
||||
:placeholder="t('memories', 'Hour')"
|
||||
/>
|
||||
<NcTextField
|
||||
:value.sync="minuteLast"
|
||||
class="field"
|
||||
:label="t('memories', 'Minute')"
|
||||
:placeholder="t('memories', 'Minute')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
{{ t('memories', 'Loading data … {n}/{m}', {
|
||||
n: photosDone,
|
||||
m: photos.length,
|
||||
}) }}
|
||||
</div>
|
||||
</Modal>
|
||||
<div v-if="processing" class="info-pad">
|
||||
{{
|
||||
t("memories", "Processing … {n}/{m}", {
|
||||
n: photosDone,
|
||||
m: photos.length,
|
||||
})
|
||||
}}
|
||||
</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>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Mixins } from 'vue-property-decorator';
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import { IPhoto } from '../../types';
|
||||
import { Component, Emit, Mixins } from "vue-property-decorator";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
import { IPhoto } from "../../types";
|
||||
|
||||
import { NcButton, NcTextField } from '@nextcloud/vue';
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import Modal from './Modal.vue';
|
||||
import axios from '@nextcloud/axios'
|
||||
import * as utils from '../../services/Utils';
|
||||
import { NcButton, NcTextField } from "@nextcloud/vue";
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
import { generateUrl } from "@nextcloud/router";
|
||||
import Modal from "./Modal.vue";
|
||||
import axios from "@nextcloud/axios";
|
||||
import * as utils from "../../services/Utils";
|
||||
import * as dav from "../../services/DavRequests";
|
||||
|
||||
const INFO_API_URL = '/apps/memories/api/info/{id}';
|
||||
const EDIT_API_URL = '/apps/memories/api/edit/{id}';
|
||||
const INFO_API_URL = "/apps/memories/api/info/{id}";
|
||||
const EDIT_API_URL = "/apps/memories/api/edit/{id}";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcButton,
|
||||
NcTextField,
|
||||
Modal,
|
||||
}
|
||||
components: {
|
||||
NcButton,
|
||||
NcTextField,
|
||||
Modal,
|
||||
},
|
||||
})
|
||||
export default class EditDate extends Mixins(GlobalMixin) {
|
||||
@Emit('refresh') emitRefresh(val: boolean) {}
|
||||
@Emit("refresh") emitRefresh(val: boolean) {}
|
||||
|
||||
private photos: IPhoto[] = [];
|
||||
private photosDone: number = 0;
|
||||
private processing: boolean = false;
|
||||
private photos: IPhoto[] = [];
|
||||
private photosDone: number = 0;
|
||||
private processing: boolean = false;
|
||||
|
||||
private longDateStr: string = '';
|
||||
private year: string = "0";
|
||||
private month: string = "0";
|
||||
private day: string = "0";
|
||||
private hour: string = "0";
|
||||
private minute: string = "0";
|
||||
private second: string = "0";
|
||||
private longDateStr: string = "";
|
||||
private year: string = "0";
|
||||
private month: string = "0";
|
||||
private day: string = "0";
|
||||
private hour: string = "0";
|
||||
private minute: string = "0";
|
||||
private second: string = "0";
|
||||
|
||||
private longDateStrLast: string = '';
|
||||
private yearLast: string = "0";
|
||||
private monthLast: string = "0";
|
||||
private dayLast: string = "0";
|
||||
private hourLast: string = "0";
|
||||
private minuteLast: string = "0";
|
||||
private secondLast: string = "0";
|
||||
private longDateStrLast: string = "";
|
||||
private yearLast: string = "0";
|
||||
private monthLast: string = "0";
|
||||
private dayLast: string = "0";
|
||||
private hourLast: string = "0";
|
||||
private minuteLast: string = "0";
|
||||
private secondLast: string = "0";
|
||||
|
||||
public async open(photos: IPhoto[]) {
|
||||
this.photos = photos;
|
||||
if (photos.length === 0) {
|
||||
return;
|
||||
public async open(photos: IPhoto[]) {
|
||||
this.photos = photos;
|
||||
if (photos.length === 0) {
|
||||
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;
|
||||
this.longDateStr = '';
|
||||
p.datetaken = res.data.datetaken * 1000;
|
||||
} catch (error) {
|
||||
console.error("Failed to get date info for", p.fileid, error);
|
||||
} finally {
|
||||
this.photosDone++;
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
for await (const _ of dav.runInParallel(calls, 10)) {
|
||||
// nothing to do
|
||||
}
|
||||
|
||||
public newestChange(time=false) {
|
||||
if (this.photos.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Remove photos without datetaken
|
||||
this.photos = this.photos.filter((p) => p.datetaken !== undefined);
|
||||
|
||||
// Set the last date to have the same offset to newest date
|
||||
try {
|
||||
const date = new Date(this.photos[0].datetaken);
|
||||
const dateLast = new Date(this.photos[this.photos.length - 1].datetaken);
|
||||
// Sort photos by datetaken descending
|
||||
this.photos.sort((a, b) => b.datetaken - a.datetaken);
|
||||
|
||||
const dateNew = this.getDate();
|
||||
const offset = dateNew.getTime() - date.getTime();
|
||||
const dateLastNew = new Date(dateLast.getTime() + offset);
|
||||
// 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);
|
||||
|
||||
this.yearLast = dateLastNew.getUTCFullYear().toString();
|
||||
this.monthLast = (dateLastNew.getUTCMonth() + 1).toString();
|
||||
this.dayLast = dateLastNew.getUTCDate().toString();
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
if (time) {
|
||||
this.hourLast = dateLastNew.getUTCHours().toString();
|
||||
this.minuteLast = dateLastNew.getUTCMinutes().toString();
|
||||
this.secondLast = dateLastNew.getUTCSeconds().toString();
|
||||
}
|
||||
} catch (error) {}
|
||||
public newestChange(time = false) {
|
||||
if (this.photos.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.photos = [];
|
||||
// Set the last date to have the same offset to newest date
|
||||
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() {
|
||||
// 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;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
public async saveMany() {
|
||||
if (this.processing) {
|
||||
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();
|
||||
// Validate if the old is still old
|
||||
if (diffNew < 0) {
|
||||
showError("The newest date must be newer than the oldest date");
|
||||
return;
|
||||
}
|
||||
|
||||
public async save() {
|
||||
if (this.photos.length === 0) {
|
||||
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;
|
||||
}
|
||||
|
||||
if (this.photos.length === 1) {
|
||||
return await this.saveOne();
|
||||
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++;
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
const year = date.getUTCFullYear().toString().padStart(4, "0");
|
||||
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}`;
|
||||
if (this.photos.length === 1) {
|
||||
return await this.saveOne();
|
||||
}
|
||||
|
||||
public getDate() {
|
||||
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;
|
||||
return await this.saveMany();
|
||||
}
|
||||
|
||||
if (isNaN(year)) throw new Error("Invalid year");
|
||||
if (isNaN(month)) throw new Error("Invalid month");
|
||||
if (isNaN(day)) throw new Error("Invalid day");
|
||||
if (isNaN(hour)) throw new Error("Invalid hour");
|
||||
if (isNaN(minute)) throw new Error("Invalid minute");
|
||||
if (isNaN(second)) throw new Error("Invalid second");
|
||||
private getExifFormat(date: Date) {
|
||||
const year = date.getUTCFullYear().toString().padStart(4, "0");
|
||||
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}`;
|
||||
}
|
||||
|
||||
dateNew.setUTCFullYear(year);
|
||||
dateNew.setUTCMonth(month);
|
||||
dateNew.setUTCDate(day);
|
||||
dateNew.setUTCHours(hour);
|
||||
dateNew.setUTCMinutes(minute);
|
||||
dateNew.setUTCSeconds(second);
|
||||
return dateNew;
|
||||
}
|
||||
public getDate() {
|
||||
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;
|
||||
|
||||
public getDateLast() {
|
||||
const dateNew = new Date();
|
||||
const year = parseInt(this.yearLast, 10);
|
||||
const month = parseInt(this.monthLast, 10) - 1;
|
||||
const day = parseInt(this.dayLast, 10);
|
||||
const hour = parseInt(this.hourLast, 10);
|
||||
const minute = parseInt(this.minuteLast, 10);
|
||||
const second = parseInt(this.secondLast, 10) || 0;
|
||||
if (isNaN(year)) throw new Error("Invalid year");
|
||||
if (isNaN(month)) throw new Error("Invalid month");
|
||||
if (isNaN(day)) throw new Error("Invalid day");
|
||||
if (isNaN(hour)) throw new Error("Invalid hour");
|
||||
if (isNaN(minute)) throw new Error("Invalid minute");
|
||||
if (isNaN(second)) throw new Error("Invalid second");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
dateNew.setUTCFullYear(year);
|
||||
dateNew.setUTCMonth(month);
|
||||
dateNew.setUTCDate(day);
|
||||
dateNew.setUTCHours(hour);
|
||||
dateNew.setUTCMinutes(minute);
|
||||
dateNew.setUTCSeconds(second);
|
||||
return dateNew;
|
||||
}
|
||||
public getDateLast() {
|
||||
const dateNew = new Date();
|
||||
const year = parseInt(this.yearLast, 10);
|
||||
const month = parseInt(this.monthLast, 10) - 1;
|
||||
const day = parseInt(this.dayLast, 10);
|
||||
const hour = parseInt(this.hourLast, 10);
|
||||
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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fields {
|
||||
.field {
|
||||
width: 4.1em;
|
||||
display: inline-block;
|
||||
}
|
||||
.field {
|
||||
width: 4.1em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
:deep label {
|
||||
font-size: 0.8em;
|
||||
padding: 0 !important;
|
||||
padding-left: 3px !important;
|
||||
}
|
||||
:deep label {
|
||||
font-size: 0.8em;
|
||||
padding: 0 !important;
|
||||
padding-left: 3px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.oldest {
|
||||
margin-top: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.info-pad {
|
||||
margin-top: 6px;
|
||||
margin-bottom: 2px;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 2px;
|
||||
|
||||
&.warn {
|
||||
color: #f44336;
|
||||
font-size: 0.8em;
|
||||
line-height: 1em;
|
||||
}
|
||||
&.warn {
|
||||
color: #f44336;
|
||||
font-size: 0.8em;
|
||||
line-height: 1em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,79 +1,87 @@
|
|||
<template>
|
||||
<Modal @close="close" v-if="show">
|
||||
<template #title>
|
||||
{{ t('memories', 'Remove person') }}
|
||||
</template>
|
||||
<Modal @close="close" v-if="show">
|
||||
<template #title>
|
||||
{{ t("memories", "Remove person") }}
|
||||
</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>
|
||||
<NcButton @click="save" class="button" type="error">
|
||||
{{ t('memories', 'Delete') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</Modal>
|
||||
<template #buttons>
|
||||
<NcButton @click="save" class="button" type="error">
|
||||
{{ t("memories", "Delete") }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Mixins, Watch } from 'vue-property-decorator';
|
||||
import { NcButton, NcTextField } from '@nextcloud/vue';
|
||||
import { showError } from '@nextcloud/dialogs';
|
||||
import { getCurrentUser } from '@nextcloud/auth';
|
||||
import Modal from './Modal.vue';
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import client from '../../services/DavClient';
|
||||
import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
|
||||
import { NcButton, NcTextField } from "@nextcloud/vue";
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
import { getCurrentUser } from "@nextcloud/auth";
|
||||
import Modal from "./Modal.vue";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
import client from "../../services/DavClient";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcButton,
|
||||
NcTextField,
|
||||
Modal,
|
||||
}
|
||||
components: {
|
||||
NcButton,
|
||||
NcTextField,
|
||||
Modal,
|
||||
},
|
||||
})
|
||||
export default class FaceDeleteModal extends Mixins(GlobalMixin) {
|
||||
private user: string = "";
|
||||
private name: string = "";
|
||||
private show = false;
|
||||
private user: string = "";
|
||||
private name: string = "";
|
||||
private show = false;
|
||||
|
||||
@Emit('close')
|
||||
public close() {
|
||||
this.show = false;
|
||||
}
|
||||
@Emit("close")
|
||||
public close() {
|
||||
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 delete this person', { user }));
|
||||
return;
|
||||
}
|
||||
this.show = true;
|
||||
public open() {
|
||||
const user = this.$route.params.user || "";
|
||||
if (this.$route.params.user !== getCurrentUser().uid) {
|
||||
showError(
|
||||
this.t("memories", 'Only user "{user}" can delete this person', {
|
||||
user,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.show = true;
|
||||
}
|
||||
|
||||
@Watch('$route')
|
||||
async routeChange(from: any, to: any) {
|
||||
this.refreshParams();
|
||||
}
|
||||
@Watch("$route")
|
||||
async routeChange(from: any, to: any) {
|
||||
this.refreshParams();
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.refreshParams();
|
||||
}
|
||||
mounted() {
|
||||
this.refreshParams();
|
||||
}
|
||||
|
||||
public refreshParams() {
|
||||
this.user = this.$route.params.user || '';
|
||||
this.name = this.$route.params.name || '';
|
||||
}
|
||||
public refreshParams() {
|
||||
this.user = this.$route.params.user || "";
|
||||
this.name = this.$route.params.name || "";
|
||||
}
|
||||
|
||||
public async save() {
|
||||
try {
|
||||
await client.deleteFile(`/recognize/${this.user}/faces/${this.name}`)
|
||||
this.$router.push({ name: 'people' });
|
||||
this.close();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
showError(this.t('photos', 'Failed to delete {name}.', {
|
||||
name: this.name,
|
||||
}));
|
||||
}
|
||||
public async save() {
|
||||
try {
|
||||
await client.deleteFile(`/recognize/${this.user}/faces/${this.name}`);
|
||||
this.$router.push({ name: "people" });
|
||||
this.close();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
showError(
|
||||
this.t("photos", "Failed to delete {name}.", {
|
||||
name: this.name,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -1,97 +1,109 @@
|
|||
<template>
|
||||
<Modal @close="close" v-if="show">
|
||||
<template #title>
|
||||
{{ t('memories', 'Rename person') }}
|
||||
</template>
|
||||
<Modal @close="close" v-if="show">
|
||||
<template #title>
|
||||
{{ t("memories", "Rename person") }}
|
||||
</template>
|
||||
|
||||
<div class="fields">
|
||||
<NcTextField :value.sync="name"
|
||||
class="field"
|
||||
:label="t('memories', 'Name')" :label-visible="false"
|
||||
:placeholder="t('memories', 'Name')"
|
||||
@keypress.enter="save()" />
|
||||
</div>
|
||||
<div class="fields">
|
||||
<NcTextField
|
||||
:value.sync="name"
|
||||
class="field"
|
||||
:label="t('memories', 'Name')"
|
||||
:label-visible="false"
|
||||
:placeholder="t('memories', 'Name')"
|
||||
@keypress.enter="save()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #buttons>
|
||||
<NcButton @click="save" class="button" type="primary">
|
||||
{{ t('memories', 'Update') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</Modal>
|
||||
<template #buttons>
|
||||
<NcButton @click="save" class="button" type="primary">
|
||||
{{ t("memories", "Update") }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Mixins, Watch } from 'vue-property-decorator';
|
||||
import { NcButton, NcTextField } from '@nextcloud/vue';
|
||||
import { showError } from '@nextcloud/dialogs';
|
||||
import { getCurrentUser } from '@nextcloud/auth';
|
||||
import Modal from './Modal.vue';
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import client from '../../services/DavClient';
|
||||
import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
|
||||
import { NcButton, NcTextField } from "@nextcloud/vue";
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
import { getCurrentUser } from "@nextcloud/auth";
|
||||
import Modal from "./Modal.vue";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
import client from "../../services/DavClient";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcButton,
|
||||
NcTextField,
|
||||
Modal,
|
||||
}
|
||||
components: {
|
||||
NcButton,
|
||||
NcTextField,
|
||||
Modal,
|
||||
},
|
||||
})
|
||||
export default class FaceEditModal extends Mixins(GlobalMixin) {
|
||||
private user: string = "";
|
||||
private name: string = "";
|
||||
private oldName: string = "";
|
||||
private show = false;
|
||||
private user: string = "";
|
||||
private name: string = "";
|
||||
private oldName: string = "";
|
||||
private show = false;
|
||||
|
||||
@Emit('close')
|
||||
public close() {
|
||||
this.show = false;
|
||||
}
|
||||
@Emit("close")
|
||||
public close() {
|
||||
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 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;
|
||||
}
|
||||
|
||||
@Watch('$route')
|
||||
async routeChange(from: any, to: any) {
|
||||
this.refreshParams();
|
||||
}
|
||||
@Watch("$route")
|
||||
async routeChange(from: any, to: any) {
|
||||
this.refreshParams();
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.refreshParams();
|
||||
}
|
||||
mounted() {
|
||||
this.refreshParams();
|
||||
}
|
||||
|
||||
public refreshParams() {
|
||||
this.user = this.$route.params.user || '';
|
||||
this.name = this.$route.params.name || '';
|
||||
this.oldName = this.$route.params.name || '';
|
||||
}
|
||||
public refreshParams() {
|
||||
this.user = this.$route.params.user || "";
|
||||
this.name = this.$route.params.name || "";
|
||||
this.oldName = this.$route.params.name || "";
|
||||
}
|
||||
|
||||
public async save() {
|
||||
try {
|
||||
await client.moveFile(
|
||||
`/recognize/${this.user}/faces/${this.oldName}`,
|
||||
`/recognize/${this.user}/faces/${this.name}`,
|
||||
);
|
||||
this.$router.push({ name: 'people', params: { user: this.user, name: this.name } });
|
||||
this.close();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
showError(this.t('photos', 'Failed to rename {oldName} to {name}.', {
|
||||
oldName: this.oldName,
|
||||
name: this.name,
|
||||
}));
|
||||
}
|
||||
public async save() {
|
||||
try {
|
||||
await client.moveFile(
|
||||
`/recognize/${this.user}/faces/${this.oldName}`,
|
||||
`/recognize/${this.user}/faces/${this.name}`
|
||||
);
|
||||
this.$router.push({
|
||||
name: "people",
|
||||
params: { user: this.user, name: this.name },
|
||||
});
|
||||
this.close();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
showError(
|
||||
this.t("photos", "Failed to rename {oldName} to {name}.", {
|
||||
oldName: this.oldName,
|
||||
name: this.name,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fields {
|
||||
margin-top: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
|
@ -1,83 +1,83 @@
|
|||
<template>
|
||||
<div class="outer" v-if="detail">
|
||||
<div class="photo" v-for="photo of detail" :key="photo.fileid" >
|
||||
<Tag :data="photo" :noNavigate="true" @open="clickFace" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ t('memories', 'Loading …') }}
|
||||
<div class="outer" v-if="detail">
|
||||
<div class="photo" v-for="photo of detail" :key="photo.fileid">
|
||||
<Tag :data="photo" :noNavigate="true" @open="clickFace" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ t("memories", "Loading …") }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Mixins, Watch } from 'vue-property-decorator';
|
||||
import { IPhoto, ITag } from '../../types';
|
||||
import Tag from '../frame/Tag.vue';
|
||||
import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
|
||||
import { IPhoto, ITag } from "../../types";
|
||||
import Tag from "../frame/Tag.vue";
|
||||
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import * as dav from '../../services/DavRequests';
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
import * as dav from "../../services/DavRequests";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
Tag,
|
||||
}
|
||||
components: {
|
||||
Tag,
|
||||
},
|
||||
})
|
||||
export default class FaceMergeModal extends Mixins(GlobalMixin) {
|
||||
private user: string = "";
|
||||
private name: string = "";
|
||||
private detail: IPhoto[] | null = null;
|
||||
private user: string = "";
|
||||
private name: string = "";
|
||||
private detail: IPhoto[] | null = null;
|
||||
|
||||
@Emit('close')
|
||||
public close() {}
|
||||
@Emit("close")
|
||||
public close() {}
|
||||
|
||||
@Watch('$route')
|
||||
async routeChange(from: any, to: any) {
|
||||
this.refreshParams();
|
||||
}
|
||||
@Watch("$route")
|
||||
async routeChange(from: any, to: any) {
|
||||
this.refreshParams();
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.refreshParams();
|
||||
}
|
||||
mounted() {
|
||||
this.refreshParams();
|
||||
}
|
||||
|
||||
public async refreshParams() {
|
||||
this.user = this.$route.params.user || '';
|
||||
this.name = this.$route.params.name || '';
|
||||
this.detail = null;
|
||||
public async refreshParams() {
|
||||
this.user = this.$route.params.user || "";
|
||||
this.name = this.$route.params.name || "";
|
||||
this.detail = null;
|
||||
|
||||
const data = await dav.getPeopleData();
|
||||
let detail = data[0].detail;
|
||||
detail.forEach((photo: IPhoto) => {
|
||||
photo.flag = this.c.FLAG_IS_FACE | this.c.FLAG_IS_TAG;
|
||||
});
|
||||
detail = detail.filter((photo: ITag) => {
|
||||
const pname = photo.name || photo.fileid.toString();
|
||||
return photo.user_id !== this.user || pname !== this.name;
|
||||
});
|
||||
const data = await dav.getPeopleData();
|
||||
let detail = data[0].detail;
|
||||
detail.forEach((photo: IPhoto) => {
|
||||
photo.flag = this.c.FLAG_IS_FACE | this.c.FLAG_IS_TAG;
|
||||
});
|
||||
detail = detail.filter((photo: ITag) => {
|
||||
const pname = photo.name || photo.fileid.toString();
|
||||
return photo.user_id !== this.user || pname !== this.name;
|
||||
});
|
||||
|
||||
this.detail = detail;
|
||||
}
|
||||
this.detail = detail;
|
||||
}
|
||||
|
||||
@Emit('select')
|
||||
public async clickFace(face: ITag) {}
|
||||
@Emit("select")
|
||||
public async clickFace(face: ITag) {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.outer {
|
||||
width: 100%;
|
||||
max-height: calc(90vh - 80px - 4em);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
max-height: calc(90vh - 80px - 4em);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.photo {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
vertical-align: top;
|
||||
font-size: 0.8em;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
vertical-align: top;
|
||||
font-size: 0.8em;
|
||||
|
||||
max-width: 120px;
|
||||
width: calc(33.33%);
|
||||
aspect-ratio: 1/1;
|
||||
max-width: 120px;
|
||||
width: calc(33.33%);
|
||||
aspect-ratio: 1/1;
|
||||
}
|
||||
</style>
|
|
@ -1,136 +1,156 @@
|
|||
<template>
|
||||
<Modal @close="close" size="large" v-if="show">
|
||||
<template #title>
|
||||
{{ t('memories', 'Merge {name} with person', { name: $route.params.name }) }}
|
||||
</template>
|
||||
<Modal @close="close" size="large" v-if="show">
|
||||
<template #title>
|
||||
{{
|
||||
t("memories", "Merge {name} with person", { name: $route.params.name })
|
||||
}}
|
||||
</template>
|
||||
|
||||
<div class="outer">
|
||||
<FaceList @select="clickFace" />
|
||||
<div class="outer">
|
||||
<FaceList @select="clickFace" />
|
||||
|
||||
<div v-if="procesingTotal > 0" class="info-pad">
|
||||
{{ t('memories', 'Processing … {n}/{m}', {
|
||||
n: processing,
|
||||
m: procesingTotal,
|
||||
}) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="procesingTotal > 0" class="info-pad">
|
||||
{{
|
||||
t("memories", "Processing … {n}/{m}", {
|
||||
n: processing,
|
||||
m: procesingTotal,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #buttons>
|
||||
<NcButton @click="close" class="button" type="error">
|
||||
{{ t('memories', 'Cancel') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</Modal>
|
||||
<template #buttons>
|
||||
<NcButton @click="close" class="button" type="error">
|
||||
{{ t("memories", "Cancel") }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Mixins } from 'vue-property-decorator';
|
||||
import { NcButton, NcTextField } from '@nextcloud/vue';
|
||||
import { showError } from '@nextcloud/dialogs';
|
||||
import { getCurrentUser } from '@nextcloud/auth';
|
||||
import { IFileInfo, ITag } from '../../types';
|
||||
import Tag from '../frame/Tag.vue';
|
||||
import FaceList from './FaceList.vue';
|
||||
import { Component, Emit, Mixins } from "vue-property-decorator";
|
||||
import { NcButton, NcTextField } from "@nextcloud/vue";
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
import { getCurrentUser } from "@nextcloud/auth";
|
||||
import { IFileInfo, ITag } from "../../types";
|
||||
import Tag from "../frame/Tag.vue";
|
||||
import FaceList from "./FaceList.vue";
|
||||
|
||||
import Modal from './Modal.vue';
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import client from '../../services/DavClient';
|
||||
import * as dav from '../../services/DavRequests';
|
||||
import Modal from "./Modal.vue";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
import client from "../../services/DavClient";
|
||||
import * as dav from "../../services/DavRequests";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcButton,
|
||||
NcTextField,
|
||||
Modal,
|
||||
Tag,
|
||||
FaceList,
|
||||
}
|
||||
components: {
|
||||
NcButton,
|
||||
NcTextField,
|
||||
Modal,
|
||||
Tag,
|
||||
FaceList,
|
||||
},
|
||||
})
|
||||
export default class FaceMergeModal extends Mixins(GlobalMixin) {
|
||||
private processing = 0;
|
||||
private procesingTotal = 0;
|
||||
private show = false;
|
||||
private processing = 0;
|
||||
private procesingTotal = 0;
|
||||
private show = false;
|
||||
|
||||
@Emit('close')
|
||||
public close() {
|
||||
this.show = false;
|
||||
@Emit("close")
|
||||
public close() {
|
||||
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() {
|
||||
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;
|
||||
try {
|
||||
// Get all files for current face
|
||||
let res = (await client.getDirectoryContents(
|
||||
`/recognize/${user}/faces/${name}`,
|
||||
{ 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) {
|
||||
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;
|
||||
// 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
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all files for current face
|
||||
let res = await client.getDirectoryContents(
|
||||
`/recognize/${user}/faces/${name}`, { 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++;
|
||||
}
|
||||
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 }));
|
||||
}
|
||||
// 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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.outer {
|
||||
margin-top: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
.info-pad {
|
||||
margin-top: 6px;
|
||||
margin-bottom: 2px;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
</style>
|
|
@ -1,129 +1,141 @@
|
|||
<template>
|
||||
<Modal @close="close" size="large" v-if="show">
|
||||
<template #title>
|
||||
{{ t('memories', 'Move selected photos to person') }}
|
||||
</template>
|
||||
<Modal @close="close" size="large" v-if="show">
|
||||
<template #title>
|
||||
{{ t("memories", "Move selected photos to person") }}
|
||||
</template>
|
||||
|
||||
<div class="outer">
|
||||
<FaceList @select="clickFace" />
|
||||
</div>
|
||||
<div class="outer">
|
||||
<FaceList @select="clickFace" />
|
||||
</div>
|
||||
|
||||
<template #buttons>
|
||||
<NcButton @click="close" class="button" type="error">
|
||||
{{ t('memories', 'Cancel') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</Modal>
|
||||
<template #buttons>
|
||||
<NcButton @click="close" class="button" type="error">
|
||||
{{ t("memories", "Cancel") }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator';
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
|
||||
import { NcButton, NcTextField } from '@nextcloud/vue';
|
||||
import { showError } from '@nextcloud/dialogs';
|
||||
import { getCurrentUser } from '@nextcloud/auth';
|
||||
import { IPhoto, ITag } from '../../types';
|
||||
import Tag from '../frame/Tag.vue';
|
||||
import FaceList from './FaceList.vue';
|
||||
import { NcButton, NcTextField } from "@nextcloud/vue";
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
import { getCurrentUser } from "@nextcloud/auth";
|
||||
import { IPhoto, ITag } from "../../types";
|
||||
import Tag from "../frame/Tag.vue";
|
||||
import FaceList from "./FaceList.vue";
|
||||
|
||||
import Modal from './Modal.vue';
|
||||
import client from '../../services/DavClient';
|
||||
import * as dav from '../../services/DavRequests';
|
||||
import Modal from "./Modal.vue";
|
||||
import client from "../../services/DavClient";
|
||||
import * as dav from "../../services/DavRequests";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcButton,
|
||||
NcTextField,
|
||||
Modal,
|
||||
Tag,
|
||||
FaceList,
|
||||
}
|
||||
components: {
|
||||
NcButton,
|
||||
NcTextField,
|
||||
Modal,
|
||||
Tag,
|
||||
FaceList,
|
||||
},
|
||||
})
|
||||
export default class FaceMoveModal extends Mixins(GlobalMixin) {
|
||||
private show = false;
|
||||
private photos: IPhoto[] = [];
|
||||
private show = false;
|
||||
private photos: IPhoto[] = [];
|
||||
|
||||
@Prop()
|
||||
private updateLoading: (delta: number) => void;
|
||||
@Prop()
|
||||
private updateLoading: (delta: number) => void;
|
||||
|
||||
public open(photos: IPhoto[]) {
|
||||
if (this.photos.length) {
|
||||
// is processing
|
||||
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;
|
||||
public open(photos: IPhoto[]) {
|
||||
if (this.photos.length) {
|
||||
// is processing
|
||||
return;
|
||||
}
|
||||
|
||||
@Emit('close')
|
||||
public close() {
|
||||
this.photos = [];
|
||||
this.show = false;
|
||||
// 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;
|
||||
}
|
||||
|
||||
@Emit('moved')
|
||||
public moved(list: IPhoto[]) {}
|
||||
this.show = true;
|
||||
this.photos = photos;
|
||||
}
|
||||
|
||||
public async clickFace(face: ITag) {
|
||||
const user = this.$route.params.user || '';
|
||||
const name = this.$route.params.name || '';
|
||||
@Emit("close")
|
||||
public close() {
|
||||
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}))) {
|
||||
return;
|
||||
}
|
||||
public async clickFace(face: ITag) {
|
||||
const user = this.$route.params.user || "";
|
||||
const name = this.$route.params.name || "";
|
||||
|
||||
try {
|
||||
this.show = false;
|
||||
this.updateLoading(1);
|
||||
const newName = face.name || face.fileid.toString();
|
||||
|
||||
// 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();
|
||||
}
|
||||
if (
|
||||
!confirm(
|
||||
this.t(
|
||||
"memories",
|
||||
"Are you sure you want to move the selected photos from {name} to {newName}?",
|
||||
{ name, newName }
|
||||
)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.outer {
|
||||
margin-top: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
</style>
|
|
@ -1,56 +1,53 @@
|
|||
<template>
|
||||
<NcModal
|
||||
:size="size"
|
||||
@close="close"
|
||||
:outTransition="true">
|
||||
<div class="container">
|
||||
<div class="head">
|
||||
<span> <slot name="title"></slot> </span>
|
||||
</div>
|
||||
<NcModal :size="size" @close="close" :outTransition="true">
|
||||
<div class="container">
|
||||
<div class="head">
|
||||
<span> <slot name="title"></slot> </span>
|
||||
</div>
|
||||
|
||||
<slot></slot>
|
||||
<slot></slot>
|
||||
|
||||
<div class="buttons">
|
||||
<slot name="buttons"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</NcModal>
|
||||
<div class="buttons">
|
||||
<slot name="buttons"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</NcModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Prop, Vue } from 'vue-property-decorator';
|
||||
import { NcModal } from '@nextcloud/vue';
|
||||
import { Component, Emit, Prop, Vue } from "vue-property-decorator";
|
||||
import { NcModal } from "@nextcloud/vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcModal,
|
||||
}
|
||||
components: {
|
||||
NcModal,
|
||||
},
|
||||
})
|
||||
export default class Modal extends Vue {
|
||||
@Prop({default: 'small'}) private size?: string;
|
||||
@Prop({ default: "small" }) private size?: string;
|
||||
|
||||
@Emit('close')
|
||||
public close() {}
|
||||
@Emit("close")
|
||||
public close() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
margin: 20px;
|
||||
margin: 20px;
|
||||
|
||||
.head {
|
||||
font-weight: 500;
|
||||
font-size: 1.15em;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
:deep .buttons {
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
|
||||
> button {
|
||||
display: inline-block !important;
|
||||
}
|
||||
.head {
|
||||
font-weight: 500;
|
||||
font-size: 1.15em;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
:deep .buttons {
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
|
||||
> button {
|
||||
display: inline-block !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,123 +1,139 @@
|
|||
<template>
|
||||
<div class="album-top-matter">
|
||||
<NcActions v-if="!isAlbumList">
|
||||
<NcActionButton :aria-label="t('memories', 'Back')" @click="back()">
|
||||
{{ t('memories', 'Back') }}
|
||||
<template #icon> <BackIcon :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
<div class="album-top-matter">
|
||||
<NcActions v-if="!isAlbumList">
|
||||
<NcActionButton :aria-label="t('memories', 'Back')" @click="back()">
|
||||
{{ t("memories", "Back") }}
|
||||
<template #icon> <BackIcon :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
|
||||
<div class="name">{{ name }}</div>
|
||||
<div class="name">{{ name }}</div>
|
||||
|
||||
<div class="right-actions">
|
||||
<NcActions :inline="1">
|
||||
<NcActionButton :aria-label="t('memories', 'Create new album')" @click="$refs.createModal.open(false)" close-after-click
|
||||
v-if="isAlbumList">
|
||||
{{ t('memories', 'Create new album') }}
|
||||
<template #icon> <PlusIcon :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
<NcActionButton :aria-label="t('memories', 'Share album')" @click="$refs.shareModal.open(false)" close-after-click
|
||||
v-if="!isAlbumList">
|
||||
{{ t('memories', 'Share album') }}
|
||||
<template #icon> <ShareIcon :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
<NcActionButton :aria-label="t('memories', 'Edit album details')" @click="$refs.createModal.open(true)" close-after-click
|
||||
v-if="!isAlbumList">
|
||||
{{ t('memories', 'Edit album details') }}
|
||||
<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>
|
||||
|
||||
<AlbumCreateModal ref="createModal" />
|
||||
<AlbumDeleteModal ref="deleteModal" />
|
||||
<AlbumShareModal ref="shareModal" />
|
||||
<div class="right-actions">
|
||||
<NcActions :inline="1">
|
||||
<NcActionButton
|
||||
:aria-label="t('memories', 'Create new album')"
|
||||
@click="$refs.createModal.open(false)"
|
||||
close-after-click
|
||||
v-if="isAlbumList"
|
||||
>
|
||||
{{ t("memories", "Create new album") }}
|
||||
<template #icon> <PlusIcon :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
<NcActionButton
|
||||
:aria-label="t('memories', 'Share album')"
|
||||
@click="$refs.shareModal.open(false)"
|
||||
close-after-click
|
||||
v-if="!isAlbumList"
|
||||
>
|
||||
{{ t("memories", "Share album") }}
|
||||
<template #icon> <ShareIcon :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
<NcActionButton
|
||||
:aria-label="t('memories', 'Edit album details')"
|
||||
@click="$refs.createModal.open(true)"
|
||||
close-after-click
|
||||
v-if="!isAlbumList"
|
||||
>
|
||||
{{ t("memories", "Edit album details") }}
|
||||
<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>
|
||||
|
||||
<AlbumCreateModal ref="createModal" />
|
||||
<AlbumDeleteModal ref="deleteModal" />
|
||||
<AlbumShareModal ref="shareModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Mixins, Watch } from 'vue-property-decorator';
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import { Component, Mixins, Watch } from "vue-property-decorator";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
import UserConfig from "../../mixins/UserConfig";
|
||||
|
||||
import AlbumCreateModal from '../modal/AlbumCreateModal.vue';
|
||||
import AlbumDeleteModal from '../modal/AlbumDeleteModal.vue';
|
||||
import AlbumShareModal from '../modal/AlbumShareModal.vue';
|
||||
import AlbumCreateModal from "../modal/AlbumCreateModal.vue";
|
||||
import AlbumDeleteModal from "../modal/AlbumDeleteModal.vue";
|
||||
import AlbumShareModal from "../modal/AlbumShareModal.vue";
|
||||
|
||||
import { NcActions, NcActionButton, NcActionCheckbox } from '@nextcloud/vue';
|
||||
import BackIcon from 'vue-material-design-icons/ArrowLeft.vue';
|
||||
import EditIcon from 'vue-material-design-icons/Pencil.vue';
|
||||
import DeleteIcon from 'vue-material-design-icons/Close.vue';
|
||||
import PlusIcon from 'vue-material-design-icons/Plus.vue';
|
||||
import ShareIcon from 'vue-material-design-icons/ShareVariant.vue';
|
||||
import { NcActions, NcActionButton, NcActionCheckbox } from "@nextcloud/vue";
|
||||
import BackIcon from "vue-material-design-icons/ArrowLeft.vue";
|
||||
import EditIcon from "vue-material-design-icons/Pencil.vue";
|
||||
import DeleteIcon from "vue-material-design-icons/Close.vue";
|
||||
import PlusIcon from "vue-material-design-icons/Plus.vue";
|
||||
import ShareIcon from "vue-material-design-icons/ShareVariant.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
NcActionCheckbox,
|
||||
components: {
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
NcActionCheckbox,
|
||||
|
||||
AlbumCreateModal,
|
||||
AlbumDeleteModal,
|
||||
AlbumShareModal,
|
||||
AlbumCreateModal,
|
||||
AlbumDeleteModal,
|
||||
AlbumShareModal,
|
||||
|
||||
BackIcon,
|
||||
EditIcon,
|
||||
DeleteIcon,
|
||||
PlusIcon,
|
||||
ShareIcon,
|
||||
},
|
||||
BackIcon,
|
||||
EditIcon,
|
||||
DeleteIcon,
|
||||
PlusIcon,
|
||||
ShareIcon,
|
||||
},
|
||||
})
|
||||
export default class AlbumTopMatter extends Mixins(GlobalMixin, UserConfig) {
|
||||
private name: string = '';
|
||||
private name: string = "";
|
||||
|
||||
get isAlbumList() {
|
||||
return !Boolean(this.$route.params.name);
|
||||
}
|
||||
get isAlbumList() {
|
||||
return !Boolean(this.$route.params.name);
|
||||
}
|
||||
|
||||
@Watch('$route')
|
||||
async routeChange(from: any, to: any) {
|
||||
this.createMatter();
|
||||
}
|
||||
@Watch("$route")
|
||||
async routeChange(from: any, to: any) {
|
||||
this.createMatter();
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.createMatter();
|
||||
}
|
||||
mounted() {
|
||||
this.createMatter();
|
||||
}
|
||||
|
||||
createMatter() {
|
||||
this.name = this.$route.params.name || this.t('memories', 'Albums');
|
||||
}
|
||||
createMatter() {
|
||||
this.name = this.$route.params.name || this.t("memories", "Albums");
|
||||
}
|
||||
|
||||
back() {
|
||||
this.$router.push({ name: 'albums' });
|
||||
}
|
||||
back() {
|
||||
this.$router.push({ name: "albums" });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.album-top-matter {
|
||||
display: flex;
|
||||
vertical-align: middle;
|
||||
display: flex;
|
||||
vertical-align: middle;
|
||||
|
||||
.name {
|
||||
font-size: 1.3em;
|
||||
font-weight: 400;
|
||||
line-height: 40px;
|
||||
padding-left: 3px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.name {
|
||||
font-size: 1.3em;
|
||||
font-weight: 400;
|
||||
line-height: 40px;
|
||||
padding-left: 3px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.right-actions {
|
||||
margin-right: 40px;
|
||||
z-index: 50;
|
||||
@media (max-width: 768px) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
.right-actions {
|
||||
margin-right: 40px;
|
||||
z-index: 50;
|
||||
@media (max-width: 768px) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,116 +1,135 @@
|
|||
<template>
|
||||
<div v-if="name" class="face-top-matter">
|
||||
<NcActions>
|
||||
<NcActionButton :aria-label="t('memories', 'Back')" @click="back()">
|
||||
{{ t('memories', 'Back') }}
|
||||
<template #icon> <BackIcon :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
<div v-if="name" class="face-top-matter">
|
||||
<NcActions>
|
||||
<NcActionButton :aria-label="t('memories', 'Back')" @click="back()">
|
||||
{{ t("memories", "Back") }}
|
||||
<template #icon> <BackIcon :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
|
||||
<div class="name">{{ name }}</div>
|
||||
<div class="name">{{ name }}</div>
|
||||
|
||||
<div class="right-actions">
|
||||
<NcActions :inline="1">
|
||||
<NcActionButton :aria-label="t('memories', 'Rename person')" @click="$refs.editModal.open()" close-after-click>
|
||||
{{ t('memories', 'Rename person') }}
|
||||
<template #icon> <EditIcon :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
<NcActionButton :aria-label="t('memories', 'Merge with different person')" @click="$refs.mergeModal.open()" close-after-click>
|
||||
{{ t('memories', 'Merge with different person') }}
|
||||
<template #icon> <MergeIcon :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
<NcActionCheckbox :aria-label="t('memories', 'Mark person in preview')" :checked.sync="config_showFaceRect" @change="changeShowFaceRect">
|
||||
{{ 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>
|
||||
|
||||
<FaceEditModal ref="editModal" />
|
||||
<FaceDeleteModal ref="deleteModal" />
|
||||
<FaceMergeModal ref="mergeModal" />
|
||||
<div class="right-actions">
|
||||
<NcActions :inline="1">
|
||||
<NcActionButton
|
||||
:aria-label="t('memories', 'Rename person')"
|
||||
@click="$refs.editModal.open()"
|
||||
close-after-click
|
||||
>
|
||||
{{ t("memories", "Rename person") }}
|
||||
<template #icon> <EditIcon :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
<NcActionButton
|
||||
:aria-label="t('memories', 'Merge with different person')"
|
||||
@click="$refs.mergeModal.open()"
|
||||
close-after-click
|
||||
>
|
||||
{{ t("memories", "Merge with different person") }}
|
||||
<template #icon> <MergeIcon :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
<NcActionCheckbox
|
||||
:aria-label="t('memories', 'Mark person in preview')"
|
||||
:checked.sync="config_showFaceRect"
|
||||
@change="changeShowFaceRect"
|
||||
>
|
||||
{{ 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>
|
||||
|
||||
<FaceEditModal ref="editModal" />
|
||||
<FaceDeleteModal ref="deleteModal" />
|
||||
<FaceMergeModal ref="mergeModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Mixins, Watch } from 'vue-property-decorator';
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import { Component, Mixins, Watch } from "vue-property-decorator";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
import UserConfig from "../../mixins/UserConfig";
|
||||
|
||||
import { NcActions, NcActionButton, NcActionCheckbox } from '@nextcloud/vue';
|
||||
import FaceEditModal from '../modal/FaceEditModal.vue';
|
||||
import FaceDeleteModal from '../modal/FaceDeleteModal.vue';
|
||||
import FaceMergeModal from '../modal/FaceMergeModal.vue';
|
||||
import BackIcon from 'vue-material-design-icons/ArrowLeft.vue';
|
||||
import EditIcon from 'vue-material-design-icons/Pencil.vue';
|
||||
import DeleteIcon from 'vue-material-design-icons/Close.vue';
|
||||
import MergeIcon from 'vue-material-design-icons/Merge.vue';
|
||||
import { NcActions, NcActionButton, NcActionCheckbox } from "@nextcloud/vue";
|
||||
import FaceEditModal from "../modal/FaceEditModal.vue";
|
||||
import FaceDeleteModal from "../modal/FaceDeleteModal.vue";
|
||||
import FaceMergeModal from "../modal/FaceMergeModal.vue";
|
||||
import BackIcon from "vue-material-design-icons/ArrowLeft.vue";
|
||||
import EditIcon from "vue-material-design-icons/Pencil.vue";
|
||||
import DeleteIcon from "vue-material-design-icons/Close.vue";
|
||||
import MergeIcon from "vue-material-design-icons/Merge.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
NcActionCheckbox,
|
||||
FaceEditModal,
|
||||
FaceDeleteModal,
|
||||
FaceMergeModal,
|
||||
BackIcon,
|
||||
EditIcon,
|
||||
DeleteIcon,
|
||||
MergeIcon,
|
||||
},
|
||||
components: {
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
NcActionCheckbox,
|
||||
FaceEditModal,
|
||||
FaceDeleteModal,
|
||||
FaceMergeModal,
|
||||
BackIcon,
|
||||
EditIcon,
|
||||
DeleteIcon,
|
||||
MergeIcon,
|
||||
},
|
||||
})
|
||||
export default class FaceTopMatter extends Mixins(GlobalMixin, UserConfig) {
|
||||
private name: string = '';
|
||||
private name: string = "";
|
||||
|
||||
@Watch('$route')
|
||||
async routeChange(from: any, to: any) {
|
||||
this.createMatter();
|
||||
}
|
||||
@Watch("$route")
|
||||
async routeChange(from: any, to: any) {
|
||||
this.createMatter();
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.createMatter();
|
||||
}
|
||||
mounted() {
|
||||
this.createMatter();
|
||||
}
|
||||
|
||||
createMatter() {
|
||||
this.name = this.$route.params.name || '';
|
||||
}
|
||||
createMatter() {
|
||||
this.name = this.$route.params.name || "";
|
||||
}
|
||||
|
||||
back() {
|
||||
this.$router.push({ name: 'people' });
|
||||
}
|
||||
back() {
|
||||
this.$router.push({ name: "people" });
|
||||
}
|
||||
|
||||
changeShowFaceRect() {
|
||||
localStorage.setItem('memories_showFaceRect', this.config_showFaceRect ? '1' : '0');
|
||||
setTimeout(() => {
|
||||
this.$router.go(0); // refresh page
|
||||
}, 500);
|
||||
}
|
||||
changeShowFaceRect() {
|
||||
localStorage.setItem(
|
||||
"memories_showFaceRect",
|
||||
this.config_showFaceRect ? "1" : "0"
|
||||
);
|
||||
setTimeout(() => {
|
||||
this.$router.go(0); // refresh page
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.face-top-matter {
|
||||
display: flex;
|
||||
vertical-align: middle;
|
||||
display: flex;
|
||||
vertical-align: middle;
|
||||
|
||||
.name {
|
||||
font-size: 1.3em;
|
||||
font-weight: 400;
|
||||
line-height: 40px;
|
||||
padding-left: 3px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.name {
|
||||
font-size: 1.3em;
|
||||
font-weight: 400;
|
||||
line-height: 40px;
|
||||
padding-left: 3px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.right-actions {
|
||||
margin-right: 40px;
|
||||
z-index: 50;
|
||||
@media (max-width: 768px) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
.right-actions {
|
||||
margin-right: 40px;
|
||||
z-index: 50;
|
||||
@media (max-width: 768px) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,60 +1,66 @@
|
|||
<template>
|
||||
<NcBreadcrumbs v-if="topMatter">
|
||||
<NcBreadcrumb title="Home" :to="{ name: 'folders' }">
|
||||
<template #icon>
|
||||
<HomeIcon :size="20" />
|
||||
</template>
|
||||
</NcBreadcrumb>
|
||||
<NcBreadcrumb v-for="folder in topMatter.list" :key="folder.path" :title="folder.text"
|
||||
:to="{ name: 'folders', params: { path: folder.path }}" />
|
||||
</NcBreadcrumbs>
|
||||
<NcBreadcrumbs v-if="topMatter">
|
||||
<NcBreadcrumb title="Home" :to="{ name: 'folders' }">
|
||||
<template #icon>
|
||||
<HomeIcon :size="20" />
|
||||
</template>
|
||||
</NcBreadcrumb>
|
||||
<NcBreadcrumb
|
||||
v-for="folder in topMatter.list"
|
||||
:key="folder.path"
|
||||
:title="folder.text"
|
||||
:to="{ name: 'folders', params: { path: folder.path } }"
|
||||
/>
|
||||
</NcBreadcrumbs>
|
||||
</template>
|
||||
|
||||
<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 { NcBreadcrumbs, NcBreadcrumb } from '@nextcloud/vue';
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import HomeIcon from 'vue-material-design-icons/Home.vue';
|
||||
import { NcBreadcrumbs, NcBreadcrumb } from "@nextcloud/vue";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
import HomeIcon from "vue-material-design-icons/Home.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcBreadcrumbs,
|
||||
NcBreadcrumb,
|
||||
HomeIcon,
|
||||
}
|
||||
components: {
|
||||
NcBreadcrumbs,
|
||||
NcBreadcrumb,
|
||||
HomeIcon,
|
||||
},
|
||||
})
|
||||
export default class FolderTopMatter extends Mixins(GlobalMixin) {
|
||||
private topMatter?: TopMatterFolder = null;
|
||||
private topMatter?: TopMatterFolder = null;
|
||||
|
||||
@Watch('$route')
|
||||
async routeChange(from: any, to: any) {
|
||||
this.createMatter();
|
||||
}
|
||||
@Watch("$route")
|
||||
async routeChange(from: any, to: any) {
|
||||
this.createMatter();
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.createMatter();
|
||||
}
|
||||
mounted() {
|
||||
this.createMatter();
|
||||
}
|
||||
|
||||
createMatter() {
|
||||
if (this.$route.name === 'folders') {
|
||||
let path: any = this.$route.params.path || '';
|
||||
if (typeof path === 'string') {
|
||||
path = path.split('/');
|
||||
}
|
||||
createMatter() {
|
||||
if (this.$route.name === "folders") {
|
||||
let path: any = this.$route.params.path || "";
|
||||
if (typeof path === "string") {
|
||||
path = path.split("/");
|
||||
}
|
||||
|
||||
this.topMatter = {
|
||||
type: TopMatterType.FOLDER,
|
||||
list: path.filter(x => x).map((x, idx, arr) => {
|
||||
return {
|
||||
text: x,
|
||||
path: arr.slice(0, idx + 1).join('/'),
|
||||
}
|
||||
}),
|
||||
this.topMatter = {
|
||||
type: TopMatterType.FOLDER,
|
||||
list: path
|
||||
.filter((x) => x)
|
||||
.map((x, idx, arr) => {
|
||||
return {
|
||||
text: x,
|
||||
path: arr.slice(0, idx + 1).join("/"),
|
||||
};
|
||||
} else {
|
||||
this.topMatter = null;
|
||||
}
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
this.topMatter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -1,190 +1,210 @@
|
|||
<template>
|
||||
<div class="outer" v-show="years.length > 0">
|
||||
<div class="inner" ref="inner">
|
||||
<div v-for="year of years" class="group" :key="year.year" @click="click(year)">
|
||||
<img class="fill-block"
|
||||
:src="year.url" />
|
||||
<div class="outer" v-show="years.length > 0">
|
||||
<div class="inner" ref="inner">
|
||||
<div
|
||||
v-for="year of years"
|
||||
class="group"
|
||||
:key="year.year"
|
||||
@click="click(year)"
|
||||
>
|
||||
<img class="fill-block" :src="year.url" />
|
||||
|
||||
<div class="overlay">
|
||||
{{ 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 class="overlay">
|
||||
{{ 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>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator';
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import { NcActions, NcActionButton } from '@nextcloud/vue';
|
||||
import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
import { NcActions, NcActionButton } from "@nextcloud/vue";
|
||||
|
||||
import * as utils from "../../services/Utils";
|
||||
import * as dav from '../../services/DavRequests';
|
||||
import * as dav from "../../services/DavRequests";
|
||||
import { ViewerManager } from "../../services/Viewer";
|
||||
import { IPhoto } from '../../types';
|
||||
import { IPhoto } from "../../types";
|
||||
import { getPreviewUrl } from "../../services/FileUtils";
|
||||
|
||||
import LeftMoveIcon from 'vue-material-design-icons/ChevronLeft.vue';
|
||||
import RightMoveIcon from 'vue-material-design-icons/ChevronRight.vue';
|
||||
import LeftMoveIcon from "vue-material-design-icons/ChevronLeft.vue";
|
||||
import RightMoveIcon from "vue-material-design-icons/ChevronRight.vue";
|
||||
|
||||
interface IYear {
|
||||
year: number;
|
||||
url: string;
|
||||
preview: IPhoto;
|
||||
photos: IPhoto[];
|
||||
text: string;
|
||||
};
|
||||
year: number;
|
||||
url: string;
|
||||
preview: IPhoto;
|
||||
photos: IPhoto[];
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
name: 'OnThisDay',
|
||||
components: {
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
LeftMoveIcon,
|
||||
RightMoveIcon,
|
||||
}
|
||||
name: "OnThisDay",
|
||||
components: {
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
LeftMoveIcon,
|
||||
RightMoveIcon,
|
||||
},
|
||||
})
|
||||
export default class OnThisDay extends Mixins(GlobalMixin) {
|
||||
private getPreviewUrl = getPreviewUrl;
|
||||
private getPreviewUrl = getPreviewUrl;
|
||||
|
||||
@Emit('load')
|
||||
onload() {}
|
||||
@Emit("load")
|
||||
onload() {}
|
||||
|
||||
private years: IYear[] = []
|
||||
private years: IYear[] = [];
|
||||
|
||||
private hasRight = false;
|
||||
private hasLeft = false;
|
||||
private scrollStack: number[] = [];
|
||||
private hasRight = false;
|
||||
private hasLeft = false;
|
||||
private scrollStack: number[] = [];
|
||||
|
||||
/**
|
||||
* Nextcloud viewer proxy
|
||||
* Can't use the timeline instance because these photos
|
||||
* might not be in view, so can't delete them
|
||||
*/
|
||||
@Prop()
|
||||
private viewerManager!: ViewerManager;
|
||||
/**
|
||||
* Nextcloud viewer proxy
|
||||
* Can't use the timeline instance because these photos
|
||||
* might not be in view, so can't delete them
|
||||
*/
|
||||
@Prop()
|
||||
private viewerManager!: ViewerManager;
|
||||
|
||||
mounted() {
|
||||
const inner = this.$refs.inner as HTMLElement;
|
||||
inner.addEventListener('scroll', this.onScroll.bind(this));
|
||||
mounted() {
|
||||
const inner = this.$refs.inner as HTMLElement;
|
||||
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() {
|
||||
// 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);
|
||||
// For each year, randomly choose 10 photos to display
|
||||
for (const year of this.years) {
|
||||
year.photos = utils.randomSubarray(year.photos, 10);
|
||||
}
|
||||
|
||||
async process(photos: IPhoto[]) {
|
||||
this.years = [];
|
||||
// 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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();
|
||||
// Get random photo
|
||||
year.preview ||= utils.randomChoice(year.photos);
|
||||
year.url = getPreviewUrl(
|
||||
year.preview.fileid,
|
||||
year.preview.etag,
|
||||
false,
|
||||
512
|
||||
);
|
||||
}
|
||||
|
||||
moveLeft() {
|
||||
const inner = this.$refs.inner as HTMLElement;
|
||||
inner.scrollBy(-(this.scrollStack.pop() || inner.clientWidth), 0);
|
||||
}
|
||||
await this.$nextTick();
|
||||
this.onScroll();
|
||||
this.onload();
|
||||
}
|
||||
|
||||
moveRight() {
|
||||
const inner = this.$refs.inner as HTMLElement;
|
||||
const innerRect = inner.getBoundingClientRect();
|
||||
const nextChild = Array.from(inner.children).map(c => c.getBoundingClientRect()).find((rect) =>
|
||||
rect.right > innerRect.right
|
||||
);
|
||||
moveLeft() {
|
||||
const inner = this.$refs.inner as HTMLElement;
|
||||
inner.scrollBy(-(this.scrollStack.pop() || inner.clientWidth), 0);
|
||||
}
|
||||
|
||||
let scroll = nextChild ? (nextChild.left - innerRect.left) : inner.clientWidth;
|
||||
scroll = Math.min(inner.scrollWidth - inner.scrollLeft - inner.clientWidth, scroll);
|
||||
this.scrollStack.push(scroll);
|
||||
inner.scrollBy(scroll, 0);
|
||||
}
|
||||
moveRight() {
|
||||
const inner = this.$refs.inner as HTMLElement;
|
||||
const innerRect = inner.getBoundingClientRect();
|
||||
const nextChild = Array.from(inner.children)
|
||||
.map((c) => c.getBoundingClientRect())
|
||||
.find((rect) => rect.right > innerRect.right);
|
||||
|
||||
onScroll() {
|
||||
const inner = this.$refs.inner as HTMLElement;
|
||||
if (!inner) return;
|
||||
this.hasLeft = inner.scrollLeft > 0;
|
||||
this.hasRight = (inner.clientWidth + inner.scrollLeft < inner.scrollWidth - 20);
|
||||
}
|
||||
let scroll = nextChild
|
||||
? nextChild.left - innerRect.left
|
||||
: inner.clientWidth;
|
||||
scroll = Math.min(
|
||||
inner.scrollWidth - inner.scrollLeft - inner.clientWidth,
|
||||
scroll
|
||||
);
|
||||
this.scrollStack.push(scroll);
|
||||
inner.scrollBy(scroll, 0);
|
||||
}
|
||||
|
||||
click(year: IYear) {
|
||||
const allPhotos = this.years.flatMap(y => y.photos);
|
||||
this.viewerManager.open(year.preview, allPhotos);
|
||||
}
|
||||
onScroll() {
|
||||
const inner = this.$refs.inner as HTMLElement;
|
||||
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>
|
||||
|
||||
|
@ -193,97 +213,107 @@ $height: 200px;
|
|||
$mobHeight: 150px;
|
||||
|
||||
.outer {
|
||||
width: calc(100% - 50px);
|
||||
height: $height;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
padding: 0 calc(28px * 0.6);
|
||||
width: calc(100% - 50px);
|
||||
height: $height;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
padding: 0 calc(28px * 0.6);
|
||||
|
||||
// Sloppy: ideally this should be done in Timeline
|
||||
// to put a gap between the title and this
|
||||
margin-top: 10px;
|
||||
// Sloppy: ideally this should be done in Timeline
|
||||
// to put a gap between the title and this
|
||||
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 {
|
||||
height: calc(100% + 20px);
|
||||
white-space: nowrap;
|
||||
overflow-x: scroll;
|
||||
scroll-behavior: smooth;
|
||||
border-radius: 10px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
: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 { padding: 0 8px; }
|
||||
.dir-btn { display: none; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
height: $mobHeight;
|
||||
.dir-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
height: $mobHeight;
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
height: $height;
|
||||
aspect-ratio: 4/3;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
height: $height;
|
||||
aspect-ratio: 4/3;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&:not(:last-of-type) { margin-right: 8px; }
|
||||
&:not(:last-of-type) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
img {
|
||||
cursor: inherit;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
background-color: var(--color-background-dark);
|
||||
background-clip: padding-box, content-box;
|
||||
}
|
||||
img {
|
||||
cursor: inherit;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
background-color: var(--color-background-dark);
|
||||
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 {
|
||||
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 { font-size: 1.1em; }
|
||||
font-size: 1.1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,63 +1,63 @@
|
|||
<template>
|
||||
<div v-if="name" class="tag-top-matter">
|
||||
<NcActions>
|
||||
<NcActionButton :aria-label="t('memories', 'Back')" @click="back()">
|
||||
{{ t('memories', 'Back') }}
|
||||
<template #icon> <BackIcon :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
<span class="name">{{ name }}</span>
|
||||
</div>
|
||||
<div v-if="name" class="tag-top-matter">
|
||||
<NcActions>
|
||||
<NcActionButton :aria-label="t('memories', 'Back')" @click="back()">
|
||||
{{ t("memories", "Back") }}
|
||||
<template #icon> <BackIcon :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
<span class="name">{{ name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Mixins, Watch } from 'vue-property-decorator';
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import { Component, Mixins, Watch } from "vue-property-decorator";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
|
||||
import { NcActions, NcActionButton } from '@nextcloud/vue';
|
||||
import BackIcon from 'vue-material-design-icons/ArrowLeft.vue';
|
||||
import { NcActions, NcActionButton } from "@nextcloud/vue";
|
||||
import BackIcon from "vue-material-design-icons/ArrowLeft.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
BackIcon,
|
||||
},
|
||||
components: {
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
BackIcon,
|
||||
},
|
||||
})
|
||||
export default class TagTopMatter extends Mixins(GlobalMixin) {
|
||||
private name: string = '';
|
||||
private name: string = "";
|
||||
|
||||
@Watch('$route')
|
||||
async routeChange(from: any, to: any) {
|
||||
this.createMatter();
|
||||
}
|
||||
@Watch("$route")
|
||||
async routeChange(from: any, to: any) {
|
||||
this.createMatter();
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.createMatter();
|
||||
}
|
||||
mounted() {
|
||||
this.createMatter();
|
||||
}
|
||||
|
||||
createMatter() {
|
||||
this.name = this.$route.params.name || '';
|
||||
}
|
||||
createMatter() {
|
||||
this.name = this.$route.params.name || "";
|
||||
}
|
||||
|
||||
back() {
|
||||
this.$router.push({ name: 'tags' });
|
||||
}
|
||||
back() {
|
||||
this.$router.push({ name: "tags" });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tag-top-matter {
|
||||
.name {
|
||||
font-size: 1.3em;
|
||||
font-weight: 400;
|
||||
line-height: 42px;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
.name {
|
||||
font-size: 1.3em;
|
||||
font-weight: 400;
|
||||
line-height: 42px;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-block;
|
||||
}
|
||||
button {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,53 +1,62 @@
|
|||
<template>
|
||||
<div class="top-matter" v-if="type">
|
||||
<FolderTopMatter v-if="type === 1" />
|
||||
<TagTopMatter v-else-if="type === 2" />
|
||||
<FaceTopMatter v-else-if="type === 3" />
|
||||
<AlbumTopMatter v-else-if="type === 4" />
|
||||
</div>
|
||||
<div class="top-matter" v-if="type">
|
||||
<FolderTopMatter v-if="type === 1" />
|
||||
<TagTopMatter v-else-if="type === 2" />
|
||||
<FaceTopMatter v-else-if="type === 3" />
|
||||
<AlbumTopMatter v-else-if="type === 4" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 TagTopMatter from "./TagTopMatter.vue";
|
||||
import FaceTopMatter from "./FaceTopMatter.vue";
|
||||
import AlbumTopMatter from "./AlbumTopMatter.vue";
|
||||
|
||||
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||
import { TopMatterType } from '../../types';
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
import { TopMatterType } from "../../types";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
FolderTopMatter,
|
||||
TagTopMatter,
|
||||
FaceTopMatter,
|
||||
AlbumTopMatter,
|
||||
},
|
||||
components: {
|
||||
FolderTopMatter,
|
||||
TagTopMatter,
|
||||
FaceTopMatter,
|
||||
AlbumTopMatter,
|
||||
},
|
||||
})
|
||||
export default class TopMatter extends Mixins(GlobalMixin) {
|
||||
public type: TopMatterType = TopMatterType.NONE;
|
||||
public type: TopMatterType = TopMatterType.NONE;
|
||||
|
||||
@Watch('$route')
|
||||
async routeChange(from: any, to: any) {
|
||||
this.setTopMatter();
|
||||
}
|
||||
@Watch("$route")
|
||||
async routeChange(from: any, to: any) {
|
||||
this.setTopMatter();
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.setTopMatter();
|
||||
}
|
||||
mounted() {
|
||||
this.setTopMatter();
|
||||
}
|
||||
|
||||
/** Create top matter */
|
||||
setTopMatter() {
|
||||
this.type = (() => {
|
||||
switch (this.$route.name) {
|
||||
case 'folders': return TopMatterType.FOLDER;
|
||||
case 'tags': return this.$route.params.name ? TopMatterType.TAG : TopMatterType.NONE;
|
||||
case 'people': return this.$route.params.name ? TopMatterType.FACE : TopMatterType.NONE;
|
||||
case 'albums': return TopMatterType.ALBUM;
|
||||
default: return TopMatterType.NONE;
|
||||
}
|
||||
})();
|
||||
}
|
||||
/** Create top matter */
|
||||
setTopMatter() {
|
||||
this.type = (() => {
|
||||
switch (this.$route.name) {
|
||||
case "folders":
|
||||
return TopMatterType.FOLDER;
|
||||
case "tags":
|
||||
return this.$route.params.name
|
||||
? 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>
|
40
src/main.ts
40
src/main.ts
|
@ -19,30 +19,34 @@
|
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import Vue from 'vue'
|
||||
import VueVirtualScroller from 'vue-virtual-scroller'
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||
import "reflect-metadata";
|
||||
import Vue from "vue";
|
||||
import VueVirtualScroller from "vue-virtual-scroller";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
|
||||
Vue.use(VueVirtualScroller)
|
||||
Vue.use(VueVirtualScroller);
|
||||
|
||||
// https://github.com/nextcloud/photos/blob/156f280c0476c483cb9ce81769ccb0c1c6500a4e/src/main.js
|
||||
// TODO: remove when we have a proper fileinfo standalone library
|
||||
// original scripts are loaded from
|
||||
// https://github.com/nextcloud/server/blob/5bf3d1bb384da56adbf205752be8f840aac3b0c5/lib/private/legacy/template.php#L120-L122
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
if (!globalThis.OCA.Files) {
|
||||
globalThis.OCA.Files = {}
|
||||
}
|
||||
// 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)
|
||||
})
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
if (!globalThis.OCA.Files) {
|
||||
globalThis.OCA.Files = {};
|
||||
}
|
||||
// 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
|
||||
);
|
||||
});
|
||||
|
||||
export default new Vue({
|
||||
el: '#content',
|
||||
router,
|
||||
render: h => h(App),
|
||||
})
|
||||
el: "#content",
|
||||
router,
|
||||
render: (h) => h(App),
|
||||
});
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
||||
import { constants } from '../services/Utils';
|
||||
import { Component, Vue } from "vue-property-decorator";
|
||||
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
|
||||
import { constants } from "../services/Utils";
|
||||
|
||||
@Component
|
||||
export default class GlobalMixin extends Vue {
|
||||
public readonly t = t;
|
||||
public readonly n = n;
|
||||
public readonly t = t;
|
||||
public readonly n = n;
|
||||
|
||||
public readonly c = constants.c;
|
||||
public readonly TagDayID = constants.TagDayID;
|
||||
public readonly TagDayIDValueSet = constants.TagDayIDValueSet;
|
||||
}
|
||||
public readonly c = constants.c;
|
||||
public readonly TagDayID = constants.TagDayID;
|
||||
public readonly TagDayIDValueSet = constants.TagDayIDValueSet;
|
||||
}
|
||||
|
|
|
@ -20,60 +20,60 @@
|
|||
*
|
||||
*/
|
||||
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { Component, Vue } from "vue-property-decorator";
|
||||
import { emit, subscribe, unsubscribe } from "@nextcloud/event-bus";
|
||||
import { generateUrl } from "@nextcloud/router";
|
||||
import { loadState } from "@nextcloud/initial-state";
|
||||
import axios from "@nextcloud/axios";
|
||||
|
||||
const eventName = 'memories:user-config-changed'
|
||||
const localSettings = ['squareThumbs', 'showFaceRect'];
|
||||
const eventName = "memories:user-config-changed";
|
||||
const localSettings = ["squareThumbs", "showFaceRect"];
|
||||
|
||||
@Component
|
||||
export default class UserConfig extends Vue {
|
||||
config_timelinePath: string = loadState('memories', 'timelinePath') || '';
|
||||
config_foldersPath: string = loadState('memories', 'foldersPath') || '/';
|
||||
config_showHidden = loadState('memories', 'showHidden') === "true";
|
||||
config_timelinePath: string = loadState("memories", "timelinePath") || "";
|
||||
config_foldersPath: string = loadState("memories", "foldersPath") || "/";
|
||||
config_showHidden = loadState("memories", "showHidden") === "true";
|
||||
|
||||
config_tagsEnabled = Boolean(loadState('memories', 'systemtags'));
|
||||
config_recognizeEnabled = Boolean(loadState('memories', 'recognize'));
|
||||
config_mapsEnabled = Boolean(loadState('memories', 'maps'));
|
||||
config_albumsEnabled = Boolean(loadState('memories', 'albums'));
|
||||
config_tagsEnabled = Boolean(loadState("memories", "systemtags"));
|
||||
config_recognizeEnabled = Boolean(loadState("memories", "recognize"));
|
||||
config_mapsEnabled = Boolean(loadState("memories", "maps"));
|
||||
config_albumsEnabled = Boolean(loadState("memories", "albums"));
|
||||
|
||||
config_squareThumbs = localStorage.getItem('memories_squareThumbs') === '1';
|
||||
config_showFaceRect = localStorage.getItem('memories_showFaceRect') === '1';
|
||||
config_squareThumbs = localStorage.getItem("memories_squareThumbs") === "1";
|
||||
config_showFaceRect = localStorage.getItem("memories_showFaceRect") === "1";
|
||||
|
||||
config_eventName = eventName;
|
||||
config_eventName = eventName;
|
||||
|
||||
created() {
|
||||
subscribe(eventName, this.updateLocalSetting)
|
||||
created() {
|
||||
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() {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
// Visible elements update setting
|
||||
emit(eventName, { setting, value });
|
||||
}
|
||||
}
|
||||
// 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 { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
||||
import Router from 'vue-router'
|
||||
import Vue from 'vue'
|
||||
import Timeline from './components/Timeline.vue';
|
||||
import { generateUrl } from "@nextcloud/router";
|
||||
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
|
||||
import Router from "vue-router";
|
||||
import Vue from "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
|
||||
* + always lead current path with a slash
|
||||
*
|
||||
* @param {string | Array} path path arguments to parse
|
||||
* @return {string}
|
||||
*/
|
||||
const parsePathParams = (path) => {
|
||||
return `/${Array.isArray(path) ? path.join('/') : path || ''}`
|
||||
}
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* @param {string | Array} path path arguments to parse
|
||||
* @return {string}
|
||||
*/
|
||||
const parsePathParams = (path) => {
|
||||
return `/${Array.isArray(path) ? path.join("/") : path || ""}`;
|
||||
};
|
||||
|
||||
export default new Router({
|
||||
mode: 'history',
|
||||
// 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
|
||||
base: generateUrl('/apps/memories'),
|
||||
linkActiveClass: 'active',
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: Timeline,
|
||||
name: 'timeline',
|
||||
props: route => ({
|
||||
rootTitle: t('memories', 'Timeline'),
|
||||
}),
|
||||
},
|
||||
export default new Router({
|
||||
mode: "history",
|
||||
// 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
|
||||
base: generateUrl("/apps/memories"),
|
||||
linkActiveClass: "active",
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
component: Timeline,
|
||||
name: "timeline",
|
||||
props: (route) => ({
|
||||
rootTitle: t("memories", "Timeline"),
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/folders/:path*',
|
||||
component: Timeline,
|
||||
name: 'folders',
|
||||
props: route => ({
|
||||
rootTitle: t('memories', 'Folders'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/folders/:path*",
|
||||
component: Timeline,
|
||||
name: "folders",
|
||||
props: (route) => ({
|
||||
rootTitle: t("memories", "Folders"),
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/favorites',
|
||||
component: Timeline,
|
||||
name: 'favorites',
|
||||
props: route => ({
|
||||
rootTitle: t('memories', 'Favorites'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/favorites",
|
||||
component: Timeline,
|
||||
name: "favorites",
|
||||
props: (route) => ({
|
||||
rootTitle: t("memories", "Favorites"),
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/videos',
|
||||
component: Timeline,
|
||||
name: 'videos',
|
||||
props: route => ({
|
||||
rootTitle: t('memories', 'Videos'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/videos",
|
||||
component: Timeline,
|
||||
name: "videos",
|
||||
props: (route) => ({
|
||||
rootTitle: t("memories", "Videos"),
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/albums/:user?/:name?',
|
||||
component: Timeline,
|
||||
name: 'albums',
|
||||
props: route => ({
|
||||
rootTitle: t('memories', 'Albums'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/albums/:user?/:name?",
|
||||
component: Timeline,
|
||||
name: "albums",
|
||||
props: (route) => ({
|
||||
rootTitle: t("memories", "Albums"),
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/archive',
|
||||
component: Timeline,
|
||||
name: 'archive',
|
||||
props: route => ({
|
||||
rootTitle: t('memories', 'Archive'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/archive",
|
||||
component: Timeline,
|
||||
name: "archive",
|
||||
props: (route) => ({
|
||||
rootTitle: t("memories", "Archive"),
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/thisday',
|
||||
component: Timeline,
|
||||
name: 'thisday',
|
||||
props: route => ({
|
||||
rootTitle: t('memories', 'On this day'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/thisday",
|
||||
component: Timeline,
|
||||
name: "thisday",
|
||||
props: (route) => ({
|
||||
rootTitle: t("memories", "On this day"),
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/people/:user?/:name?',
|
||||
component: Timeline,
|
||||
name: 'people',
|
||||
props: route => ({
|
||||
rootTitle: t('memories', 'People'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/people/:user?/:name?",
|
||||
component: Timeline,
|
||||
name: "people",
|
||||
props: (route) => ({
|
||||
rootTitle: t("memories", "People"),
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/tags/:name*',
|
||||
component: Timeline,
|
||||
name: 'tags',
|
||||
props: route => ({
|
||||
rootTitle: t('memories', 'Tags'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/tags/:name*",
|
||||
component: Timeline,
|
||||
name: "tags",
|
||||
props: (route) => ({
|
||||
rootTitle: t("memories", "Tags"),
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/maps',
|
||||
name: 'maps',
|
||||
// router-link doesn't support external url, let's force the redirect
|
||||
beforeEnter() {
|
||||
window.open(generateUrl('/apps/maps'), '_blank')
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
{
|
||||
path: "/maps",
|
||||
name: "maps",
|
||||
// router-link doesn't support external url, let's force the redirect
|
||||
beforeEnter() {
|
||||
window.open(generateUrl("/apps/maps"), "_blank");
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
@ -20,26 +20,30 @@
|
|||
*
|
||||
*/
|
||||
|
||||
import * as webdav from 'webdav'
|
||||
import axios from '@nextcloud/axios'
|
||||
import parseUrl from 'url-parse'
|
||||
import { generateRemoteUrl } from '@nextcloud/router'
|
||||
import * as webdav from "webdav";
|
||||
import axios from "@nextcloud/axios";
|
||||
import parseUrl from "url-parse";
|
||||
import { generateRemoteUrl } from "@nextcloud/router";
|
||||
|
||||
// Monkey business
|
||||
import * as rq from 'webdav/dist/node/request';
|
||||
(<any>rq).prepareRequestOptionsOld = rq.prepareRequestOptions.bind(rq);
|
||||
(<any>rq).prepareRequestOptions = (function(requestOptions, context, userOptions) {
|
||||
requestOptions.method = userOptions.method || requestOptions.method;
|
||||
return this.prepareRequestOptionsOld(requestOptions, context, userOptions);
|
||||
}).bind(rq);
|
||||
// Monkey business
|
||||
import * as rq from "webdav/dist/node/request";
|
||||
(<any>rq).prepareRequestOptionsOld = rq.prepareRequestOptions.bind(rq);
|
||||
(<any>rq).prepareRequestOptions = function (
|
||||
requestOptions,
|
||||
context,
|
||||
userOptions
|
||||
) {
|
||||
requestOptions.method = userOptions.method || requestOptions.method;
|
||||
return this.prepareRequestOptionsOld(requestOptions, context, userOptions);
|
||||
}.bind(rq);
|
||||
|
||||
// force our axios
|
||||
const patcher = webdav.getPatcher()
|
||||
patcher.patch('request', axios)
|
||||
// force our axios
|
||||
const patcher = webdav.getPatcher();
|
||||
patcher.patch("request", axios);
|
||||
|
||||
// init webdav client on default dav endpoint
|
||||
const remote = generateRemoteUrl('dav')
|
||||
const client = webdav.createClient(remote)
|
||||
// init webdav client on default dav endpoint
|
||||
const remote = generateRemoteUrl("dav");
|
||||
const client = webdav.createClient(remote);
|
||||
|
||||
export const remotePath = parseUrl(remote).pathname
|
||||
export default client
|
||||
export const remotePath = parseUrl(remote).pathname;
|
||||
export default client;
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
export * from './dav/base';
|
||||
export * from './dav/albums';
|
||||
export * from './dav/archive';
|
||||
export * from './dav/download';
|
||||
export * from './dav/face';
|
||||
export * from './dav/favorites';
|
||||
export * from './dav/folders';
|
||||
export * from './dav/onthisday';
|
||||
export * from './dav/tags';
|
||||
export * from "./dav/base";
|
||||
export * from "./dav/albums";
|
||||
export * from "./dav/archive";
|
||||
export * from "./dav/download";
|
||||
export * from "./dav/face";
|
||||
export * from "./dav/favorites";
|
||||
export * from "./dav/folders";
|
||||
export * from "./dav/onthisday";
|
||||
export * from "./dav/tags";
|
||||
|
|
|
@ -19,113 +19,136 @@
|
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
import camelcase from 'camelcase'
|
||||
import { isNumber } from './NumberUtils'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import camelcase from "camelcase";
|
||||
import { isNumber } from "./NumberUtils";
|
||||
import { generateUrl } from "@nextcloud/router";
|
||||
|
||||
/**
|
||||
* Get an url encoded path
|
||||
*
|
||||
* @param {string} path the full path
|
||||
* @return {string} url encoded file path
|
||||
*/
|
||||
const encodeFilePath = function(path) {
|
||||
const pathSections = (path.startsWith('/') ? path : `/${path}`).split('/')
|
||||
let relativePath = ''
|
||||
pathSections.forEach((section) => {
|
||||
if (section !== '') {
|
||||
relativePath += '/' + encodeURIComponent(section)
|
||||
}
|
||||
})
|
||||
return relativePath
|
||||
}
|
||||
/**
|
||||
* Get an url encoded path
|
||||
*
|
||||
* @param {string} path the full path
|
||||
* @return {string} url encoded file path
|
||||
*/
|
||||
const encodeFilePath = function (path) {
|
||||
const pathSections = (path.startsWith("/") ? path : `/${path}`).split("/");
|
||||
let relativePath = "";
|
||||
pathSections.forEach((section) => {
|
||||
if (section !== "") {
|
||||
relativePath += "/" + encodeURIComponent(section);
|
||||
}
|
||||
});
|
||||
return relativePath;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract dir and name from file path
|
||||
*
|
||||
* @param {string} path the full path
|
||||
* @return {string[]} [dirPath, fileName]
|
||||
*/
|
||||
const extractFilePaths = function(path) {
|
||||
const pathSections = path.split('/')
|
||||
const fileName = pathSections[pathSections.length - 1]
|
||||
const dirPath = pathSections.slice(0, pathSections.length - 1).join('/')
|
||||
return [dirPath, fileName]
|
||||
}
|
||||
/**
|
||||
* Extract dir and name from file path
|
||||
*
|
||||
* @param {string} path the full path
|
||||
* @return {string[]} [dirPath, fileName]
|
||||
*/
|
||||
const extractFilePaths = function (path) {
|
||||
const pathSections = path.split("/");
|
||||
const fileName = pathSections[pathSections.length - 1];
|
||||
const dirPath = pathSections.slice(0, pathSections.length - 1).join("/");
|
||||
return [dirPath, fileName];
|
||||
};
|
||||
|
||||
/**
|
||||
* Sorting comparison function
|
||||
*
|
||||
* @param {object} fileInfo1 file 1 fileinfo
|
||||
* @param {object} fileInfo2 file 2 fileinfo
|
||||
* @param {string} key key to sort with
|
||||
* @param {boolean} [asc=true] sort ascending?
|
||||
* @return {number}
|
||||
*/
|
||||
const sortCompare = function(fileInfo1, fileInfo2, key, asc = true) {
|
||||
/**
|
||||
* Sorting comparison function
|
||||
*
|
||||
* @param {object} fileInfo1 file 1 fileinfo
|
||||
* @param {object} fileInfo2 file 2 fileinfo
|
||||
* @param {string} key key to sort with
|
||||
* @param {boolean} [asc=true] sort ascending?
|
||||
* @return {number}
|
||||
*/
|
||||
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 (fileInfo1.isFavorite && !fileInfo2.isFavorite) {
|
||||
return -1
|
||||
} else if (!fileInfo1.isFavorite && fileInfo2.isFavorite) {
|
||||
return 1
|
||||
}
|
||||
// if this is a number, let's sort by integer
|
||||
if (isNumber(fileInfo1[key]) && isNumber(fileInfo2[key])) {
|
||||
return asc
|
||||
? Number(fileInfo2[key]) - Number(fileInfo1[key])
|
||||
: Number(fileInfo1[key]) - Number(fileInfo2[key]);
|
||||
}
|
||||
|
||||
// if this is a number, let's sort by integer
|
||||
if (isNumber(fileInfo1[key]) && isNumber(fileInfo2[key])) {
|
||||
return asc
|
||||
? Number(fileInfo2[key]) - Number(fileInfo1[key])
|
||||
: Number(fileInfo1[key]) - Number(fileInfo2[key])
|
||||
}
|
||||
// else we sort by string, so let's sort directories first
|
||||
if (fileInfo1.type !== "file" && fileInfo2.type === "file") {
|
||||
return asc ? -1 : 1;
|
||||
} else if (fileInfo1.type === "file" && fileInfo2.type !== "file") {
|
||||
return asc ? 1 : -1;
|
||||
}
|
||||
|
||||
// else we sort by string, so let's sort directories first
|
||||
if (fileInfo1.type !== 'file' && fileInfo2.type === 'file') {
|
||||
return asc ? -1 : 1
|
||||
} else if (fileInfo1.type === 'file' && fileInfo2.type !== 'file') {
|
||||
return asc ? 1 : -1
|
||||
}
|
||||
// if this is a date, let's sort by date
|
||||
if (
|
||||
isNumber(new Date(fileInfo1[key]).getTime()) &&
|
||||
isNumber(new Date(fileInfo2[key]).getTime())
|
||||
) {
|
||||
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
|
||||
if (isNumber(new Date(fileInfo1[key]).getTime()) && isNumber(new Date(fileInfo2[key]).getTime())) {
|
||||
return asc
|
||||
? new Date(fileInfo2[key]).getTime() - new Date(fileInfo1[key]).getTime()
|
||||
: new Date(fileInfo1[key]).getTime() - new Date(fileInfo2[key]).getTime()
|
||||
}
|
||||
// finally sort by name
|
||||
return asc
|
||||
? fileInfo1[key]
|
||||
?.toString()
|
||||
?.localeCompare(
|
||||
fileInfo2[key].toString(),
|
||||
globalThis.OC.getLanguage()
|
||||
) || 1
|
||||
: -fileInfo1[key]
|
||||
?.toString()
|
||||
?.localeCompare(
|
||||
fileInfo2[key].toString(),
|
||||
globalThis.OC.getLanguage()
|
||||
) || -1;
|
||||
};
|
||||
|
||||
// finally sort by name
|
||||
return asc
|
||||
? 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) {
|
||||
const fileInfo = {};
|
||||
|
||||
const genFileInfo = function(obj) {
|
||||
const fileInfo = {}
|
||||
Object.keys(obj).forEach((key) => {
|
||||
const data = obj[key];
|
||||
|
||||
Object.keys(obj).forEach(key => {
|
||||
const data = obj[key]
|
||||
// flatten object if any
|
||||
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
|
||||
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
|
||||
}
|
||||
const getPreviewUrl = function (
|
||||
fileid: number,
|
||||
etag: string,
|
||||
square: boolean,
|
||||
size: number
|
||||
): string {
|
||||
const a = square ? "0" : "1";
|
||||
return generateUrl(
|
||||
`/core/preview?fileId=${fileid}&c=${etag}&x=${size}&y=${size}&forceIcon=0&a=${a}`
|
||||
);
|
||||
};
|
||||
|
||||
const getPreviewUrl = function(fileid: number, etag: string, square: boolean, size: number): string {
|
||||
const a = square ? '0' : '1'
|
||||
return generateUrl(`/core/preview?fileId=${fileid}&c=${etag}&x=${size}&y=${size}&forceIcon=0&a=${a}`);
|
||||
}
|
||||
|
||||
export { encodeFilePath, extractFilePaths, sortCompare, genFileInfo, getPreviewUrl }
|
||||
export {
|
||||
encodeFilePath,
|
||||
extractFilePaths,
|
||||
sortCompare,
|
||||
genFileInfo,
|
||||
getPreviewUrl,
|
||||
};
|
||||
|
|
|
@ -7,221 +7,236 @@ import justifiedLayout from "justified-layout";
|
|||
* Otherwise, use flickr/justified-layout (at least for now).
|
||||
*/
|
||||
export function getLayout(
|
||||
input: {
|
||||
width: number,
|
||||
height: number,
|
||||
forceSquare: boolean,
|
||||
}[],
|
||||
opts: {
|
||||
rowWidth: number,
|
||||
rowHeight: number,
|
||||
squareMode: boolean,
|
||||
numCols: number,
|
||||
allowBreakout: boolean,
|
||||
seed: number,
|
||||
}
|
||||
input: {
|
||||
width: number;
|
||||
height: number;
|
||||
forceSquare: boolean;
|
||||
}[],
|
||||
opts: {
|
||||
rowWidth: number;
|
||||
rowHeight: number;
|
||||
squareMode: boolean;
|
||||
numCols: number;
|
||||
allowBreakout: boolean;
|
||||
seed: number;
|
||||
}
|
||||
): {
|
||||
top: number,
|
||||
left: number,
|
||||
width: number,
|
||||
height: number,
|
||||
rowHeight?: number,
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rowHeight?: number;
|
||||
}[] {
|
||||
if (input.length === 0) return [];
|
||||
if (input.length === 0) return [];
|
||||
|
||||
if (!opts.squareMode) {
|
||||
return justifiedLayout((input), {
|
||||
containerPadding: 0,
|
||||
boxSpacing: 0,
|
||||
containerWidth: opts.rowWidth,
|
||||
targetRowHeight: opts.rowHeight,
|
||||
targetRowHeightTolerance: 0.1,
|
||||
}).boxes;
|
||||
if (!opts.squareMode) {
|
||||
return justifiedLayout(input, {
|
||||
containerPadding: 0,
|
||||
boxSpacing: 0,
|
||||
containerWidth: opts.rowWidth,
|
||||
targetRowHeight: opts.rowHeight,
|
||||
targetRowHeightTolerance: 0.1,
|
||||
}).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
|
||||
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;
|
||||
}
|
||||
|
||||
// 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++;
|
||||
// Make sure we have this and the next few rows
|
||||
while (row + 3 >= matrix.length) {
|
||||
matrix.push(new Array(opts.numCols).fill(0));
|
||||
}
|
||||
|
||||
// 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++;
|
||||
// Check if already used
|
||||
if (matrix[row][col] & FLAG_USED) {
|
||||
col++;
|
||||
continue;
|
||||
}
|
||||
|
||||
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) {
|
||||
let str = '';
|
||||
for (let i = 0; i < matrix.length; i++) {
|
||||
const rstr = matrix[i].map(v => v.toString(2).padStart(numFlag, '0')).join(' ');
|
||||
str += i.toString().padStart(2) + ' | ' + rstr + '\n';
|
||||
}
|
||||
return str;
|
||||
let str = "";
|
||||
for (let i = 0; i < matrix.length; i++) {
|
||||
const rstr = matrix[i]
|
||||
.map((v) => v.toString(2).padStart(numFlag, "0"))
|
||||
.join(" ");
|
||||
str += i.toString().padStart(2) + " | " + rstr + "\n";
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function mulberry32(a: number) {
|
||||
return function() {
|
||||
var t = a += 0x6D2B79F5;
|
||||
t = Math.imul(t ^ t >>> 15, t | 1);
|
||||
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
||||
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
||||
}
|
||||
}
|
||||
return function () {
|
||||
var t = (a += 0x6d2b79f5);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -20,11 +20,11 @@
|
|||
*
|
||||
*/
|
||||
|
||||
const isNumber = function(num: any) {
|
||||
if (!num) {
|
||||
return false
|
||||
}
|
||||
return Number(num).toString() === num.toString()
|
||||
}
|
||||
const isNumber = function (num: any) {
|
||||
if (!num) {
|
||||
return false;
|
||||
}
|
||||
return Number(num).toString() === num.toString();
|
||||
};
|
||||
|
||||
export { isNumber }
|
||||
export { isNumber };
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { getCanonicalLocale } from "@nextcloud/l10n";
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { getCurrentUser } from "@nextcloud/auth";
|
||||
import { loadState } from "@nextcloud/initial-state";
|
||||
import { IPhoto } from "../types";
|
||||
import moment from 'moment';
|
||||
import moment from "moment";
|
||||
|
||||
// Memoize the result of short date conversions
|
||||
// These operations are surprisingly expensive
|
||||
|
@ -10,49 +10,54 @@ import moment from 'moment';
|
|||
const shortDateStrMemo = new Map<number, string>();
|
||||
|
||||
/** Get JS date object from dayId */
|
||||
export function dayIdToDate(dayId: number){
|
||||
return new Date(dayId*86400*1000);
|
||||
export function dayIdToDate(dayId: number) {
|
||||
return new Date(dayId * 86400 * 1000);
|
||||
}
|
||||
|
||||
/** Get Day ID from JS date */
|
||||
export function dateToDayId(date: Date){
|
||||
return Math.floor(date.getTime() / (86400*1000));
|
||||
export function dateToDayId(date: Date) {
|
||||
return Math.floor(date.getTime() / (86400 * 1000));
|
||||
}
|
||||
|
||||
/** Get month name from number */
|
||||
export function getShortDateStr(date: Date) {
|
||||
const dayId = dateToDayId(date);
|
||||
if (!shortDateStrMemo.has(dayId)) {
|
||||
shortDateStrMemo.set(dayId,
|
||||
date.toLocaleDateString(getCanonicalLocale(), {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}));
|
||||
}
|
||||
return shortDateStrMemo.get(dayId);
|
||||
const dayId = dateToDayId(date);
|
||||
if (!shortDateStrMemo.has(dayId)) {
|
||||
shortDateStrMemo.set(
|
||||
dayId,
|
||||
date.toLocaleDateString(getCanonicalLocale(), {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
);
|
||||
}
|
||||
return shortDateStrMemo.get(dayId);
|
||||
}
|
||||
|
||||
/** Get long date string with optional year if same as current */
|
||||
export function getLongDateStr(date: Date, skipYear=false, time=false) {
|
||||
return date.toLocaleDateString(getCanonicalLocale(), {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: (skipYear && date.getUTCFullYear() === new Date().getUTCFullYear()) ? undefined : 'numeric',
|
||||
timeZone: 'UTC',
|
||||
hour: time ? 'numeric' : undefined,
|
||||
minute: time ? 'numeric' : undefined,
|
||||
});
|
||||
export function getLongDateStr(date: Date, skipYear = false, time = false) {
|
||||
return date.toLocaleDateString(getCanonicalLocale(), {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year:
|
||||
skipYear && date.getUTCFullYear() === new Date().getUTCFullYear()
|
||||
? undefined
|
||||
: "numeric",
|
||||
timeZone: "UTC",
|
||||
hour: time ? "numeric" : undefined,
|
||||
minute: time ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get text like "5 years ago" from a date */
|
||||
export function getFromNowStr(date: Date) {
|
||||
// Get fromNow in correct locale
|
||||
const text = moment(date).locale(getCanonicalLocale()).fromNow();
|
||||
// Get fromNow in correct locale
|
||||
const text = moment(date).locale(getCanonicalLocale()).fromNow();
|
||||
|
||||
// Title case
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
// Title case
|
||||
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/
|
||||
*/
|
||||
export function hashCode(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0, len = str.length; i < len; i++) {
|
||||
let chr = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + chr;
|
||||
hash |= 0; // Convert to 32bit integer
|
||||
}
|
||||
return hash;
|
||||
let hash = 0;
|
||||
for (let i = 0, len = str.length; i < len; i++) {
|
||||
let chr = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + chr;
|
||||
hash |= 0; // Convert to 32bit integer
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -80,27 +85,25 @@ export function hashCode(str: string): number {
|
|||
* @param key Key to use for comparison
|
||||
*/
|
||||
export function binarySearch(arr: any, elem: any, key?: string) {
|
||||
let minIndex = 0;
|
||||
let maxIndex = arr.length - 1;
|
||||
let currentIndex: number;
|
||||
let currentElement: any;
|
||||
let minIndex = 0;
|
||||
let maxIndex = arr.length - 1;
|
||||
let currentIndex: number;
|
||||
let currentElement: any;
|
||||
|
||||
while (minIndex <= maxIndex) {
|
||||
currentIndex = (minIndex + maxIndex) / 2 | 0;
|
||||
currentElement = key ? arr[currentIndex][key] : arr[currentIndex];
|
||||
while (minIndex <= maxIndex) {
|
||||
currentIndex = ((minIndex + maxIndex) / 2) | 0;
|
||||
currentElement = key ? arr[currentIndex][key] : arr[currentIndex];
|
||||
|
||||
if (currentElement < elem) {
|
||||
minIndex = currentIndex + 1;
|
||||
}
|
||||
else if (currentElement > elem) {
|
||||
maxIndex = currentIndex - 1;
|
||||
}
|
||||
else {
|
||||
return currentIndex;
|
||||
}
|
||||
if (currentElement < elem) {
|
||||
minIndex = currentIndex + 1;
|
||||
} else if (currentElement > elem) {
|
||||
maxIndex = currentIndex - 1;
|
||||
} 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 floor If true, round down instead of to nearest
|
||||
*/
|
||||
export function round(num: number, places: number, floor=false) {
|
||||
const pow = Math.pow(10, places);
|
||||
const int = num * pow;
|
||||
return (floor ? Math.floor : Math.round)(int) / pow;
|
||||
export function round(num: number, places: number, floor = false) {
|
||||
const pow = Math.pow(10, places);
|
||||
const int = num * 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
|
||||
*/
|
||||
export function roundHalf(num: number) {
|
||||
return Math.round(num * 2) / 2;
|
||||
return Math.round(num * 2) / 2;
|
||||
}
|
||||
|
||||
/** Choose a random element from an array */
|
||||
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
|
||||
*/
|
||||
export function randomSubarray(arr: any[], size: number) {
|
||||
if (arr.length <= size) return arr;
|
||||
var shuffled = arr.slice(0), i = arr.length, min = i - size, temp, index;
|
||||
while (i-- > min) {
|
||||
index = Math.floor((i + 1) * Math.random());
|
||||
temp = shuffled[index];
|
||||
shuffled[index] = shuffled[i];
|
||||
shuffled[i] = temp;
|
||||
}
|
||||
return shuffled.slice(min);
|
||||
if (arr.length <= size) return arr;
|
||||
var shuffled = arr.slice(0),
|
||||
i = arr.length,
|
||||
min = i - size,
|
||||
temp,
|
||||
index;
|
||||
while (i-- > min) {
|
||||
index = Math.floor((i + 1) * Math.random());
|
||||
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
|
||||
*/
|
||||
export function convertFlags(photo: IPhoto) {
|
||||
if (typeof photo.flag === "undefined") {
|
||||
photo.flag = 0; // flags
|
||||
}
|
||||
if (typeof photo.flag === "undefined") {
|
||||
photo.flag = 0; // flags
|
||||
}
|
||||
|
||||
if (photo.isvideo) {
|
||||
photo.flag |= constants.c.FLAG_IS_VIDEO;
|
||||
delete photo.isvideo;
|
||||
}
|
||||
if (photo.isfavorite) {
|
||||
photo.flag |= constants.c.FLAG_IS_FAVORITE;
|
||||
delete photo.isfavorite;
|
||||
}
|
||||
if (photo.isfolder) {
|
||||
photo.flag |= constants.c.FLAG_IS_FOLDER;
|
||||
delete photo.isfolder;
|
||||
}
|
||||
if (photo.isface) {
|
||||
photo.flag |= constants.c.FLAG_IS_FACE;
|
||||
delete photo.isface;
|
||||
}
|
||||
if (photo.istag) {
|
||||
photo.flag |= constants.c.FLAG_IS_TAG;
|
||||
delete photo.istag;
|
||||
}
|
||||
if (photo.isalbum) {
|
||||
photo.flag |= constants.c.FLAG_IS_ALBUM;
|
||||
delete photo.isalbum;
|
||||
}
|
||||
if (photo.isvideo) {
|
||||
photo.flag |= constants.c.FLAG_IS_VIDEO;
|
||||
delete photo.isvideo;
|
||||
}
|
||||
if (photo.isfavorite) {
|
||||
photo.flag |= constants.c.FLAG_IS_FAVORITE;
|
||||
delete photo.isfavorite;
|
||||
}
|
||||
if (photo.isfolder) {
|
||||
photo.flag |= constants.c.FLAG_IS_FOLDER;
|
||||
delete photo.isfolder;
|
||||
}
|
||||
if (photo.isface) {
|
||||
photo.flag |= constants.c.FLAG_IS_FACE;
|
||||
delete photo.isface;
|
||||
}
|
||||
if (photo.istag) {
|
||||
photo.flag |= constants.c.FLAG_IS_TAG;
|
||||
delete photo.istag;
|
||||
}
|
||||
if (photo.isalbum) {
|
||||
photo.flag |= constants.c.FLAG_IS_ALBUM;
|
||||
delete photo.isalbum;
|
||||
}
|
||||
}
|
||||
|
||||
// Outside for set
|
||||
const TagDayID = {
|
||||
START: -(1 << 30),
|
||||
FOLDERS: -(1 << 30) + 1,
|
||||
TAGS: -(1 << 30) + 2,
|
||||
FACES: -(1 << 30) + 3,
|
||||
ALBUMS: -(1 << 30) + 4,
|
||||
}
|
||||
START: -(1 << 30),
|
||||
FOLDERS: -(1 << 30) + 1,
|
||||
TAGS: -(1 << 30) + 2,
|
||||
FACES: -(1 << 30) + 3,
|
||||
ALBUMS: -(1 << 30) + 4,
|
||||
};
|
||||
|
||||
/** Global constants */
|
||||
export const constants = {
|
||||
c: {
|
||||
FLAG_PLACEHOLDER: 1 << 0,
|
||||
FLAG_LOAD_FAIL: 1 << 1,
|
||||
FLAG_IS_VIDEO: 1 << 2,
|
||||
FLAG_IS_FAVORITE: 1 << 3,
|
||||
FLAG_IS_FOLDER: 1 << 4,
|
||||
FLAG_IS_TAG: 1 << 5,
|
||||
FLAG_IS_FACE: 1 << 6,
|
||||
FLAG_IS_ALBUM: 1 << 7,
|
||||
FLAG_SELECTED: 1 << 8,
|
||||
FLAG_LEAVING: 1 << 9,
|
||||
},
|
||||
c: {
|
||||
FLAG_PLACEHOLDER: 1 << 0,
|
||||
FLAG_LOAD_FAIL: 1 << 1,
|
||||
FLAG_IS_VIDEO: 1 << 2,
|
||||
FLAG_IS_FAVORITE: 1 << 3,
|
||||
FLAG_IS_FOLDER: 1 << 4,
|
||||
FLAG_IS_TAG: 1 << 5,
|
||||
FLAG_IS_FACE: 1 << 6,
|
||||
FLAG_IS_ALBUM: 1 << 7,
|
||||
FLAG_SELECTED: 1 << 8,
|
||||
FLAG_LEAVING: 1 << 9,
|
||||
},
|
||||
|
||||
TagDayID: TagDayID,
|
||||
TagDayIDValueSet: new Set(Object.values(TagDayID)),
|
||||
}
|
||||
TagDayID: TagDayID,
|
||||
TagDayIDValueSet: new Set(Object.values(TagDayID)),
|
||||
};
|
||||
|
||||
/** Cache store */
|
||||
let staticCache: Cache | null = null;
|
||||
const cacheName = `memories-${loadState('memories', 'version')}-${getCurrentUser()!.uid}`;
|
||||
openCache().then((cache) => { staticCache = cache });
|
||||
const cacheName = `memories-${loadState("memories", "version")}-${
|
||||
getCurrentUser()!.uid
|
||||
}`;
|
||||
openCache().then((cache) => {
|
||||
staticCache = cache;
|
||||
});
|
||||
|
||||
// Clear all caches except the current one
|
||||
window.caches?.keys().then((keys) => {
|
||||
keys.filter((key) => key.startsWith('memories-') && key !== cacheName).forEach((key) => {
|
||||
window.caches.delete(key);
|
||||
keys
|
||||
.filter((key) => key.startsWith("memories-") && key !== cacheName)
|
||||
.forEach((key) => {
|
||||
window.caches.delete(key);
|
||||
});
|
||||
});
|
||||
|
||||
/** Open the cache */
|
||||
export async function openCache() {
|
||||
try {
|
||||
return await window.caches?.open(cacheName);
|
||||
} catch {
|
||||
console.warn('Failed to get cache', cacheName);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await window.caches?.open(cacheName);
|
||||
} catch {
|
||||
console.warn("Failed to get cache", cacheName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get data from the cache */
|
||||
export async function getCachedData<T>(url: string): Promise<T> {
|
||||
if (!window.caches) return null;
|
||||
const cache = staticCache || await openCache();
|
||||
if (!cache) return null;
|
||||
if (!window.caches) return null;
|
||||
const cache = staticCache || (await openCache());
|
||||
if (!cache) return null;
|
||||
|
||||
const cachedResponse = await cache.match(url);
|
||||
if (!cachedResponse || !cachedResponse.ok) return undefined;
|
||||
return await cachedResponse.json();
|
||||
const cachedResponse = await cache.match(url);
|
||||
if (!cachedResponse || !cachedResponse.ok) return undefined;
|
||||
return await cachedResponse.json();
|
||||
}
|
||||
|
||||
/** Store data in the cache */
|
||||
export function cacheData(url: string, data: Object) {
|
||||
if (!window.caches) return;
|
||||
const str = JSON.stringify(data);
|
||||
if (!window.caches) return;
|
||||
const str = JSON.stringify(data);
|
||||
|
||||
(async () => {
|
||||
const cache = staticCache || await openCache();
|
||||
if (!cache) return;
|
||||
(async () => {
|
||||
const cache = staticCache || (await openCache());
|
||||
if (!cache) return;
|
||||
|
||||
const response = new Response(str);
|
||||
response.headers.set('Content-Type', 'application/json');
|
||||
await cache.put(url, response);
|
||||
})();
|
||||
}
|
||||
const response = new Response(str);
|
||||
response.headers.set("Content-Type", "application/json");
|
||||
await cache.put(url, response);
|
||||
})();
|
||||
}
|
||||
|
|
|
@ -1,87 +1,88 @@
|
|||
import { IFileInfo, IPhoto } from "../types";
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { subscribe } from '@nextcloud/event-bus';
|
||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
import { subscribe } from "@nextcloud/event-bus";
|
||||
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
|
||||
import * as dav from "./DavRequests";
|
||||
|
||||
// Key to store sidebar state
|
||||
const SIDEBAR_KEY = 'memories:sidebar-open';
|
||||
const SIDEBAR_KEY = "memories:sidebar-open";
|
||||
|
||||
export class ViewerManager {
|
||||
/** Map from fileid to Photo */
|
||||
private photoMap = new Map<number, IPhoto>();
|
||||
/** Map from fileid to Photo */
|
||||
private photoMap = new Map<number, IPhoto>();
|
||||
|
||||
constructor(
|
||||
ondelete: (photos: IPhoto[]) => void,
|
||||
private updateLoading: (delta: number) => void,
|
||||
) {
|
||||
subscribe('files:file:deleted', ({ fileid }: { fileid: number }) => {
|
||||
const photo = this.photoMap.get(fileid);
|
||||
ondelete([photo]);
|
||||
});
|
||||
constructor(
|
||||
ondelete: (photos: IPhoto[]) => void,
|
||||
private updateLoading: (delta: number) => void
|
||||
) {
|
||||
subscribe("files:file:deleted", ({ fileid }: { fileid: number }) => {
|
||||
const photo = this.photoMap.get(fileid);
|
||||
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[]) {
|
||||
list = list || photo.d?.detail;
|
||||
if (!list) return;
|
||||
|
||||
// Repopulate map
|
||||
this.photoMap.clear();
|
||||
for (const p of list) {
|
||||
this.photoMap.set(p.fileid, p);
|
||||
}
|
||||
|
||||
// Get file infos
|
||||
let fileInfos: IFileInfo[];
|
||||
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);
|
||||
}
|
||||
// Get file infos
|
||||
let fileInfos: IFileInfo[];
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,51 +1,58 @@
|
|||
import * as base from "./base";
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
||||
import { IAlbum, IDay, ITag } from '../../types';
|
||||
import { constants } from '../Utils';
|
||||
import axios from '@nextcloud/axios'
|
||||
import client from '../DavClient';
|
||||
import { getCurrentUser } from "@nextcloud/auth";
|
||||
import { generateUrl } from "@nextcloud/router";
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
|
||||
import { IAlbum, IDay, ITag } from "../../types";
|
||||
import { constants } from "../Utils";
|
||||
import axios from "@nextcloud/axios";
|
||||
import client from "../DavClient";
|
||||
|
||||
/**
|
||||
* Get DAV path for album
|
||||
*/
|
||||
export function getAlbumPath(user: string, name: string) {
|
||||
// Folder in the dav collection for user
|
||||
const cuid = getCurrentUser().uid;
|
||||
if (user === cuid) {
|
||||
return `/photos/${cuid}/albums/${name}`;
|
||||
} else {
|
||||
return `/photos/${cuid}/sharedalbums/${name} (${user})`;
|
||||
}
|
||||
export function getAlbumPath(user: string, name: string) {
|
||||
// Folder in the dav collection for user
|
||||
const cuid = getCurrentUser().uid;
|
||||
if (user === cuid) {
|
||||
return `/photos/${cuid}/albums/${name}`;
|
||||
} else {
|
||||
return `/photos/${cuid}/sharedalbums/${name} (${user})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of albums and convert to Days response
|
||||
* @param type Type of albums to get; 1 = personal, 2 = shared, 3 = all
|
||||
*/
|
||||
export async function getAlbumsData(type: '1' | '2' | '3'): Promise<IDay[]> {
|
||||
let data: IAlbum[] = [];
|
||||
try {
|
||||
const res = await axios.get<typeof data>(generateUrl(`/apps/memories/api/albums?t=${type}`));
|
||||
data = res.data;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
export async function getAlbumsData(type: "1" | "2" | "3"): Promise<IDay[]> {
|
||||
let data: IAlbum[] = [];
|
||||
try {
|
||||
const res = await axios.get<typeof data>(
|
||||
generateUrl(`/apps/memories/api/albums?t=${type}`)
|
||||
);
|
||||
data = res.data;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Convert to days response
|
||||
return [{
|
||||
dayid: constants.TagDayID.ALBUMS,
|
||||
count: data.length,
|
||||
detail: data.map((album) => ({
|
||||
// Convert to days response
|
||||
return [
|
||||
{
|
||||
dayid: constants.TagDayID.ALBUMS,
|
||||
count: data.length,
|
||||
detail: data.map(
|
||||
(album) =>
|
||||
({
|
||||
...album,
|
||||
fileid: album.album_id,
|
||||
flag: constants.c.FLAG_IS_TAG & constants.c.FLAG_IS_ALBUM,
|
||||
istag: true,
|
||||
isalbum: true,
|
||||
} as ITag)),
|
||||
}]
|
||||
} as ITag)
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -56,34 +63,37 @@ export async function getAlbumsData(type: '1' | '2' | '3'): Promise<IDay[]> {
|
|||
* @param fileIds List of file IDs to add
|
||||
* @returns Generator
|
||||
*/
|
||||
export async function* addToAlbum(user: string, name: string, fileIds: number[]) {
|
||||
// Get files data
|
||||
let fileInfos = await base.getFiles(fileIds.filter(f => f));
|
||||
export async function* addToAlbum(
|
||||
user: string,
|
||||
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
|
||||
const calls = fileInfos.map((f) => async () => {
|
||||
try {
|
||||
await client.copyFile(
|
||||
f.originalFilename,
|
||||
`${albumPath}/${f.basename}`,
|
||||
)
|
||||
return f.fileid;
|
||||
} catch (e) {
|
||||
if (e.response?.status === 409) {
|
||||
// File already exists, all good
|
||||
return f.fileid;
|
||||
}
|
||||
// Add each file
|
||||
const calls = fileInfos.map((f) => async () => {
|
||||
try {
|
||||
await client.copyFile(f.originalFilename, `${albumPath}/${f.basename}`);
|
||||
return f.fileid;
|
||||
} catch (e) {
|
||||
if (e.response?.status === 409) {
|
||||
// File already exists, all good
|
||||
return f.fileid;
|
||||
}
|
||||
|
||||
showError(t('memories', 'Failed to add {filename} to album.', {
|
||||
filename: f.filename,
|
||||
}));
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
showError(
|
||||
t("memories", "Failed to add {filename} to album.", {
|
||||
filename: f.filename,
|
||||
})
|
||||
);
|
||||
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
|
||||
* @returns Generator
|
||||
*/
|
||||
export async function* removeFromAlbum(user: string, name: string, fileIds: number[]) {
|
||||
// Get files data
|
||||
let fileInfos = await base.getFiles(fileIds.filter(f => f));
|
||||
export async function* removeFromAlbum(
|
||||
user: string,
|
||||
name: string,
|
||||
fileIds: number[]
|
||||
) {
|
||||
// Get files data
|
||||
let fileInfos = await base.getFiles(fileIds.filter((f) => f));
|
||||
|
||||
// Add each file
|
||||
const calls = fileInfos.map((f) => async () => {
|
||||
try {
|
||||
await client.deleteFile(
|
||||
`/photos/${user}/albums/${name}/${f.fileid}-${f.basename}`,
|
||||
)
|
||||
return f.fileid;
|
||||
} catch (e) {
|
||||
showError(t('memories', 'Failed to remove {filename}.', {
|
||||
filename: f.filename,
|
||||
}));
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
// Add each file
|
||||
const calls = fileInfos.map((f) => async () => {
|
||||
try {
|
||||
await client.deleteFile(
|
||||
`/photos/${user}/albums/${name}/${f.fileid}-${f.basename}`
|
||||
);
|
||||
return f.fileid;
|
||||
} catch (e) {
|
||||
showError(
|
||||
t("memories", "Failed to remove {filename}.", {
|
||||
filename: f.filename,
|
||||
})
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
yield* base.runInParallel(calls, 10);
|
||||
yield* base.runInParallel(calls, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an album.
|
||||
*/
|
||||
export async function createAlbum(albumName: string) {
|
||||
try {
|
||||
await client.createDirectory(`/photos/${getCurrentUser()?.uid}/albums/${albumName}`)
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showError(t('photos', 'Failed to create {albumName}.', { albumName }))
|
||||
}
|
||||
try {
|
||||
await client.createDirectory(
|
||||
`/photos/${getCurrentUser()?.uid}/albums/${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.
|
||||
*/
|
||||
export async function updateAlbum(album: any, { albumName, properties }: any) {
|
||||
const stringifiedProperties = Object
|
||||
.entries(properties)
|
||||
.map(([name, value]) => {
|
||||
switch (typeof value) {
|
||||
case 'string':
|
||||
return `<nc:${name}>${value}</nc:${name}>`
|
||||
case 'object':
|
||||
return `<nc:${name}>${JSON.stringify(value)}</nc:${name}>`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
.join()
|
||||
const stringifiedProperties = Object.entries(properties)
|
||||
.map(([name, value]) => {
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
return `<nc:${name}>${value}</nc:${name}>`;
|
||||
case "object":
|
||||
return `<nc:${name}>${JSON.stringify(value)}</nc:${name}>`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.join();
|
||||
|
||||
try {
|
||||
await client.customRequest(
|
||||
album.filename,
|
||||
{
|
||||
method: 'PROPPATCH',
|
||||
data: `<?xml version="1.0"?>
|
||||
try {
|
||||
await client.customRequest(album.filename, {
|
||||
method: "PROPPATCH",
|
||||
data: `<?xml version="1.0"?>
|
||||
<d:propertyupdate xmlns:d="DAV:"
|
||||
xmlns:oc="http://owncloud.org/ns"
|
||||
xmlns:nc="http://nextcloud.org/ns"
|
||||
|
@ -167,15 +182,20 @@ export async function updateAlbum(album: any, { albumName, properties }: any) {
|
|||
</d:prop>
|
||||
</d:set>
|
||||
</d:propertyupdate>`,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return album;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showError(t('photos', 'Failed to update properties of {albumName} with {properties}.', { albumName, properties: JSON.stringify(properties) }))
|
||||
return album
|
||||
}
|
||||
return album;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showError(
|
||||
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)
|
||||
*/
|
||||
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:"
|
||||
xmlns:oc="http://owncloud.org/ns"
|
||||
xmlns:nc="http://nextcloud.org/ns"
|
||||
|
@ -198,33 +218,41 @@ export async function getAlbum(user: string, name: string, extraProps = {}) {
|
|||
${extraProps}
|
||||
</d:prop>
|
||||
</d:propfind>`;
|
||||
let album = await client.stat(`/photos/${user}/albums/${name}`, {
|
||||
data: req,
|
||||
details: true,
|
||||
}) as any;
|
||||
let album = (await client.stat(`/photos/${user}/albums/${name}`, {
|
||||
data: req,
|
||||
details: true,
|
||||
})) as any;
|
||||
|
||||
// Post processing
|
||||
album = {
|
||||
...album.data,
|
||||
...album.data.props,
|
||||
};
|
||||
const c = album?.collaborators?.collaborator;
|
||||
album.collaborators = c ? (Array.isArray(c) ? c : [c]) : [];
|
||||
return album;
|
||||
// Post processing
|
||||
album = {
|
||||
...album.data,
|
||||
...album.data.props,
|
||||
};
|
||||
const c = album?.collaborators?.collaborator;
|
||||
album.collaborators = c ? (Array.isArray(c) ? c : [c]) : [];
|
||||
return album;
|
||||
}
|
||||
|
||||
/** Rename an album */
|
||||
export async function renameAlbum(album: any, { currentAlbumName, newAlbumName }) {
|
||||
const newAlbum = { ...album, basename: newAlbumName }
|
||||
try {
|
||||
await client.moveFile(
|
||||
`/photos/${getCurrentUser()?.uid}/albums/${currentAlbumName}`,
|
||||
`/photos/${getCurrentUser()?.uid}/albums/${newAlbumName}`,
|
||||
)
|
||||
return newAlbum
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showError(t('photos', 'Failed to rename {currentAlbumName} to {newAlbumName}.', { currentAlbumName, newAlbumName }))
|
||||
return album
|
||||
}
|
||||
export async function renameAlbum(
|
||||
album: any,
|
||||
{ currentAlbumName, newAlbumName }
|
||||
) {
|
||||
const newAlbum = { ...album, basename: newAlbumName };
|
||||
try {
|
||||
await client.moveFile(
|
||||
`/photos/${getCurrentUser()?.uid}/albums/${currentAlbumName}`,
|
||||
`/photos/${getCurrentUser()?.uid}/albums/${newAlbumName}`
|
||||
);
|
||||
return newAlbum;
|
||||
} 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 { generateUrl } from '@nextcloud/router'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
||||
import axios from '@nextcloud/axios'
|
||||
import * as base from "./base";
|
||||
import { generateUrl } from "@nextcloud/router";
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
|
||||
import axios from "@nextcloud/axios";
|
||||
|
||||
/**
|
||||
* Archive or unarchive a single file
|
||||
|
@ -10,8 +10,11 @@ import axios from '@nextcloud/axios'
|
|||
* @param fileid File id
|
||||
* @param archive Archive or unarchive
|
||||
*/
|
||||
export async function archiveFile(fileid: number, archive: boolean) {
|
||||
return await axios.patch(generateUrl('/apps/memories/api/archive/{fileid}', { fileid }), { archive });
|
||||
export async function archiveFile(fileid: number, archive: boolean) {
|
||||
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
|
||||
* @returns list of file ids that were deleted
|
||||
*/
|
||||
export async function* archiveFilesByIds(fileIds: number[], archive: boolean) {
|
||||
if (fileIds.length === 0) {
|
||||
return;
|
||||
export async function* archiveFilesByIds(fileIds: number[], archive: boolean) {
|
||||
if (fileIds.length === 0) {
|
||||
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
|
||||
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);
|
||||
}
|
||||
yield* base.runInParallel(calls, 10);
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { getCurrentUser } from '@nextcloud/auth';
|
||||
import { showError } from '@nextcloud/dialogs';
|
||||
import { translate as t } from '@nextcloud/l10n';
|
||||
import { IFileInfo } from '../../types';
|
||||
import client from '../DavClient';
|
||||
import { genFileInfo } from '../FileUtils';
|
||||
import { getCurrentUser } from "@nextcloud/auth";
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
import { translate as t } from "@nextcloud/l10n";
|
||||
import { IFileInfo } from "../../types";
|
||||
import client from "../DavClient";
|
||||
import { genFileInfo } from "../FileUtils";
|
||||
|
||||
export const props = `
|
||||
<oc:fileid />
|
||||
|
@ -17,18 +17,18 @@ export const props = `
|
|||
<d:resourcetype />`;
|
||||
|
||||
export const IMAGE_MIME_TYPES = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/heic',
|
||||
'image/png',
|
||||
'image/tiff',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
'video/mpeg',
|
||||
'video/webm',
|
||||
'video/mp4',
|
||||
'video/quicktime',
|
||||
'video/x-matroska',
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/heic",
|
||||
"image/png",
|
||||
"image/tiff",
|
||||
"image/gif",
|
||||
"image/bmp",
|
||||
"video/mpeg",
|
||||
"video/webm",
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/x-matroska",
|
||||
];
|
||||
|
||||
const GET_FILE_CHUNK_SIZE = 50;
|
||||
|
@ -38,17 +38,17 @@ const GET_FILE_CHUNK_SIZE = 50;
|
|||
* @param fileIds list of file ids
|
||||
* @returns list of file infos
|
||||
*/
|
||||
export async function getFiles(fileIds: number[]): Promise<IFileInfo[]> {
|
||||
// Divide fileIds into chunks of GET_FILE_CHUNK_SIZE
|
||||
const chunks = [];
|
||||
for (let i = 0; i < fileIds.length; i += GET_FILE_CHUNK_SIZE) {
|
||||
chunks.push(fileIds.slice(i, i + GET_FILE_CHUNK_SIZE));
|
||||
}
|
||||
export async function getFiles(fileIds: number[]): Promise<IFileInfo[]> {
|
||||
// Divide fileIds into chunks of GET_FILE_CHUNK_SIZE
|
||||
const chunks = [];
|
||||
for (let i = 0; i < fileIds.length; i += GET_FILE_CHUNK_SIZE) {
|
||||
chunks.push(fileIds.slice(i, i + GET_FILE_CHUNK_SIZE));
|
||||
}
|
||||
|
||||
// Get file infos for each chunk
|
||||
const fileInfos = await Promise.all(chunks.map(getFilesInternal));
|
||||
return fileInfos.flat();
|
||||
}
|
||||
// Get file infos for each chunk
|
||||
const fileInfos = await Promise.all(chunks.map(getFilesInternal));
|
||||
return fileInfos.flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file infos for list of files given Ids
|
||||
|
@ -56,29 +56,33 @@ const GET_FILE_CHUNK_SIZE = 50;
|
|||
* @returns list of file infos
|
||||
*/
|
||||
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
|
||||
// returns EVERYTHING on the server!
|
||||
if (fileIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
// IMPORTANT: if this isn't there, then a blank
|
||||
// returns EVERYTHING on the server!
|
||||
if (fileIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filter = fileIds.map(fileId => `
|
||||
const filter = fileIds
|
||||
.map(
|
||||
(fileId) => `
|
||||
<d:eq>
|
||||
<d:prop>
|
||||
<oc:fileid/>
|
||||
</d:prop>
|
||||
<d:literal>${fileId}</d:literal>
|
||||
</d:eq>
|
||||
`).join('');
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
const options = {
|
||||
method: 'SEARCH',
|
||||
headers: {
|
||||
'content-Type': 'text/xml',
|
||||
},
|
||||
data: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
const options = {
|
||||
method: "SEARCH",
|
||||
headers: {
|
||||
"content-Type": "text/xml",
|
||||
},
|
||||
data: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:searchrequest xmlns:d="DAV:"
|
||||
xmlns:oc="http://owncloud.org/ns"
|
||||
xmlns:nc="http://nextcloud.org/ns"
|
||||
|
@ -103,34 +107,39 @@ async function getFilesInternal(fileIds: number[]): Promise<IFileInfo[]> {
|
|||
</d:where>
|
||||
</d:basicsearch>
|
||||
</d:searchrequest>`,
|
||||
deep: true,
|
||||
details: true,
|
||||
responseType: 'text',
|
||||
};
|
||||
deep: true,
|
||||
details: true,
|
||||
responseType: "text",
|
||||
};
|
||||
|
||||
let response: any = await client.getDirectoryContents('', options);
|
||||
return response.data
|
||||
.map((data: any) => genFileInfo(data))
|
||||
.map((data: any) => Object.assign({}, data, {
|
||||
originalFilename: data.filename,
|
||||
filename: data.filename.replace(prefixPath, '')
|
||||
}));
|
||||
let response: any = await client.getDirectoryContents("", options);
|
||||
return response.data
|
||||
.map((data: any) => genFileInfo(data))
|
||||
.map((data: any) =>
|
||||
Object.assign({}, data, {
|
||||
originalFilename: data.filename,
|
||||
filename: data.filename.replace(prefixPath, ""),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Run promises in parallel, but only n at a time
|
||||
* @param promises Array of promise generator funnction (async functions)
|
||||
* @param n Number of promises to run in parallel
|
||||
*/
|
||||
export async function* runInParallel<T>(promises: (() => Promise<T>)[], n: number) {
|
||||
while (promises.length > 0) {
|
||||
const promisesToRun = promises.splice(0, n);
|
||||
const resultsForThisBatch = await Promise.all(promisesToRun.map(p => p()));
|
||||
yield resultsForThisBatch;
|
||||
}
|
||||
return;
|
||||
export async function* runInParallel<T>(
|
||||
promises: (() => Promise<T>)[],
|
||||
n: number
|
||||
) {
|
||||
while (promises.length > 0) {
|
||||
const promisesToRun = promises.splice(0, n);
|
||||
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
|
||||
*/
|
||||
export async function deleteFile(path: string) {
|
||||
const prefixPath = `/files/${getCurrentUser()!.uid}`;
|
||||
return await client.deleteFile(`${prefixPath}${path}`);
|
||||
const prefixPath = `/files/${getCurrentUser()!.uid}`;
|
||||
return await client.deleteFile(`${prefixPath}${path}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -150,46 +159,34 @@ export async function deleteFile(path: string) {
|
|||
* @returns list of file ids that were deleted
|
||||
*/
|
||||
export async function* deleteFilesByIds(fileIds: number[]) {
|
||||
const fileIdsSet = new Set(fileIds);
|
||||
const fileIdsSet = new Set(fileIds);
|
||||
|
||||
if (fileIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (fileIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get files data
|
||||
let fileInfos: any[] = [];
|
||||
// Get files data
|
||||
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 {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// Delete each file
|
||||
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);
|
||||
yield* runInParallel(calls, 10);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import * as base from './base';
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import * as base from "./base";
|
||||
import { generateUrl } from "@nextcloud/router";
|
||||
|
||||
/**
|
||||
* Download a file
|
||||
|
@ -7,32 +7,32 @@ import { generateUrl } from '@nextcloud/router'
|
|||
* @param fileNames - The file's names
|
||||
*/
|
||||
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()
|
||||
params.append('files', JSON.stringify(fileNames))
|
||||
params.append('downloadStartSecret', randomToken)
|
||||
const params = new URLSearchParams();
|
||||
params.append("files", JSON.stringify(fileNames));
|
||||
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) => {
|
||||
const waitForCookieInterval = setInterval(
|
||||
() => {
|
||||
const cookieIsSet = document.cookie
|
||||
.split(';')
|
||||
.map(cookie => cookie.split('='))
|
||||
.findIndex(([cookieName, cookieValue]) => cookieName === 'ocDownloadStarted' && cookieValue === randomToken)
|
||||
return new Promise((resolve) => {
|
||||
const waitForCookieInterval = setInterval(() => {
|
||||
const cookieIsSet = document.cookie
|
||||
.split(";")
|
||||
.map((cookie) => cookie.split("="))
|
||||
.findIndex(
|
||||
([cookieName, cookieValue]) =>
|
||||
cookieName === "ocDownloadStarted" && cookieValue === randomToken
|
||||
);
|
||||
|
||||
if (cookieIsSet) {
|
||||
clearInterval(waitForCookieInterval)
|
||||
resolve(true)
|
||||
}
|
||||
},
|
||||
50
|
||||
)
|
||||
})
|
||||
if (cookieIsSet) {
|
||||
clearInterval(waitForCookieInterval);
|
||||
resolve(true);
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -40,11 +40,11 @@ export async function downloadFiles(fileNames: string[]): Promise<boolean> {
|
|||
* @param fileIds list of file ids
|
||||
*/
|
||||
export async function downloadFilesByIds(fileIds: number[]) {
|
||||
if (fileIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (fileIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get files to download
|
||||
const fileInfos = await base.getFiles(fileIds);
|
||||
await downloadFiles(fileInfos.map(f => f.filename));
|
||||
}
|
||||
// Get files to download
|
||||
const fileInfos = await base.getFiles(fileIds);
|
||||
await downloadFiles(fileInfos.map((f) => f.filename));
|
||||
}
|
||||
|
|
|
@ -1,44 +1,51 @@
|
|||
import axios from '@nextcloud/axios';
|
||||
import { showError } from '@nextcloud/dialogs';
|
||||
import { translate as t } from '@nextcloud/l10n';
|
||||
import { generateUrl } from '@nextcloud/router';
|
||||
import { IDay, IPhoto } from '../../types';
|
||||
import client from '../DavClient';
|
||||
import { constants } from '../Utils';
|
||||
import * as base from './base';
|
||||
import axios from "@nextcloud/axios";
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
import { translate as t } from "@nextcloud/l10n";
|
||||
import { generateUrl } from "@nextcloud/router";
|
||||
import { IDay, IPhoto } from "../../types";
|
||||
import client from "../DavClient";
|
||||
import { constants } from "../Utils";
|
||||
import * as base from "./base";
|
||||
|
||||
/**
|
||||
* Get list of tags and convert to Days response
|
||||
*/
|
||||
export async function getPeopleData(): Promise<IDay[]> {
|
||||
// Query for photos
|
||||
let data: {
|
||||
id: number;
|
||||
count: number;
|
||||
name: string;
|
||||
previews: IPhoto[];
|
||||
}[] = [];
|
||||
try {
|
||||
const res = await axios.get<typeof data>(generateUrl('/apps/memories/api/faces'));
|
||||
data = res.data;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
// Query for photos
|
||||
let data: {
|
||||
id: number;
|
||||
count: number;
|
||||
name: string;
|
||||
previews: IPhoto[];
|
||||
}[] = [];
|
||||
try {
|
||||
const res = await axios.get<typeof data>(
|
||||
generateUrl("/apps/memories/api/faces")
|
||||
);
|
||||
data = res.data;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Add flag to previews
|
||||
data.forEach(t => t.previews?.forEach((preview) => preview.flag = 0));
|
||||
// Add flag to previews
|
||||
data.forEach((t) => t.previews?.forEach((preview) => (preview.flag = 0)));
|
||||
|
||||
// Convert to days response
|
||||
return [{
|
||||
dayid: constants.TagDayID.FACES,
|
||||
count: data.length,
|
||||
detail: data.map((face) => ({
|
||||
// Convert to days response
|
||||
return [
|
||||
{
|
||||
dayid: constants.TagDayID.FACES,
|
||||
count: data.length,
|
||||
detail: data.map(
|
||||
(face) =>
|
||||
({
|
||||
...face,
|
||||
fileid: face.id,
|
||||
istag: 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
|
||||
* @returns Generator
|
||||
*/
|
||||
export async function* removeFaceImages(user: string, name: string, fileIds: number[]) {
|
||||
// Get files data
|
||||
let fileInfos = await base.getFiles(fileIds.filter(f => f));
|
||||
export async function* removeFaceImages(
|
||||
user: string,
|
||||
name: string,
|
||||
fileIds: number[]
|
||||
) {
|
||||
// Get files data
|
||||
let fileInfos = await base.getFiles(fileIds.filter((f) => f));
|
||||
|
||||
// Remove each file
|
||||
const calls = fileInfos.map((f) => async () => {
|
||||
try {
|
||||
await client.deleteFile(`/recognize/${user}/faces/${name}/${f.fileid}-${f.basename}`)
|
||||
return f.fileid;
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
showError(t('memories', 'Failed to remove {filename} from face.', {
|
||||
filename: f.filename,
|
||||
}));
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
// Remove each file
|
||||
const calls = fileInfos.map((f) => async () => {
|
||||
try {
|
||||
await client.deleteFile(
|
||||
`/recognize/${user}/faces/${name}/${f.fileid}-${f.basename}`
|
||||
);
|
||||
return f.fileid;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showError(
|
||||
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 { generateUrl } from '@nextcloud/router'
|
||||
import { encodePath } from '@nextcloud/paths'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
||||
import axios from '@nextcloud/axios'
|
||||
import * as base from "./base";
|
||||
import { generateUrl } from "@nextcloud/router";
|
||||
import { encodePath } from "@nextcloud/paths";
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
|
||||
import axios from "@nextcloud/axios";
|
||||
|
||||
/**
|
||||
* Favorite a file
|
||||
|
@ -13,22 +13,22 @@ import axios from '@nextcloud/axios'
|
|||
* @param favoriteState - The new favorite state
|
||||
*/
|
||||
export async function favoriteFile(fileName: string, favoriteState: boolean) {
|
||||
let encodedPath = encodePath(fileName)
|
||||
while (encodedPath[0] === '/') {
|
||||
encodedPath = encodedPath.substring(1)
|
||||
}
|
||||
let encodedPath = encodePath(fileName);
|
||||
while (encodedPath[0] === "/") {
|
||||
encodedPath = encodedPath.substring(1);
|
||||
}
|
||||
|
||||
try {
|
||||
return axios.post(
|
||||
`${generateUrl('/apps/files/api/v1/files/')}${encodedPath}`,
|
||||
{
|
||||
tags: favoriteState ? ['_$!<Favorite>!$_'] : [],
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to favorite', fileName, error)
|
||||
showError(t('memories', 'Failed to favorite {fileName}.', { fileName }))
|
||||
}
|
||||
try {
|
||||
return axios.post(
|
||||
`${generateUrl("/apps/files/api/v1/files/")}${encodedPath}`,
|
||||
{
|
||||
tags: favoriteState ? ["_$!<Favorite>!$_"] : [],
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to favorite", fileName, error);
|
||||
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
|
||||
* @returns generator of lists of file ids that were state-changed
|
||||
*/
|
||||
export async function* favoriteFilesByIds(fileIds: number[], favoriteState: boolean) {
|
||||
const fileIdsSet = new Set(fileIds);
|
||||
export async function* favoriteFilesByIds(
|
||||
fileIds: number[],
|
||||
favoriteState: boolean
|
||||
) {
|
||||
const fileIdsSet = new Set(fileIds);
|
||||
|
||||
if (fileIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (fileIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get files data
|
||||
let fileInfos: any[] = [];
|
||||
// Get files data
|
||||
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 {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// Favorite each file
|
||||
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);
|
||||
}
|
||||
yield* base.runInParallel(calls, 10);
|
||||
}
|
||||
|
|
|
@ -1,32 +1,37 @@
|
|||
import * as base from './base';
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import { genFileInfo } from '../FileUtils'
|
||||
import { IFileInfo } from '../../types';
|
||||
import client from '../DavClient';
|
||||
import * as base from "./base";
|
||||
import { getCurrentUser } from "@nextcloud/auth";
|
||||
import { genFileInfo } from "../FileUtils";
|
||||
import { IFileInfo } from "../../types";
|
||||
import client from "../DavClient";
|
||||
|
||||
/**
|
||||
* Get file infos for files in folder path
|
||||
* @param folderPath Path to folder
|
||||
* @param limit Max number of files to return
|
||||
*/
|
||||
export async function getFolderPreviewFileIds(folderPath: string, limit: number): Promise<IFileInfo[]> {
|
||||
const prefixPath = `/files/${getCurrentUser()!.uid}`;
|
||||
export async function getFolderPreviewFileIds(
|
||||
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:prop>
|
||||
<d:getcontenttype/>
|
||||
</d:prop>
|
||||
<d:literal>${mime}</d:literal>
|
||||
</d:like>
|
||||
`).join('');
|
||||
`
|
||||
).join("");
|
||||
|
||||
const options = {
|
||||
method: 'SEARCH',
|
||||
headers: {
|
||||
'content-Type': 'text/xml',
|
||||
},
|
||||
data: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
const options = {
|
||||
method: "SEARCH",
|
||||
headers: {
|
||||
"content-Type": "text/xml",
|
||||
},
|
||||
data: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:searchrequest xmlns:d="DAV:"
|
||||
xmlns:oc="http://owncloud.org/ns"
|
||||
xmlns:nc="http://nextcloud.org/ns"
|
||||
|
@ -54,16 +59,18 @@ export async function getFolderPreviewFileIds(folderPath: string, limit: number)
|
|||
</d:limit>
|
||||
</d:basicsearch>
|
||||
</d:searchrequest>`,
|
||||
deep: true,
|
||||
details: true,
|
||||
responseType: 'text',
|
||||
};
|
||||
deep: true,
|
||||
details: true,
|
||||
responseType: "text",
|
||||
};
|
||||
|
||||
let response:any = await client.getDirectoryContents('', options);
|
||||
return response.data
|
||||
.map((data: any) => genFileInfo(data))
|
||||
.map((data: any) => Object.assign({}, data, {
|
||||
filename: data.filename.replace(prefixPath, ''),
|
||||
etag: data.etag.replace(/"/g, ''), // remove quotes
|
||||
}));
|
||||
}
|
||||
let response: any = await client.getDirectoryContents("", options);
|
||||
return response.data
|
||||
.map((data: any) => genFileInfo(data))
|
||||
.map((data: any) =>
|
||||
Object.assign({}, data, {
|
||||
filename: data.filename.replace(prefixPath, ""),
|
||||
etag: data.etag.replace(/"/g, ""), // remove quotes
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,30 +1,32 @@
|
|||
import { generateUrl } from '@nextcloud/router'
|
||||
import { IDay, IPhoto } from '../../types';
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateUrl } from "@nextcloud/router";
|
||||
import { IDay, IPhoto } from "../../types";
|
||||
import axios from "@nextcloud/axios";
|
||||
|
||||
/**
|
||||
* Get original onThisDay response.
|
||||
*/
|
||||
export async function getOnThisDayRaw() {
|
||||
const dayIds: number[] = [];
|
||||
const now = new Date();
|
||||
const nowUTC = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
||||
const dayIds: number[] = [];
|
||||
const now = new Date();
|
||||
const nowUTC = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
||||
|
||||
// Populate dayIds
|
||||
for (let i = 1; i <= 120; i++) {
|
||||
// +- 3 days from this day
|
||||
for (let j = -3; j <= 3; j++) {
|
||||
const d = new Date(nowUTC);
|
||||
d.setFullYear(d.getFullYear() - i);
|
||||
d.setDate(d.getDate() + j);
|
||||
const dayId = Math.floor(d.getTime() / 1000 / 86400)
|
||||
dayIds.push(dayId);
|
||||
}
|
||||
// Populate dayIds
|
||||
for (let i = 1; i <= 120; i++) {
|
||||
// +- 3 days from this day
|
||||
for (let j = -3; j <= 3; j++) {
|
||||
const d = new Date(nowUTC);
|
||||
d.setFullYear(d.getFullYear() - i);
|
||||
d.setDate(d.getDate() + j);
|
||||
const dayId = Math.floor(d.getTime() / 1000 / 86400);
|
||||
dayIds.push(dayId);
|
||||
}
|
||||
}
|
||||
|
||||
return (await axios.post<IPhoto[]>(generateUrl('/apps/memories/api/days'), {
|
||||
body_ids: dayIds.join(','),
|
||||
})).data;
|
||||
return (
|
||||
await axios.post<IPhoto[]>(generateUrl("/apps/memories/api/days"), {
|
||||
body_ids: dayIds.join(","),
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -32,30 +34,30 @@ export async function getOnThisDayRaw() {
|
|||
* Query for last 120 years; should be enough
|
||||
*/
|
||||
export async function getOnThisDayData(): Promise<IDay[]> {
|
||||
// Query for photos
|
||||
let data = await getOnThisDayRaw();
|
||||
// Query for photos
|
||||
let data = await getOnThisDayRaw();
|
||||
|
||||
// Group photos by day
|
||||
const ans: IDay[] = [];
|
||||
let prevDayId = Number.MIN_SAFE_INTEGER;
|
||||
for (const photo of data) {
|
||||
if (!photo.dayid) continue;
|
||||
// Group photos by day
|
||||
const ans: IDay[] = [];
|
||||
let prevDayId = Number.MIN_SAFE_INTEGER;
|
||||
for (const photo of data) {
|
||||
if (!photo.dayid) continue;
|
||||
|
||||
// This works because the response is sorted by date taken
|
||||
if (photo.dayid !== prevDayId) {
|
||||
ans.push({
|
||||
dayid: photo.dayid,
|
||||
count: 0,
|
||||
detail: [],
|
||||
});
|
||||
prevDayId = photo.dayid;
|
||||
}
|
||||
|
||||
// Add to last day
|
||||
const day = ans[ans.length - 1];
|
||||
day.detail.push(photo);
|
||||
day.count++;
|
||||
// This works because the response is sorted by date taken
|
||||
if (photo.dayid !== prevDayId) {
|
||||
ans.push({
|
||||
dayid: photo.dayid,
|
||||
count: 0,
|
||||
detail: [],
|
||||
});
|
||||
prevDayId = photo.dayid;
|
||||
}
|
||||
|
||||
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 { IDay, IPhoto, ITag } from '../../types';
|
||||
import { constants, hashCode } from '../Utils';
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateUrl } from "@nextcloud/router";
|
||||
import { IDay, IPhoto, ITag } from "../../types";
|
||||
import { constants, hashCode } from "../Utils";
|
||||
import axios from "@nextcloud/axios";
|
||||
|
||||
/**
|
||||
* Get list of tags and convert to Days response
|
||||
*/
|
||||
export async function getTagsData(): Promise<IDay[]> {
|
||||
// Query for photos
|
||||
let data: {
|
||||
id: number;
|
||||
count: number;
|
||||
name: string;
|
||||
previews: IPhoto[];
|
||||
}[] = [];
|
||||
try {
|
||||
const res = await axios.get<typeof data>(generateUrl('/apps/memories/api/tags'));
|
||||
data = res.data;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
// Query for photos
|
||||
let data: {
|
||||
id: number;
|
||||
count: number;
|
||||
name: string;
|
||||
previews: IPhoto[];
|
||||
}[] = [];
|
||||
try {
|
||||
const res = await axios.get<typeof data>(
|
||||
generateUrl("/apps/memories/api/tags")
|
||||
);
|
||||
data = res.data;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Add flag to previews
|
||||
data.forEach(t => t.previews?.forEach((preview) => preview.flag = 0));
|
||||
// Add flag to previews
|
||||
data.forEach((t) => t.previews?.forEach((preview) => (preview.flag = 0)));
|
||||
|
||||
// Convert to days response
|
||||
return [{
|
||||
dayid: constants.TagDayID.TAGS,
|
||||
count: data.length,
|
||||
detail: data.map((tag) => ({
|
||||
// Convert to days response
|
||||
return [
|
||||
{
|
||||
dayid: constants.TagDayID.TAGS,
|
||||
count: data.length,
|
||||
detail: data.map(
|
||||
(tag) =>
|
||||
({
|
||||
...tag,
|
||||
fileid: hashCode(tag.name),
|
||||
flag: constants.c.FLAG_IS_TAG,
|
||||
istag: true,
|
||||
} as ITag)),
|
||||
}]
|
||||
}
|
||||
} as ITag)
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
328
src/types.ts
328
src/types.ts
|
@ -1,201 +1,201 @@
|
|||
import { VueConstructor } from "vue";
|
||||
|
||||
export type IFileInfo = {
|
||||
/** Database file ID */
|
||||
fileid: number;
|
||||
/** Full file name, e.g. /pi/test/Qx0dq7dvEXA.jpg */
|
||||
filename: string;
|
||||
/** Original file name, e.g. /files/admin/pi/test/Qx0dq7dvEXA.jpg */
|
||||
originalFilename: string;
|
||||
/** Base name of file e.g. Qx0dq7dvEXA.jpg */
|
||||
basename: string;
|
||||
/** Etag identifier */
|
||||
etag: string;
|
||||
/** File has preview available */
|
||||
hasPreview: boolean;
|
||||
/** File is marked favorite */
|
||||
favorite: boolean;
|
||||
/** Vue flags */
|
||||
flag?: number;
|
||||
}
|
||||
/** Database file ID */
|
||||
fileid: number;
|
||||
/** Full file name, e.g. /pi/test/Qx0dq7dvEXA.jpg */
|
||||
filename: string;
|
||||
/** Original file name, e.g. /files/admin/pi/test/Qx0dq7dvEXA.jpg */
|
||||
originalFilename: string;
|
||||
/** Base name of file e.g. Qx0dq7dvEXA.jpg */
|
||||
basename: string;
|
||||
/** Etag identifier */
|
||||
etag: string;
|
||||
/** File has preview available */
|
||||
hasPreview: boolean;
|
||||
/** File is marked favorite */
|
||||
favorite: boolean;
|
||||
/** Vue flags */
|
||||
flag?: number;
|
||||
};
|
||||
|
||||
export type IDay = {
|
||||
/** Day ID */
|
||||
dayid: number;
|
||||
/** Number of photos in this day */
|
||||
count: number;
|
||||
/** Rows in the day */
|
||||
rows?: IRow[];
|
||||
/** List of photos for this day */
|
||||
detail?: IPhoto[];
|
||||
}
|
||||
/** Day ID */
|
||||
dayid: number;
|
||||
/** Number of photos in this day */
|
||||
count: number;
|
||||
/** Rows in the day */
|
||||
rows?: IRow[];
|
||||
/** List of photos for this day */
|
||||
detail?: IPhoto[];
|
||||
};
|
||||
|
||||
export type IPhoto = {
|
||||
/** Nextcloud ID of file */
|
||||
fileid: number;
|
||||
/** Etag from server */
|
||||
etag?: string;
|
||||
/** Bit flags */
|
||||
flag: number;
|
||||
/** DayID from server */
|
||||
dayid?: number;
|
||||
/** Width of full image */
|
||||
w?: number;
|
||||
/** Height of full image */
|
||||
h?: number;
|
||||
/** Nextcloud ID of file */
|
||||
fileid: number;
|
||||
/** Etag from server */
|
||||
etag?: string;
|
||||
/** Bit flags */
|
||||
flag: number;
|
||||
/** DayID from server */
|
||||
dayid?: number;
|
||||
/** Width of full image */
|
||||
w?: number;
|
||||
/** Height of full image */
|
||||
h?: number;
|
||||
|
||||
/** Grid display width px */
|
||||
dispW?: number;
|
||||
/** Grid display height px */
|
||||
dispH?: number;
|
||||
/** Grid display X px */
|
||||
dispX?: number;
|
||||
/** Grid display Y px */
|
||||
dispY?: number;
|
||||
/** Grid display row id (relative to head) */
|
||||
dispRowNum?: number;
|
||||
/** Grid display width px */
|
||||
dispW?: number;
|
||||
/** Grid display height px */
|
||||
dispH?: number;
|
||||
/** Grid display X px */
|
||||
dispX?: number;
|
||||
/** Grid display Y px */
|
||||
dispY?: number;
|
||||
/** Grid display row id (relative to head) */
|
||||
dispRowNum?: number;
|
||||
|
||||
/** Reference to day object */
|
||||
d?: IDay;
|
||||
/** Reference to day object */
|
||||
d?: IDay;
|
||||
|
||||
/** Face dimensions */
|
||||
facerect?: IFaceRect;
|
||||
/** Face dimensions */
|
||||
facerect?: IFaceRect;
|
||||
|
||||
/** Video flag from server */
|
||||
isvideo?: boolean;
|
||||
/** Favorite flag from server */
|
||||
isfavorite?: boolean;
|
||||
/** Is this a folder */
|
||||
isfolder?: boolean;
|
||||
/** Is this a tag */
|
||||
istag?: boolean;
|
||||
/** Is this an album */
|
||||
isalbum?: boolean;
|
||||
/** Is this a face */
|
||||
isface?: boolean;
|
||||
/** Optional datetaken epoch */
|
||||
datetaken?: number;
|
||||
}
|
||||
/** Video flag from server */
|
||||
isvideo?: boolean;
|
||||
/** Favorite flag from server */
|
||||
isfavorite?: boolean;
|
||||
/** Is this a folder */
|
||||
isfolder?: boolean;
|
||||
/** Is this a tag */
|
||||
istag?: boolean;
|
||||
/** Is this an album */
|
||||
isalbum?: boolean;
|
||||
/** Is this a face */
|
||||
isface?: boolean;
|
||||
/** Optional datetaken epoch */
|
||||
datetaken?: number;
|
||||
};
|
||||
|
||||
export interface IFolder extends IPhoto {
|
||||
/** Path to folder */
|
||||
path: string;
|
||||
/** FileInfos for preview images */
|
||||
previewFileInfos?: IFileInfo[];
|
||||
/** Name of folder */
|
||||
name: string;
|
||||
/** Path to folder */
|
||||
path: string;
|
||||
/** FileInfos for preview images */
|
||||
previewFileInfos?: IFileInfo[];
|
||||
/** Name of folder */
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ITag extends IPhoto {
|
||||
/** Name of tag */
|
||||
name: string;
|
||||
/** Number of images in this tag */
|
||||
count: number;
|
||||
/** User for face if face */
|
||||
user_id?: string;
|
||||
/** Cache of previews */
|
||||
previews?: IPhoto[];
|
||||
/** Name of tag */
|
||||
name: string;
|
||||
/** Number of images in this tag */
|
||||
count: number;
|
||||
/** User for face if face */
|
||||
user_id?: string;
|
||||
/** Cache of previews */
|
||||
previews?: IPhoto[];
|
||||
}
|
||||
|
||||
export interface IAlbum extends ITag {
|
||||
/** ID of album */
|
||||
album_id: number;
|
||||
/** Owner of album */
|
||||
user: string;
|
||||
/** Created timestamp */
|
||||
created: number;
|
||||
/** Location string */
|
||||
location: string;
|
||||
/** File ID of last added photo */
|
||||
last_added_photo: number;
|
||||
/** ID of album */
|
||||
album_id: number;
|
||||
/** Owner of album */
|
||||
user: string;
|
||||
/** Created timestamp */
|
||||
created: number;
|
||||
/** Location string */
|
||||
location: string;
|
||||
/** File ID of last added photo */
|
||||
last_added_photo: number;
|
||||
}
|
||||
|
||||
export interface IFaceRect {
|
||||
w: number;
|
||||
h: number;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type IRow = {
|
||||
/** Vue Recycler identifier */
|
||||
id?: string;
|
||||
/** Row ID from head */
|
||||
num: number;
|
||||
/** Day ID */
|
||||
dayId: number;
|
||||
/** Refrence to day object */
|
||||
day: IDay;
|
||||
/** Whether this is a head row */
|
||||
type: IRowType;
|
||||
/** [Head only] Title of the header */
|
||||
name?: string;
|
||||
/** [Head only] Boolean if the entire day is selected */
|
||||
selected?: boolean;
|
||||
/** Main list of photo items */
|
||||
photos?: IPhoto[];
|
||||
/** Height in px of the row */
|
||||
size?: number;
|
||||
/** Count of placeholders to create */
|
||||
pct?: number;
|
||||
}
|
||||
/** Vue Recycler identifier */
|
||||
id?: string;
|
||||
/** Row ID from head */
|
||||
num: number;
|
||||
/** Day ID */
|
||||
dayId: number;
|
||||
/** Refrence to day object */
|
||||
day: IDay;
|
||||
/** Whether this is a head row */
|
||||
type: IRowType;
|
||||
/** [Head only] Title of the header */
|
||||
name?: string;
|
||||
/** [Head only] Boolean if the entire day is selected */
|
||||
selected?: boolean;
|
||||
/** Main list of photo items */
|
||||
photos?: IPhoto[];
|
||||
/** Height in px of the row */
|
||||
size?: number;
|
||||
/** Count of placeholders to create */
|
||||
pct?: number;
|
||||
};
|
||||
export type IHeadRow = IRow & {
|
||||
type: IRowType.HEAD;
|
||||
selected: boolean;
|
||||
super?: string;
|
||||
}
|
||||
type: IRowType.HEAD;
|
||||
selected: boolean;
|
||||
super?: string;
|
||||
};
|
||||
export enum IRowType {
|
||||
HEAD = 0,
|
||||
PHOTOS = 1,
|
||||
FOLDERS = 2,
|
||||
HEAD = 0,
|
||||
PHOTOS = 1,
|
||||
FOLDERS = 2,
|
||||
}
|
||||
|
||||
export type ITick = {
|
||||
/** Day ID */
|
||||
dayId: number;
|
||||
/** Display top position */
|
||||
topF: number;
|
||||
/** Display top position (truncated to 1 decimal pt) */
|
||||
top: number;
|
||||
/** Y coordinate on recycler */
|
||||
y: number;
|
||||
/** Cumulative number of photos before this tick */
|
||||
count: number;
|
||||
/** Is a new month */
|
||||
isMonth: boolean;
|
||||
/** Text if any (e.g. year) */
|
||||
text?: string | number;
|
||||
/** Whether this tick should be shown */
|
||||
s?: boolean;
|
||||
/** Key for vue component */
|
||||
key?: number
|
||||
}
|
||||
/** Day ID */
|
||||
dayId: number;
|
||||
/** Display top position */
|
||||
topF: number;
|
||||
/** Display top position (truncated to 1 decimal pt) */
|
||||
top: number;
|
||||
/** Y coordinate on recycler */
|
||||
y: number;
|
||||
/** Cumulative number of photos before this tick */
|
||||
count: number;
|
||||
/** Is a new month */
|
||||
isMonth: boolean;
|
||||
/** Text if any (e.g. year) */
|
||||
text?: string | number;
|
||||
/** Whether this tick should be shown */
|
||||
s?: boolean;
|
||||
/** Key for vue component */
|
||||
key?: number;
|
||||
};
|
||||
|
||||
export type TopMatter = {
|
||||
type: TopMatterType;
|
||||
}
|
||||
type: TopMatterType;
|
||||
};
|
||||
export enum TopMatterType {
|
||||
NONE = 0,
|
||||
FOLDER = 1,
|
||||
TAG = 2,
|
||||
FACE = 3,
|
||||
ALBUM = 4,
|
||||
NONE = 0,
|
||||
FOLDER = 1,
|
||||
TAG = 2,
|
||||
FACE = 3,
|
||||
ALBUM = 4,
|
||||
}
|
||||
export type TopMatterFolder = TopMatter & {
|
||||
type: TopMatterType.FOLDER;
|
||||
list: {
|
||||
text: string;
|
||||
path: string;
|
||||
}[];
|
||||
}
|
||||
type: TopMatterType.FOLDER;
|
||||
list: {
|
||||
text: string;
|
||||
path: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type ISelectionAction = {
|
||||
/** Display text */
|
||||
name: string;
|
||||
/** Icon component */
|
||||
icon: VueConstructor;
|
||||
/** Action to perform */
|
||||
callback: (selection: Map<number, IPhoto>) => Promise<void>;
|
||||
/** Condition to check for including */
|
||||
if?: (self?: any) => boolean;
|
||||
}
|
||||
/** Display text */
|
||||
name: string;
|
||||
/** Icon component */
|
||||
icon: VueConstructor;
|
||||
/** Action to perform */
|
||||
callback: (selection: Map<number, IPhoto>) => Promise<void>;
|
||||
/** Condition to check for including */
|
||||
if?: (self?: any) => boolean;
|
||||
};
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
declare module "*.vue" {
|
||||
import Vue from "vue"
|
||||
export default Vue
|
||||
import Vue from "vue";
|
||||
export default Vue;
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
import Vue, {VueConstructor} from 'vue';
|
||||
const content: VueConstructor<Vue>;
|
||||
export default content;
|
||||
}
|
||||
declare module "*.svg" {
|
||||
import Vue, { VueConstructor } from "vue";
|
||||
const content: VueConstructor<Vue>;
|
||||
export default content;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue