Make the code prettier

old-stable24^2
Varun Patil 2022-10-28 12:08:34 -07:00
parent 64b50deb40
commit fc6a4fc244
51 changed files with 7276 additions and 6467 deletions

View File

@ -1,221 +1,254 @@
<template> <template>
<FirstStart v-if="isFirstStart" /> <FirstStart v-if="isFirstStart" />
<NcContent app-name="memories" v-else :class="{ <NcContent
'remove-gap': removeOuterGap, app-name="memories"
}"> v-else
<NcAppNavigation> :class="{
<template id="app-memories-navigation" #list> 'remove-gap': removeOuterGap,
<NcAppNavigationItem :to="{name: 'timeline'}" }"
:title="t('memories', 'Timeline')" >
exact> <NcAppNavigation>
<ImageMultiple slot="icon" :size="20" /> <template id="app-memories-navigation" #list>
</NcAppNavigationItem> <NcAppNavigationItem
<NcAppNavigationItem :to="{name: 'folders'}" :to="{ name: 'timeline' }"
:title="t('memories', 'Folders')"> :title="t('memories', 'Timeline')"
<FolderIcon slot="icon" :size="20" /> exact
</NcAppNavigationItem> >
<NcAppNavigationItem :to="{name: 'favorites'}" <ImageMultiple slot="icon" :size="20" />
:title="t('memories', 'Favorites')"> </NcAppNavigationItem>
<Star slot="icon" :size="20" /> <NcAppNavigationItem
</NcAppNavigationItem> :to="{ name: 'folders' }"
<NcAppNavigationItem :to="{name: 'videos'}" :title="t('memories', 'Folders')"
:title="t('memories', 'Videos')"> >
<Video slot="icon" :size="20" /> <FolderIcon slot="icon" :size="20" />
</NcAppNavigationItem> </NcAppNavigationItem>
<NcAppNavigationItem :to="{name: 'albums'}" <NcAppNavigationItem
:title="t('memories', 'Albums')" v-if="showAlbums"> :to="{ name: 'favorites' }"
<AlbumIcon slot="icon" :size="20" /> :title="t('memories', 'Favorites')"
</NcAppNavigationItem> >
<NcAppNavigationItem :to="{name: 'people'}" <Star slot="icon" :size="20" />
:title="t('memories', 'People')" v-if="showPeople"> </NcAppNavigationItem>
<PeopleIcon slot="icon" :size="20" /> <NcAppNavigationItem
</NcAppNavigationItem> :to="{ name: 'videos' }"
<NcAppNavigationItem :to="{name: 'archive'}" :title="t('memories', 'Videos')"
:title="t('memories', 'Archive')"> >
<ArchiveIcon slot="icon" :size="20" /> <Video slot="icon" :size="20" />
</NcAppNavigationItem> </NcAppNavigationItem>
<NcAppNavigationItem :to="{name: 'thisday'}" <NcAppNavigationItem
:title="t('memories', 'On this day')"> :to="{ name: 'albums' }"
<CalendarIcon slot="icon" :size="20" /> :title="t('memories', 'Albums')"
</NcAppNavigationItem> v-if="showAlbums"
<NcAppNavigationItem :to="{name: 'tags'}" v-if="config_tagsEnabled" >
:title="t('memories', 'Tags')"> <AlbumIcon slot="icon" :size="20" />
<TagsIcon slot="icon" :size="20" /> </NcAppNavigationItem>
</NcAppNavigationItem> <NcAppNavigationItem
<NcAppNavigationItem :to="{name: 'maps'}" v-if="config_mapsEnabled" :to="{ name: 'people' }"
:title="t('memories', 'Maps')"> :title="t('memories', 'People')"
<MapIcon slot="icon" :size="20" /> v-if="showPeople"
</NcAppNavigationItem> >
</template> <PeopleIcon slot="icon" :size="20" />
<template #footer> </NcAppNavigationItem>
<NcAppNavigationSettings :title="t('memories', 'Settings')"> <NcAppNavigationItem
<Settings /> :to="{ name: 'archive' }"
</NcAppNavigationSettings> :title="t('memories', 'Archive')"
</template> >
</NcAppNavigation> <ArchiveIcon slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem
:to="{ name: 'thisday' }"
:title="t('memories', 'On this day')"
>
<CalendarIcon slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem
:to="{ name: 'tags' }"
v-if="config_tagsEnabled"
:title="t('memories', 'Tags')"
>
<TagsIcon slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem
:to="{ name: 'maps' }"
v-if="config_mapsEnabled"
:title="t('memories', 'Maps')"
>
<MapIcon slot="icon" :size="20" />
</NcAppNavigationItem>
</template>
<template #footer>
<NcAppNavigationSettings :title="t('memories', 'Settings')">
<Settings />
</NcAppNavigationSettings>
</template>
</NcAppNavigation>
<NcAppContent> <NcAppContent>
<div class="outer"> <div class="outer">
<router-view /> <router-view />
</div> </div>
</NcAppContent> </NcAppContent>
</NcContent> </NcContent>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Mixins } from 'vue-property-decorator'; import { Component, Mixins } from "vue-property-decorator";
import { import {
NcContent, NcAppContent, NcAppNavigation, NcContent,
NcAppNavigationItem, NcAppNavigationSettings, NcAppContent,
} from '@nextcloud/vue'; NcAppNavigation,
import { generateUrl } from '@nextcloud/router'; NcAppNavigationItem,
import { getCurrentUser } from '@nextcloud/auth'; NcAppNavigationSettings,
} from "@nextcloud/vue";
import { generateUrl } from "@nextcloud/router";
import { getCurrentUser } from "@nextcloud/auth";
import Timeline from './components/Timeline.vue' import Timeline from "./components/Timeline.vue";
import Settings from './components/Settings.vue' import Settings from "./components/Settings.vue";
import FirstStart from './components/FirstStart.vue' import FirstStart from "./components/FirstStart.vue";
import GlobalMixin from './mixins/GlobalMixin'; import GlobalMixin from "./mixins/GlobalMixin";
import UserConfig from './mixins/UserConfig'; import UserConfig from "./mixins/UserConfig";
import ImageMultiple from 'vue-material-design-icons/ImageMultiple.vue' import ImageMultiple from "vue-material-design-icons/ImageMultiple.vue";
import FolderIcon from 'vue-material-design-icons/Folder.vue' import FolderIcon from "vue-material-design-icons/Folder.vue";
import Star from 'vue-material-design-icons/Star.vue' import Star from "vue-material-design-icons/Star.vue";
import Video from 'vue-material-design-icons/Video.vue' import Video from "vue-material-design-icons/Video.vue";
import AlbumIcon from 'vue-material-design-icons/ImageAlbum.vue'; import AlbumIcon from "vue-material-design-icons/ImageAlbum.vue";
import ArchiveIcon from 'vue-material-design-icons/PackageDown.vue'; import ArchiveIcon from "vue-material-design-icons/PackageDown.vue";
import CalendarIcon from 'vue-material-design-icons/Calendar.vue'; import CalendarIcon from "vue-material-design-icons/Calendar.vue";
import PeopleIcon from 'vue-material-design-icons/AccountBoxMultiple.vue'; import PeopleIcon from "vue-material-design-icons/AccountBoxMultiple.vue";
import TagsIcon from 'vue-material-design-icons/Tag.vue'; import TagsIcon from "vue-material-design-icons/Tag.vue";
import MapIcon from 'vue-material-design-icons/Map.vue'; import MapIcon from "vue-material-design-icons/Map.vue";
@Component({ @Component({
components: { components: {
NcContent, NcContent,
NcAppContent, NcAppContent,
NcAppNavigation, NcAppNavigation,
NcAppNavigationItem, NcAppNavigationItem,
NcAppNavigationSettings, NcAppNavigationSettings,
Timeline, Timeline,
Settings, Settings,
FirstStart, FirstStart,
ImageMultiple, ImageMultiple,
FolderIcon, FolderIcon,
Star, Star,
Video, Video,
AlbumIcon, AlbumIcon,
ArchiveIcon, ArchiveIcon,
CalendarIcon, CalendarIcon,
PeopleIcon, PeopleIcon,
TagsIcon, TagsIcon,
MapIcon, MapIcon,
}, },
}) })
export default class App extends Mixins(GlobalMixin, UserConfig) { export default class App extends Mixins(GlobalMixin, UserConfig) {
// Outer element // Outer element
get ncVersion() { get ncVersion() {
const version = (<any>window.OC).config.version.split('.'); const version = (<any>window.OC).config.version.split(".");
return Number(version[0]); return Number(version[0]);
}
get showPeople() {
return this.config_recognizeEnabled || getCurrentUser()?.isAdmin;
}
get isFirstStart() {
return this.config_timelinePath === "EMPTY";
}
get showAlbums() {
return this.config_albumsEnabled;
}
get removeOuterGap() {
return this.ncVersion >= 25;
}
async beforeMount() {
if ("serviceWorker" in navigator) {
// Use the window load event to keep the page load performant
window.addEventListener("load", async () => {
try {
const url = generateUrl("/apps/memories/service-worker.js");
const registration = await navigator.serviceWorker.register(url, {
scope: generateUrl("/apps/memories"),
});
console.log("SW registered: ", registration);
} catch (error) {
console.error("SW registration failed: ", error);
}
});
} else {
console.debug("Service Worker is not enabled on this browser.");
} }
}
get showPeople() {
return this.config_recognizeEnabled || getCurrentUser()?.isAdmin;
}
get isFirstStart() {
return this.config_timelinePath === 'EMPTY';
}
get showAlbums() {
return this.config_albumsEnabled;
}
get removeOuterGap() {
return this.ncVersion >= 25;
}
async beforeMount() {
if ('serviceWorker' in navigator) {
// Use the window load event to keep the page load performant
window.addEventListener('load', async () => {
try {
const url = generateUrl('/apps/memories/service-worker.js');
const registration = await navigator.serviceWorker.register(url, { scope: generateUrl('/apps/memories') });
console.log('SW registered: ', registration);
} catch (error) {
console.error('SW registration failed: ', error);
}
})
} else {
console.debug('Service Worker is not enabled on this browser.')
}
}
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.outer { .outer {
padding: 0 0 0 44px; padding: 0 0 0 44px;
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.outer { .outer {
padding: 0px; padding: 0px;
// Get rid of padding on img-outer (1px on mobile) // Get rid of padding on img-outer (1px on mobile)
// Also need to make sure we don't end up with a scrollbar -- see below // Also need to make sure we don't end up with a scrollbar -- see below
margin-left: -1px; margin-left: -1px;
width: calc(100% + 3px); // 1px extra here because ... reasons width: calc(100% + 3px); // 1px extra here because ... reasons
} }
} }
</style> </style>
<style lang="scss"> <style lang="scss">
body { body {
overflow: hidden; overflow: hidden;
} }
// Nextcloud 25+: get rid of gap and border radius at right // Nextcloud 25+: get rid of gap and border radius at right
#content-vue.remove-gap { #content-vue.remove-gap {
// was var(--body-container-radius) // was var(--body-container-radius)
border-top-right-radius: 0; border-top-right-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
width: calc(100% - var(--body-container-margin)*1); // was *2 width: calc(100% - var(--body-container-margin) * 1); // was *2
} }
// Prevent content overflow on NC <25 // Prevent content overflow on NC <25
#content-vue { #content-vue {
max-height: 100vh; max-height: 100vh;
} }
// Patch viewer to remove the title and // Patch viewer to remove the title and
// make the image fill the entire screen // make the image fill the entire screen
.viewer { .viewer {
.modal-title { .modal-title {
display: none; display: none;
} }
.modal-wrapper .modal-container { .modal-wrapper .modal-container {
top: 0 !important; top: 0 !important;
bottom: 0 !important; bottom: 0 !important;
} }
} }
// Hide horizontal scrollbar on mobile // Hide horizontal scrollbar on mobile
// For the padding removal above // For the padding removal above
#app-content-vue { #app-content-vue {
overflow-x: hidden; overflow-x: hidden;
} }
// Fill all available space // Fill all available space
.fill-block { .fill-block {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: block;
} }
</style> </style>

View File

@ -1,176 +1,189 @@
<template> <template>
<NcContent app-name="memories"> <NcContent app-name="memories">
<NcAppContent> <NcAppContent>
<div class="outer fill-block" :class="{ show }"> <div class="outer fill-block" :class="{ show }">
<div class="title"> <div class="title">
<img :src="banner" /> <img :src="banner" />
</div> </div>
<div class="text"> <div class="text">
{{ t('memories', 'A better photos experience awaits you') }} <br/> {{ t("memories", "A better photos experience awaits you") }} <br />
{{ t('memories', 'Choose the root folder of your timeline to begin') }} {{
</div> t("memories", "Choose the root folder of your timeline to begin")
}}
</div>
<div class="admin-text" v-if="isAdmin"> <div class="admin-text" v-if="isAdmin">
{{ t('memories', 'If you just installed Memories, run:') }} {{ t("memories", "If you just installed Memories, run:") }}
<br/> <br />
<code>occ memories:index</code> <code>occ memories:index</code>
</div> </div>
<div class="error" v-if="error"> <div class="error" v-if="error">
{{ error }} {{ error }}
</div> </div>
<div class="info" v-if="info"> <div class="info" v-if="info">
{{ info }} <br/> {{ info }} <br />
<NcButton @click="finish" class="button" type="primary"> <NcButton @click="finish" class="button" type="primary">
{{ t('memories', 'Continue to Memories') }} {{ t("memories", "Continue to Memories") }}
</NcButton> </NcButton>
</div> </div>
<NcButton @click="begin" class="button" v-if="info"> <NcButton @click="begin" class="button" v-if="info">
{{ t('memories', 'Choose again') }} {{ t("memories", "Choose again") }}
</NcButton> </NcButton>
<NcButton @click="begin" class="button" type="primary" v-else> <NcButton @click="begin" class="button" type="primary" v-else>
{{ t('memories', 'Click here to start') }} {{ t("memories", "Click here to start") }}
</NcButton> </NcButton>
<div class="footer"> <div class="footer">
{{ t('memories', 'You can always change this later in settings') }} {{ t("memories", "You can always change this later in settings") }}
</div> </div>
</div> </div>
</NcAppContent> </NcAppContent>
</NcContent> </NcContent>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Mixins } from 'vue-property-decorator'; import { Component, Mixins } from "vue-property-decorator";
import { NcContent, NcAppContent, NcButton } from '@nextcloud/vue'; import { NcContent, NcAppContent, NcButton } from "@nextcloud/vue";
import { getFilePickerBuilder } from '@nextcloud/dialogs' import { getFilePickerBuilder } from "@nextcloud/dialogs";
import { generateUrl } from '@nextcloud/router' import { generateUrl } from "@nextcloud/router";
import { getCurrentUser } from '@nextcloud/auth'; import { getCurrentUser } from "@nextcloud/auth";
import axios from '@nextcloud/axios' import axios from "@nextcloud/axios";
import GlobalMixin from '../mixins/GlobalMixin'; import GlobalMixin from "../mixins/GlobalMixin";
import UserConfig from '../mixins/UserConfig'; import UserConfig from "../mixins/UserConfig";
import banner from "../assets/banner.svg"; import banner from "../assets/banner.svg";
import { IDay } from '../types'; import { IDay } from "../types";
@Component({ @Component({
components: { components: {
NcContent, NcContent,
NcAppContent, NcAppContent,
NcButton, NcButton,
}, },
}) })
export default class FirstStart extends Mixins(GlobalMixin, UserConfig) { export default class FirstStart extends Mixins(GlobalMixin, UserConfig) {
banner = banner; banner = banner;
error = ''; error = "";
info = '' info = "";
show = false; show = false;
chosenPath = ''; chosenPath = "";
mounted() { mounted() {
window.setTimeout(() => { window.setTimeout(() => {
this.show = true; this.show = true;
}, 300); }, 300);
}
get isAdmin() {
return getCurrentUser().isAdmin;
}
async begin() {
const path = await this.chooseFolder(
this.t("memories", "Choose the root of your timeline"),
"/"
);
// Get folder days
this.error = "";
this.info = "";
const query = new URLSearchParams();
query.set("timelinePath", path);
let url = generateUrl("/apps/memories/api/days?" + query.toString());
const res = await axios.get<IDay[]>(url);
// Check response
if (res.status !== 200) {
this.error = this.t(
"memories",
"The selected folder does not seem to be valid. Try again."
);
return;
} }
get isAdmin() { // Count total photos
return getCurrentUser().isAdmin; const total = res.data.reduce((acc, day) => acc + day.count, 0);
} this.info = this.t("memories", "Found {total} photos in {path}", {
total,
path,
});
this.chosenPath = path;
}
async begin() { async finish() {
const path = await this.chooseFolder(this.t('memories', 'Choose the root of your timeline'), '/'); this.show = false;
await new Promise((resolve) => setTimeout(resolve, 500));
this.config_timelinePath = this.chosenPath;
await this.updateSetting("timelinePath");
}
// Get folder days async chooseFolder(title: string, initial: string) {
this.error = ''; const picker = getFilePickerBuilder(title)
this.info = ''; .setMultiSelect(false)
const query = new URLSearchParams(); .setModal(true)
query.set('timelinePath', path); .setType(1)
let url = generateUrl('/apps/memories/api/days?' + query.toString()); .addMimeTypeFilter("httpd/unix-directory")
const res = await axios.get<IDay[]>(url); .allowDirectories()
.startAt(initial)
.build();
// Check response return await picker.pick();
if (res.status !== 200) { }
this.error = this.t('memories', 'The selected folder does not seem to be valid. Try again.');
return;
}
// Count total photos
const total = res.data.reduce((acc, day) => acc + day.count, 0);
this.info = this.t('memories', 'Found {total} photos in {path}', { total, path });
this.chosenPath = path;
}
async finish() {
this.show = false;
await new Promise(resolve => setTimeout(resolve, 500));
this.config_timelinePath = this.chosenPath;
await this.updateSetting('timelinePath');
}
async chooseFolder(title: string, initial: string) {
const picker = getFilePickerBuilder(title)
.setMultiSelect(false)
.setModal(true)
.setType(1)
.addMimeTypeFilter('httpd/unix-directory')
.allowDirectories()
.startAt(initial)
.build();
return await picker.pick();
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.outer { .outer {
padding: 20px; padding: 20px;
text-align: center; text-align: center;
transition: opacity 1s ease; transition: opacity 1s ease;
opacity: 0; opacity: 0;
&.show { opacity: 1; } &.show {
opacity: 1;
}
.title { .title {
font-size: 2.8em; font-size: 2.8em;
line-height: 1.1em; line-height: 1.1em;
font-family: cursive; font-family: cursive;
font-weight: 500; font-weight: 500;
margin-top: 10px; margin-top: 10px;
margin-bottom: 20px; margin-bottom: 20px;
width: 100%; width: 100%;
filter: var(--background-invert-if-dark); filter: var(--background-invert-if-dark);
> img { > img {
max-width: calc(100vw - 40px); max-width: calc(100vw - 40px);
}
} }
}
.admin-text { .admin-text {
margin-top: 10px; margin-top: 10px;
} }
.error { .error {
color: red; color: red;
} }
.info { .info {
margin-top: 10px; margin-top: 10px;
font-weight: bold; font-weight: bold;
} }
.button { .button {
display: inline-block; display: inline-block;
margin: 15px; margin: 15px;
} }
.footer { .footer {
font-size: 0.8em; font-size: 0.8em;
} }
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@ -1,444 +1,513 @@
<template> <template>
<div> <div>
<div v-if="selection.size > 0" class="top-bar"> <div v-if="selection.size > 0" class="top-bar">
<NcActions> <NcActions>
<NcActionButton <NcActionButton
:aria-label="t('memories', 'Cancel')" :aria-label="t('memories', 'Cancel')"
@click="clearSelection()"> @click="clearSelection()"
{{ t('memories', 'Cancel') }} >
<template #icon> <CloseIcon :size="20" /> </template> {{ t("memories", "Cancel") }}
</NcActionButton> <template #icon> <CloseIcon :size="20" /> </template>
</NcActions> </NcActionButton>
</NcActions>
<div class="text"> <div class="text">
{{ n("memories", "{n} selected", "{n} selected", selection.size, { n: selection.size }) }} {{
</div> n("memories", "{n} selected", "{n} selected", selection.size, {
n: selection.size,
})
}}
</div>
<NcActions :inline="1"> <NcActions :inline="1">
<NcActionButton v-for="action of getActions()" :key="action.name" <NcActionButton
:aria-label="action.name" close-after-click v-for="action of getActions()"
@click="click(action)"> :key="action.name"
{{ action.name }} :aria-label="action.name"
<template #icon> <component :is="action.icon" :size="20" /> </template> close-after-click
</NcActionButton> @click="click(action)"
</NcActions> >
</div> {{ action.name }}
<template #icon>
<!-- Selection Modals --> <component :is="action.icon" :size="20" />
<EditDate ref="editDate" @refresh="refresh" /> </template>
<FaceMoveModal ref="faceMoveModal" @moved="deletePhotos" :updateLoading="updateLoading" /> </NcActionButton>
<AddToAlbumModal ref="addToAlbumModal" @added="clearSelection" /> </NcActions>
</div> </div>
<!-- Selection Modals -->
<EditDate ref="editDate" @refresh="refresh" />
<FaceMoveModal
ref="faceMoveModal"
@moved="deletePhotos"
:updateLoading="updateLoading"
/>
<AddToAlbumModal ref="addToAlbumModal" @added="clearSelection" />
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator'; import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
import GlobalMixin from '../mixins/GlobalMixin'; import GlobalMixin from "../mixins/GlobalMixin";
import UserConfig from '../mixins/UserConfig'; import UserConfig from "../mixins/UserConfig";
import { showError } from '@nextcloud/dialogs' import { showError } from "@nextcloud/dialogs";
import { generateUrl } from '@nextcloud/router' import { generateUrl } from "@nextcloud/router";
import { NcActions, NcActionButton } from '@nextcloud/vue'; import { NcActions, NcActionButton } from "@nextcloud/vue";
import { translate as t, translatePlural as n } from '@nextcloud/l10n' import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import { IHeadRow, IPhoto, ISelectionAction } from '../types'; import { IHeadRow, IPhoto, ISelectionAction } from "../types";
import { getCurrentUser } from '@nextcloud/auth'; import { getCurrentUser } from "@nextcloud/auth";
import * as dav from "../services/DavRequests"; import * as dav from "../services/DavRequests";
import EditDate from "./modal/EditDate.vue" import EditDate from "./modal/EditDate.vue";
import FaceMoveModal from "./modal/FaceMoveModal.vue" import FaceMoveModal from "./modal/FaceMoveModal.vue";
import AddToAlbumModal from "./modal/AddToAlbumModal.vue" import AddToAlbumModal from "./modal/AddToAlbumModal.vue";
import StarIcon from 'vue-material-design-icons/Star.vue'; import StarIcon from "vue-material-design-icons/Star.vue";
import DownloadIcon from 'vue-material-design-icons/Download.vue'; import DownloadIcon from "vue-material-design-icons/Download.vue";
import DeleteIcon from 'vue-material-design-icons/Delete.vue'; import DeleteIcon from "vue-material-design-icons/Delete.vue";
import EditIcon from 'vue-material-design-icons/ClockEdit.vue'; import EditIcon from "vue-material-design-icons/ClockEdit.vue";
import ArchiveIcon from 'vue-material-design-icons/PackageDown.vue'; import ArchiveIcon from "vue-material-design-icons/PackageDown.vue";
import UnarchiveIcon from 'vue-material-design-icons/PackageUp.vue'; import UnarchiveIcon from "vue-material-design-icons/PackageUp.vue";
import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue'; import OpenInNewIcon from "vue-material-design-icons/OpenInNew.vue";
import CloseIcon from 'vue-material-design-icons/Close.vue'; import CloseIcon from "vue-material-design-icons/Close.vue";
import MoveIcon from 'vue-material-design-icons/ImageMove.vue'; import MoveIcon from "vue-material-design-icons/ImageMove.vue";
import AlbumsIcon from 'vue-material-design-icons/ImageAlbum.vue'; import AlbumsIcon from "vue-material-design-icons/ImageAlbum.vue";
import AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue'; import AlbumRemoveIcon from "vue-material-design-icons/BookRemove.vue";
type Selection = Map<number, IPhoto>; type Selection = Map<number, IPhoto>;
@Component({ @Component({
components: { components: {
NcActions, NcActions,
NcActionButton, NcActionButton,
EditDate, EditDate,
FaceMoveModal, FaceMoveModal,
AddToAlbumModal, AddToAlbumModal,
CloseIcon, CloseIcon,
}, },
}) })
export default class SelectionHandler extends Mixins(GlobalMixin, UserConfig) { export default class SelectionHandler extends Mixins(GlobalMixin, UserConfig) {
@Prop() public selection: Selection; @Prop() public selection: Selection;
@Prop() public heads: { [dayid: number]: IHeadRow }; @Prop() public heads: { [dayid: number]: IHeadRow };
private readonly defaultActions: ISelectionAction[]; private readonly defaultActions: ISelectionAction[];
@Emit('refresh') @Emit("refresh")
refresh() {} refresh() {}
@Emit('delete') @Emit("delete")
deletePhotos(photos: IPhoto[]) {} deletePhotos(photos: IPhoto[]) {}
@Emit('updateLoading') @Emit("updateLoading")
updateLoading(delta: number) {} updateLoading(delta: number) {}
constructor() { constructor() {
super(); super();
// Make default actions // Make default actions
this.defaultActions = [ this.defaultActions = [
{ // This is at the top because otherwise it is confusing {
name: t('memories', 'Remove from album'), // This is at the top because otherwise it is confusing
icon: AlbumRemoveIcon, name: t("memories", "Remove from album"),
callback: this.removeFromAlbum.bind(this), icon: AlbumRemoveIcon,
if: () => this.$route.name === 'albums', callback: this.removeFromAlbum.bind(this),
}, if: () => this.$route.name === "albums",
{ },
name: t('memories', 'Delete'), {
icon: DeleteIcon, name: t("memories", "Delete"),
callback: this.deleteSelection.bind(this), icon: DeleteIcon,
}, callback: this.deleteSelection.bind(this),
{ },
name: t('memories', 'Download'), {
icon: DownloadIcon, name: t("memories", "Download"),
callback: this.downloadSelection.bind(this), icon: DownloadIcon,
}, callback: this.downloadSelection.bind(this),
{ },
name: t('memories', 'Favorite'), {
icon: StarIcon, name: t("memories", "Favorite"),
callback: this.favoriteSelection.bind(this), icon: StarIcon,
}, callback: this.favoriteSelection.bind(this),
{ },
name: t('memories', 'Archive'), {
icon: ArchiveIcon, name: t("memories", "Archive"),
callback: this.archiveSelection.bind(this), icon: ArchiveIcon,
if: () => this.allowArchive() && !this.routeIsArchive(), callback: this.archiveSelection.bind(this),
}, if: () => this.allowArchive() && !this.routeIsArchive(),
{ },
name: t('memories', 'Unarchive'), {
icon: UnarchiveIcon, name: t("memories", "Unarchive"),
callback: this.archiveSelection.bind(this), icon: UnarchiveIcon,
if: () => this.allowArchive() && this.routeIsArchive(), callback: this.archiveSelection.bind(this),
}, if: () => this.allowArchive() && this.routeIsArchive(),
{ },
name: t('memories', 'Edit Date/Time'), {
icon: EditIcon, name: t("memories", "Edit Date/Time"),
callback: this.editDateSelection.bind(this), icon: EditIcon,
}, callback: this.editDateSelection.bind(this),
{ },
name: t('memories', 'View in folder'), {
icon: OpenInNewIcon, name: t("memories", "View in folder"),
callback: this.viewInFolder.bind(this), icon: OpenInNewIcon,
if: () => this.selection.size === 1, callback: this.viewInFolder.bind(this),
}, if: () => this.selection.size === 1,
{ },
name: t('memories', 'Add to album'), {
icon: AlbumsIcon, name: t("memories", "Add to album"),
callback: this.addToAlbum.bind(this), icon: AlbumsIcon,
if: (self: any) => self.config_albumsEnabled, callback: this.addToAlbum.bind(this),
}, if: (self: any) => self.config_albumsEnabled,
{ },
name: t('memories', 'Move to another person'), {
icon: MoveIcon, name: t("memories", "Move to another person"),
callback: this.moveSelectionToPerson.bind(this), icon: MoveIcon,
if: () => this.$route.name === 'people', callback: this.moveSelectionToPerson.bind(this),
}, if: () => this.$route.name === "people",
{ },
name: t('memories', 'Remove from person'), {
icon: CloseIcon, name: t("memories", "Remove from person"),
callback: this.removeSelectionFromPerson.bind(this), icon: CloseIcon,
if: () => this.$route.name === 'people', callback: this.removeSelectionFromPerson.bind(this),
}, if: () => this.$route.name === "people",
]; },
];
}
/** Click on an action */
private async click(action: ISelectionAction) {
try {
this.updateLoading(1);
await action.callback(this.selection);
} catch (error) {
console.error(error);
} finally {
this.updateLoading(-1);
} }
}
/** Click on an action */ /** Get the actions list */
private async click(action: ISelectionAction) { private getActions(): ISelectionAction[] {
try { return this.defaultActions.filter((a) => !a.if || a.if(this));
this.updateLoading(1); }
await action.callback(this.selection);
} catch (error) { /** Clear all selected photos */
console.error(error); public clearSelection(only?: IPhoto[]) {
} finally { const heads = new Set<IHeadRow>();
this.updateLoading(-1); const toClear = only || this.selection.values();
Array.from(toClear).forEach((photo: IPhoto) => {
photo.flag &= ~this.c.FLAG_SELECTED;
heads.add(this.heads[photo.d.dayid]);
this.selection.delete(photo.fileid);
});
heads.forEach(this.updateHeadSelected);
this.$forceUpdate();
}
/** Check if the day for a photo is selected entirely */
private updateHeadSelected(head: IHeadRow) {
let selected = true;
// Check if all photos are selected
for (const row of head.day.rows) {
for (const photo of row.photos) {
if (!(photo.flag & this.c.FLAG_SELECTED)) {
selected = false;
break;
} }
}
} }
/** Get the actions list */ // Update head
private getActions(): ISelectionAction[] { head.selected = selected;
return this.defaultActions.filter(a => !a.if || a.if(this)); }
/** Add a photo to selection list */
public selectPhoto(photo: IPhoto, val?: boolean, noUpdate?: boolean) {
if (
photo.flag & this.c.FLAG_PLACEHOLDER ||
photo.flag & this.c.FLAG_IS_FOLDER ||
photo.flag & this.c.FLAG_IS_TAG
) {
return; // ignore placeholders
} }
/** Clear all selected photos */ const nval = val ?? !this.selection.has(photo.fileid);
public clearSelection(only?: IPhoto[]) { if (nval) {
const heads = new Set<IHeadRow>(); photo.flag |= this.c.FLAG_SELECTED;
const toClear = only || this.selection.values(); this.selection.set(photo.fileid, photo);
Array.from(toClear).forEach((photo: IPhoto) => { } else {
photo.flag &= ~this.c.FLAG_SELECTED; photo.flag &= ~this.c.FLAG_SELECTED;
heads.add(this.heads[photo.d.dayid]); this.selection.delete(photo.fileid);
this.selection.delete(photo.fileid);
});
heads.forEach(this.updateHeadSelected);
this.$forceUpdate();
} }
/** Check if the day for a photo is selected entirely */ if (!noUpdate) {
private updateHeadSelected(head: IHeadRow) { this.updateHeadSelected(this.heads[photo.d.dayid]);
let selected = true; this.$forceUpdate();
}
}
// Check if all photos are selected /** Select or deselect all photos in a head */
for (const row of head.day.rows) { public selectHead(head: IHeadRow) {
for (const photo of row.photos) { head.selected = !head.selected;
if (!(photo.flag & this.c.FLAG_SELECTED)) { for (const row of head.day.rows) {
selected = false; for (const photo of row.photos) {
break; this.selectPhoto(photo, head.selected, true);
} }
} }
this.$forceUpdate();
}
/**
* Download the currently selected files
*/
private async downloadSelection(selection: Selection) {
if (selection.size >= 100) {
if (
!confirm(
this.t(
"memories",
"You are about to download a large number of files. Are you sure?"
)
)
) {
return;
}
}
await dav.downloadFilesByIds(Array.from(selection.keys()));
}
/**
* Check if all files selected currently are favorites
*/
private allSelectedFavorites(selection: Selection) {
return Array.from(selection.values()).every(
(p) => p.flag & this.c.FLAG_IS_FAVORITE
);
}
/**
* Favorite the currently selected photos
*/
private async favoriteSelection(selection: Selection) {
const val = !this.allSelectedFavorites(selection);
for await (const favIds of dav.favoriteFilesByIds(
Array.from(selection.keys()),
val
)) {
favIds.forEach((id) => {
const photo = selection.get(id);
if (!photo) {
return;
} }
// Update head if (val) {
head.selected = selected; photo.flag |= this.c.FLAG_IS_FAVORITE;
}
/** Add a photo to selection list */
public selectPhoto(photo: IPhoto, val?: boolean, noUpdate?: boolean) {
if (photo.flag & this.c.FLAG_PLACEHOLDER ||
photo.flag & this.c.FLAG_IS_FOLDER ||
photo.flag & this.c.FLAG_IS_TAG
) {
return; // ignore placeholders
}
const nval = val ?? !this.selection.has(photo.fileid);
if (nval) {
photo.flag |= this.c.FLAG_SELECTED;
this.selection.set(photo.fileid, photo);
} else { } else {
photo.flag &= ~this.c.FLAG_SELECTED; photo.flag &= ~this.c.FLAG_IS_FAVORITE;
this.selection.delete(photo.fileid);
} }
});
}
this.clearSelection();
}
if (!noUpdate) { /**
this.updateHeadSelected(this.heads[photo.d.dayid]); * Delete the currently selected photos
this.$forceUpdate(); */
} private async deleteSelection(selection: Selection) {
if (selection.size >= 100) {
if (
!confirm(
this.t(
"memories",
"You are about to delete a large number of files. Are you sure?"
)
)
) {
return;
}
} }
/** Select or deselect all photos in a head */ for await (const delIds of dav.deleteFilesByIds(
public selectHead(head: IHeadRow) { Array.from(selection.keys())
head.selected = !head.selected; )) {
for (const row of head.day.rows) { const delPhotos = delIds.map((id) => selection.get(id));
for (const photo of row.photos) { this.deletePhotos(delPhotos);
this.selectPhoto(photo, head.selected, true); }
} }
}
this.$forceUpdate(); /**
* Open the edit date dialog
*/
private async editDateSelection(selection: Selection) {
(<any>this.$refs.editDate).open(Array.from(selection.values()));
}
/**
* Open the files app with the selected file (one)
* Opens a new window.
*/
private async viewInFolder(selection: Selection) {
if (selection.size !== 1) return;
const photo: IPhoto = selection.values().next().value;
const f = await dav.getFiles([photo.fileid]);
if (f.length === 0) return;
const file = f[0];
const dirPath = file.filename.split("/").slice(0, -1).join("/");
const url = generateUrl(
`/apps/files/?dir=${dirPath}&scrollto=${file.fileid}&openfile=${file.fileid}`
);
window.open(url, "_blank");
}
/**
* Archive the currently selected photos
*/
private async archiveSelection(selection: Selection) {
if (selection.size >= 100) {
if (
!confirm(
this.t(
"memories",
"You are about to touch a large number of files. Are you sure?"
)
)
) {
return;
}
} }
/** for await (let delIds of dav.archiveFilesByIds(
* Download the currently selected files Array.from(selection.keys()),
*/ !this.routeIsArchive()
private async downloadSelection(selection: Selection) { )) {
if (selection.size >= 100) { delIds = delIds.filter((x) => x);
if (!confirm(this.t("memories", "You are about to download a large number of files. Are you sure?"))) { if (delIds.length === 0) {
return; continue;
} }
} const delPhotos = delIds.map((id) => selection.get(id));
await dav.downloadFilesByIds(Array.from(selection.keys())); this.deletePhotos(delPhotos);
}
}
/** Archive is not allowed only on folder routes */
private allowArchive() {
return this.$route.name !== "folders";
}
/** Is archive route */
private routeIsArchive() {
return this.$route.name === "archive";
}
/**
* Move selected photos to album
*/
private async addToAlbum(selection: Selection) {
(<any>this.$refs.addToAlbumModal).open(Array.from(selection.values()));
}
/**
* Remove selected photos from album
*/
private async removeFromAlbum(selection: Selection) {
try {
this.updateLoading(1);
const user = this.$route.params.user;
const name = this.$route.params.name;
const gen = dav.removeFromAlbum(user, name, Array.from(selection.keys()));
for await (const delIds of gen) {
const delPhotos = delIds
.filter((p) => p)
.map((id) => selection.get(id));
this.deletePhotos(delPhotos);
}
} catch (e) {
console.error(e);
showError(
e?.message || this.t("memories", "Could not remove photos from album")
);
} finally {
this.updateLoading(-1);
}
}
/**
* Move selected photos to another person
*/
private async moveSelectionToPerson(selection: Selection) {
if (!this.config_showFaceRect) {
showError(
this.t(
"memories",
'You must enable "Mark person in preview" to use this feature'
)
);
return;
}
(<any>this.$refs.faceMoveModal).open(Array.from(selection.values()));
}
/**
* Remove currently selected photos from person
*/
private async removeSelectionFromPerson(selection: Selection) {
// Make sure route is valid
const { user, name } = this.$route.params;
if (this.$route.name !== "people" || !user || !name) {
return;
} }
/** // Check photo ownership
* Check if all files selected currently are favorites if (this.$route.params.user !== getCurrentUser().uid) {
*/ showError(
private allSelectedFavorites(selection: Selection) { this.t("memories", 'Only user "{user}" can update this person', {
return Array.from(selection.values()).every(p => p.flag & this.c.FLAG_IS_FAVORITE); user,
})
);
return;
} }
/** // Run query
* Favorite the currently selected photos for await (let delIds of dav.removeFaceImages(
*/ user,
private async favoriteSelection(selection: Selection) { name,
const val = !this.allSelectedFavorites(selection); Array.from(selection.keys())
for await (const favIds of dav.favoriteFilesByIds(Array.from(selection.keys()), val)) { )) {
favIds.forEach(id => { const delPhotos = delIds.filter((x) => x).map((id) => selection.get(id));
const photo = selection.get(id); this.deletePhotos(delPhotos);
if (!photo) {
return;
}
if (val) {
photo.flag |= this.c.FLAG_IS_FAVORITE;
} else {
photo.flag &= ~this.c.FLAG_IS_FAVORITE;
}
});
}
this.clearSelection();
}
/**
* Delete the currently selected photos
*/
private async deleteSelection(selection: Selection) {
if (selection.size >= 100) {
if (!confirm(this.t("memories", "You are about to delete a large number of files. Are you sure?"))) {
return;
}
}
for await (const delIds of dav.deleteFilesByIds(Array.from(selection.keys()))) {
const delPhotos = delIds.map(id => selection.get(id));
this.deletePhotos(delPhotos);
}
}
/**
* Open the edit date dialog
*/
private async editDateSelection(selection: Selection) {
(<any>this.$refs.editDate).open(Array.from(selection.values()));
}
/**
* Open the files app with the selected file (one)
* Opens a new window.
*/
private async viewInFolder(selection: Selection) {
if (selection.size !== 1) return;
const photo: IPhoto = selection.values().next().value;
const f = await dav.getFiles([photo.fileid]);
if (f.length === 0) return;
const file = f[0];
const dirPath = file.filename.split('/').slice(0, -1).join('/')
const url = generateUrl(`/apps/files/?dir=${dirPath}&scrollto=${file.fileid}&openfile=${file.fileid}`);
window.open(url, '_blank');
}
/**
* Archive the currently selected photos
*/
private async archiveSelection(selection: Selection) {
if (selection.size >= 100) {
if (!confirm(this.t("memories", "You are about to touch a large number of files. Are you sure?"))) {
return;
}
}
for await (let delIds of dav.archiveFilesByIds(Array.from(selection.keys()), !this.routeIsArchive())) {
delIds = delIds.filter(x => x);
if (delIds.length === 0) {
continue
}
const delPhotos = delIds.map(id => selection.get(id));
this.deletePhotos(delPhotos);
}
}
/** Archive is not allowed only on folder routes */
private allowArchive() {
return this.$route.name !== 'folders';
}
/** Is archive route */
private routeIsArchive() {
return this.$route.name === 'archive';
}
/**
* Move selected photos to album
*/
private async addToAlbum(selection: Selection) {
(<any>this.$refs.addToAlbumModal).open(Array.from(selection.values()));
}
/**
* Remove selected photos from album
*/
private async removeFromAlbum(selection: Selection) {
try {
this.updateLoading(1);
const user = this.$route.params.user;
const name = this.$route.params.name;
const gen = dav.removeFromAlbum(user, name, Array.from(selection.keys()));
for await (const delIds of gen) {
const delPhotos = delIds.filter(p => p).map(id => selection.get(id));
this.deletePhotos(delPhotos);
}
} catch (e) {
console.error(e);
showError(e?.message || this.t("memories", "Could not remove photos from album"));
} finally {
this.updateLoading(-1);
}
}
/**
* Move selected photos to another person
*/
private async moveSelectionToPerson(selection: Selection) {
if (!this.config_showFaceRect) {
showError(this.t('memories', 'You must enable "Mark person in preview" to use this feature'));
return;
}
(<any>this.$refs.faceMoveModal).open(Array.from(selection.values()));
}
/**
* Remove currently selected photos from person
*/
private async removeSelectionFromPerson(selection: Selection) {
// Make sure route is valid
const { user, name } = this.$route.params;
if (this.$route.name !== "people" || !user || !name) {
return;
}
// Check photo ownership
if (this.$route.params.user !== getCurrentUser().uid) {
showError(this.t('memories', 'Only user "{user}" can update this person', { user }));
return;
}
// Run query
for await (let delIds of dav.removeFaceImages(user, name, Array.from(selection.keys()))) {
const delPhotos = delIds.filter(x => x).map(id => selection.get(id));
this.deletePhotos(delPhotos);
}
} }
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.top-bar { .top-bar {
position: absolute; position: absolute;
top: 10px; right: 60px; top: 10px;
padding: 8px; right: 60px;
width: 400px; padding: 8px;
max-width: calc(100vw - 30px); width: 400px;
background-color: var(--color-main-background); max-width: calc(100vw - 30px);
box-shadow: 0 0 2px gray; background-color: var(--color-main-background);
border-radius: 10px; box-shadow: 0 0 2px gray;
opacity: 0.95; border-radius: 10px;
display: flex; opacity: 0.95;
vertical-align: middle; display: flex;
z-index: 100; vertical-align: middle;
z-index: 100;
> .text { > .text {
flex-grow: 1; flex-grow: 1;
line-height: 40px; line-height: 40px;
padding-left: 8px; padding-left: 8px;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
top: 35px; right: 15px; top: 35px;
} right: 15px;
}
} }
</style> </style>

View File

@ -21,89 +21,103 @@
--> -->
<template> <template>
<div> <div>
<label for="timeline-path">{{ t('memories', 'Timeline Path') }}</label> <label for="timeline-path">{{ t("memories", "Timeline Path") }}</label>
<input id="timeline-path" <input
@click="chooseTimelinePath" id="timeline-path"
v-model="config_timelinePath" @click="chooseTimelinePath"
type="text"> v-model="config_timelinePath"
type="text"
/>
<label for="folders-path">{{ t('memories', 'Folders Path') }}</label> <label for="folders-path">{{ t("memories", "Folders Path") }}</label>
<input id="folders-path" <input
@click="chooseFoldersPath" id="folders-path"
v-model="config_foldersPath" @click="chooseFoldersPath"
type="text"> v-model="config_foldersPath"
type="text"
/>
<NcCheckboxRadioSwitch :checked.sync="config_showHidden" <NcCheckboxRadioSwitch
@update:checked="updateShowHidden" :checked.sync="config_showHidden"
type="switch"> @update:checked="updateShowHidden"
{{ t('memories', 'Show hidden folders') }} type="switch"
</NcCheckboxRadioSwitch> >
{{ t("memories", "Show hidden folders") }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch :checked.sync="config_squareThumbs" <NcCheckboxRadioSwitch
@update:checked="updateSquareThumbs" :checked.sync="config_squareThumbs"
type="switch"> @update:checked="updateSquareThumbs"
{{ t('memories', 'Square grid mode') }} type="switch"
</NcCheckboxRadioSwitch> >
</div> {{ t("memories", "Square grid mode") }}
</NcCheckboxRadioSwitch>
</div>
</template> </template>
<style scoped> <style scoped>
input[type=text] { input[type="text"] {
width: 100%; width: 100%;
} }
</style> </style>
<script lang="ts"> <script lang="ts">
import { Component, Mixins } from 'vue-property-decorator'; import { Component, Mixins } from "vue-property-decorator";
import GlobalMixin from '../mixins/GlobalMixin'; import GlobalMixin from "../mixins/GlobalMixin";
import { getFilePickerBuilder } from '@nextcloud/dialogs' import { getFilePickerBuilder } from "@nextcloud/dialogs";
import UserConfig from '../mixins/UserConfig' import UserConfig from "../mixins/UserConfig";
import { NcCheckboxRadioSwitch } from '@nextcloud/vue' import { NcCheckboxRadioSwitch } from "@nextcloud/vue";
@Component({ @Component({
components: { components: {
NcCheckboxRadioSwitch, NcCheckboxRadioSwitch,
}, },
}) })
export default class Settings extends Mixins(UserConfig, GlobalMixin) { export default class Settings extends Mixins(UserConfig, GlobalMixin) {
async chooseFolder(title: string, initial: string) { async chooseFolder(title: string, initial: string) {
const picker = getFilePickerBuilder(title) const picker = getFilePickerBuilder(title)
.setMultiSelect(false) .setMultiSelect(false)
.setModal(true) .setModal(true)
.setType(1) .setType(1)
.addMimeTypeFilter('httpd/unix-directory') .addMimeTypeFilter("httpd/unix-directory")
.allowDirectories() .allowDirectories()
.startAt(initial) .startAt(initial)
.build() .build();
return await picker.pick(); return await picker.pick();
} }
async chooseTimelinePath() { async chooseTimelinePath() {
const newPath = await this.chooseFolder(this.t('memories', 'Choose the root of your timeline'), this.config_timelinePath); const newPath = await this.chooseFolder(
if (newPath !== this.config_timelinePath) { this.t("memories", "Choose the root of your timeline"),
this.config_timelinePath = newPath; this.config_timelinePath
await this.updateSetting('timelinePath'); );
} if (newPath !== this.config_timelinePath) {
this.config_timelinePath = newPath;
await this.updateSetting("timelinePath");
} }
}
async chooseFoldersPath() { async chooseFoldersPath() {
const newPath = await this.chooseFolder(this.t('memories', 'Choose the root for the folders view'), this.config_foldersPath); const newPath = await this.chooseFolder(
if (newPath !== this.config_foldersPath) { this.t("memories", "Choose the root for the folders view"),
this.config_foldersPath = newPath; this.config_foldersPath
await this.updateSetting('foldersPath'); );
} if (newPath !== this.config_foldersPath) {
this.config_foldersPath = newPath;
await this.updateSetting("foldersPath");
} }
}
async updateSquareThumbs() { async updateSquareThumbs() {
await this.updateSetting('squareThumbs'); await this.updateSetting("squareThumbs");
} }
async updateShowHidden() { async updateShowHidden() {
await this.updateSetting('showHidden'); await this.updateSetting("showHidden");
} }
} }
</script> </script>

File diff suppressed because it is too large Load Diff

View File

@ -1,202 +1,238 @@
<template> <template>
<router-link class="folder fill-block" :class="{ <router-link
hasPreview: previewFileInfos.length > 0, class="folder fill-block"
onePreview: previewFileInfos.length === 1, :class="{
hasError: error, hasPreview: previewFileInfos.length > 0,
onePreview: previewFileInfos.length === 1,
hasError: error,
}" }"
:to="target"> :to="target"
<div class="big-icon fill-block"> >
<FolderIcon class="icon" /> <div class="big-icon fill-block">
<div class="name">{{ data.name }}</div> <FolderIcon class="icon" />
</div> <div class="name">{{ data.name }}</div>
</div>
<div class="previews fill-block"> <div class="previews fill-block">
<div class="img-outer" v-for="info of previewFileInfos" :key="info.fileid"> <div
<img class="img-outer"
class="fill-block" v-for="info of previewFileInfos"
:class="{ 'error': info.flag & c.FLAG_LOAD_FAIL }" :key="info.fileid"
:key="'fpreview-' + info.fileid" >
:src="getPreviewUrl(info.fileid, info.etag, true, 256)" <img
@error="info.flag |= c.FLAG_LOAD_FAIL" /> class="fill-block"
</div> :class="{ error: info.flag & c.FLAG_LOAD_FAIL }"
</div> :key="'fpreview-' + info.fileid"
</router-link> :src="getPreviewUrl(info.fileid, info.etag, true, 256)"
@error="info.flag |= c.FLAG_LOAD_FAIL"
/>
</div>
</div>
</router-link>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Watch, Mixins } from 'vue-property-decorator'; import { Component, Prop, Watch, Mixins } from "vue-property-decorator";
import { IFileInfo, IFolder } from '../../types'; import { IFileInfo, IFolder } from "../../types";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import UserConfig from '../../mixins/UserConfig'; import UserConfig from "../../mixins/UserConfig";
import * as dav from "../../services/DavRequests"; import * as dav from "../../services/DavRequests";
import { getPreviewUrl } from "../../services/FileUtils"; import { getPreviewUrl } from "../../services/FileUtils";
import FolderIcon from 'vue-material-design-icons/Folder.vue'; import FolderIcon from "vue-material-design-icons/Folder.vue";
@Component({ @Component({
components: { components: {
FolderIcon, FolderIcon,
}, },
}) })
export default class Folder extends Mixins(GlobalMixin, UserConfig) { export default class Folder extends Mixins(GlobalMixin, UserConfig) {
@Prop() data: IFolder; @Prop() data: IFolder;
// Separate property because the one on data isn't reactive // Separate property because the one on data isn't reactive
private previewFileInfos: IFileInfo[] = []; private previewFileInfos: IFileInfo[] = [];
// Error occured fetching thumbs // Error occured fetching thumbs
private error = false; private error = false;
/** Passthrough */ /** Passthrough */
private getPreviewUrl = getPreviewUrl; private getPreviewUrl = getPreviewUrl;
mounted() { mounted() {
this.refreshPreviews(); this.refreshPreviews();
}
@Watch("data")
dataChanged() {
this.refreshPreviews();
}
/** Refresh previews */
refreshPreviews() {
// Reset state
this.error = false;
// Check if valid path present
if (!this.data.path) {
this.error = true;
return;
} }
@Watch('data') // Get preview infos
dataChanged() { if (!this.data.previewFileInfos) {
this.refreshPreviews(); const folderPath = this.data.path.split("/").slice(3).join("/");
dav
.getFolderPreviewFileIds(folderPath, 4)
.then((fileInfos) => {
fileInfos = fileInfos.filter((f) => f.hasPreview);
fileInfos.forEach((f) => (f.flag = 0));
if (fileInfos.length > 0 && fileInfos.length < 4) {
fileInfos = [fileInfos[0]];
}
this.data.previewFileInfos = fileInfos;
this.previewFileInfos = fileInfos;
})
.catch(() => {
this.data.previewFileInfos = [];
this.previewFileInfos = [];
// Something is wrong with the folder
// e.g. external storage not available
this.error = true;
});
} else {
this.previewFileInfos = this.data.previewFileInfos;
}
}
/** Open folder */
get target() {
const path = this.data.path
.split("/")
.filter((x) => x)
.slice(2) as string[];
// Remove base path if present
const basePath = this.config_foldersPath.split("/").filter((x) => x);
if (
path.length >= basePath.length &&
path.slice(0, basePath.length).every((x, i) => x === basePath[i])
) {
path.splice(0, basePath.length);
} }
/** Refresh previews */ return { name: "folders", params: { path: path as any } };
refreshPreviews() { }
// Reset state
this.error = false;
// Check if valid path present
if (!this.data.path) {
this.error = true;
return;
}
// Get preview infos
if (!this.data.previewFileInfos) {
const folderPath = this.data.path.split('/').slice(3).join('/');
dav.getFolderPreviewFileIds(folderPath, 4).then(fileInfos => {
fileInfos = fileInfos.filter(f => f.hasPreview);
fileInfos.forEach(f => f.flag = 0);
if (fileInfos.length > 0 && fileInfos.length < 4) {
fileInfos = [fileInfos[0]];
}
this.data.previewFileInfos = fileInfos;
this.previewFileInfos = fileInfos;
}).catch(() => {
this.data.previewFileInfos = [];
this.previewFileInfos = [];
// Something is wrong with the folder
// e.g. external storage not available
this.error = true;
});
} else {
this.previewFileInfos = this.data.previewFileInfos;
}
}
/** Open folder */
get target() {
const path = this.data.path.split('/').filter(x => x).slice(2) as string[];
// Remove base path if present
const basePath = this.config_foldersPath.split('/').filter(x => x);
if (path.length >= basePath.length && path.slice(0, basePath.length).every((x, i) => x === basePath[i])) {
path.splice(0, basePath.length);
}
return { name: 'folders', params: { path: path as any }};
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.folder { .folder {
cursor: pointer; cursor: pointer;
} }
.big-icon { .big-icon {
cursor: pointer;
z-index: 100;
position: absolute;
top: 0;
left: 0;
transition: opacity 0.2s ease-in-out;
:deep .material-design-icon__svg {
width: 50%;
height: 50%;
}
> .name {
cursor: pointer; cursor: pointer;
z-index: 100; width: 100%;
padding: 0 5%;
text-align: center;
font-size: 1.08em;
word-wrap: break-word;
text-overflow: ellipsis;
max-height: 35%;
line-height: 1em;
position: absolute; position: absolute;
top: 0; left: 0; top: 65%;
transition: opacity 0.2s ease-in-out; }
:deep .material-design-icon__svg { // Make it white if there is a preview
width: 50%; height: 50%; .folder.hasPreview > & {
.folder-icon {
opacity: 1;
filter: invert(1) brightness(100);
} }
.name {
> .name { color: white;
cursor: pointer;
width: 100%;
padding: 0 5%;
text-align: center;
font-size: 1.08em;
word-wrap: break-word;
text-overflow: ellipsis;
max-height: 35%;
line-height: 1em;
position: absolute;
top: 65%;
} }
}
// Make it white if there is a preview // Show it on hover if not a preview
.folder.hasPreview > & { .folder:hover > & > .folder-icon {
.folder-icon { opacity: 0.8;
opacity: 1; }
filter: invert(1) brightness(100); .folder.hasPreview:hover > & {
} opacity: 0;
.name { color: white; } }
// Make it red if has an error
.folder.hasError > & {
.folder-icon {
filter: invert(12%) sepia(62%) saturate(5862%) hue-rotate(8deg)
brightness(103%) contrast(128%);
} }
.name {
// Show it on hover if not a preview color: #bb0000;
.folder:hover > & > .folder-icon { opacity: 0.8; }
.folder.hasPreview:hover > & { opacity: 0; }
// Make it red if has an error
.folder.hasError > & {
.folder-icon {
filter: invert(12%) sepia(62%) saturate(5862%) hue-rotate(8deg) brightness(103%) contrast(128%);
}
.name { color: #bb0000; }
} }
}
> .folder-icon { > .folder-icon {
cursor: pointer; cursor: pointer;
height: 90%; width: 100%; height: 90%;
opacity: 0.3; width: 100%;
} opacity: 0.3;
}
} }
.previews { .previews {
z-index: 3; z-index: 3;
line-height: 0; line-height: 0;
position: absolute; position: absolute;
padding: 2px; padding: 2px;
box-sizing: border-box; box-sizing: border-box;
@media (max-width: 768px) { padding: 1px; } @media (max-width: 768px) {
padding: 1px;
}
> .img-outer { > .img-outer {
background-color: var(--color-background-dark); background-color: var(--color-background-dark);
padding: 0; padding: 0;
margin: 0; margin: 0;
width: 50%; width: 50%;
height: 50%; height: 50%;
display: inline-block; display: inline-block;
.folder.onePreview > & { .folder.onePreview > & {
width: 100%; height: 100%; width: 100%;
} height: 100%;
> img {
object-fit: cover;
padding: 0;
filter: brightness(50%);
transition: filter 0.2s ease-in-out;
&.error { display: none; }
.folder:hover & { filter: brightness(100%); }
}
} }
> img {
object-fit: cover;
padding: 0;
filter: brightness(50%);
transition: filter 0.2s ease-in-out;
&.error {
display: none;
}
.folder:hover & {
filter: brightness(100%);
}
}
}
} }
</style> </style>

View File

@ -1,194 +1,210 @@
<template> <template>
<div class="p-outer fill-block" <div
:class="{ class="p-outer fill-block"
'selected': (data.flag & c.FLAG_SELECTED), :class="{
'placeholder': (data.flag & c.FLAG_PLACEHOLDER), selected: data.flag & c.FLAG_SELECTED,
'leaving': (data.flag & c.FLAG_LEAVING), placeholder: data.flag & c.FLAG_PLACEHOLDER,
'error': (data.flag & c.FLAG_LOAD_FAIL), leaving: data.flag & c.FLAG_LEAVING,
}"> error: data.flag & c.FLAG_LOAD_FAIL,
}"
>
<Check
:size="15"
class="select"
v-if="!(data.flag & c.FLAG_PLACEHOLDER)"
@click="toggleSelect"
/>
<Check :size="15" class="select" <Video :size="20" v-if="data.flag & c.FLAG_IS_VIDEO" />
v-if="!(data.flag & c.FLAG_PLACEHOLDER)" <Star :size="20" v-if="data.flag & c.FLAG_IS_FAVORITE" />
@click="toggleSelect" />
<Video :size="20" v-if="data.flag & c.FLAG_IS_VIDEO" /> <div
<Star :size="20" v-if="data.flag & c.FLAG_IS_FAVORITE" /> class="img-outer fill-block"
@click="emitClick"
<div class="img-outer fill-block" @contextmenu="contextmenu"
@click="emitClick" @touchstart="touchstart"
@contextmenu="contextmenu" @touchmove="touchend"
@touchstart="touchstart" @touchend="touchend"
@touchmove="touchend" @touchcancel="touchend"
@touchend="touchend" >
@touchcancel="touchend" > <img
<img ref="img"
ref="img" class="fill-block"
class="fill-block" :src="src"
:src="src" :key="data.fileid"
:key="data.fileid" @load="load"
@load="load" @error="error"
@error="error" /> />
</div>
</div> </div>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Emit, Mixins, Watch } from 'vue-property-decorator'; import { Component, Prop, Emit, Mixins, Watch } from "vue-property-decorator";
import { IDay, IPhoto } from "../../types"; import { IDay, IPhoto } from "../../types";
import { getPreviewUrl } from "../../services/FileUtils"; import { getPreviewUrl } from "../../services/FileUtils";
import errorsvg from "../../assets/error.svg"; import errorsvg from "../../assets/error.svg";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import Check from 'vue-material-design-icons/Check.vue'; import Check from "vue-material-design-icons/Check.vue";
import Video from 'vue-material-design-icons/Video.vue'; import Video from "vue-material-design-icons/Video.vue";
import Star from 'vue-material-design-icons/Star.vue'; import Star from "vue-material-design-icons/Star.vue";
@Component({ @Component({
components: { components: {
Check, Check,
Video, Video,
Star, Star,
}, },
}) })
export default class Photo extends Mixins(GlobalMixin) { export default class Photo extends Mixins(GlobalMixin) {
private touchTimer = 0; private touchTimer = 0;
private src = null; private src = null;
private hasFaceRect = false; private hasFaceRect = false;
@Prop() data: IPhoto; @Prop() data: IPhoto;
@Prop() day: IDay; @Prop() day: IDay;
@Emit('select') emitSelect(data: IPhoto) {} @Emit("select") emitSelect(data: IPhoto) {}
@Emit('click') emitClick() {} @Emit("click") emitClick() {}
@Watch('data') @Watch("data")
onDataChange(newData: IPhoto, oldData: IPhoto) { onDataChange(newData: IPhoto, oldData: IPhoto) {
// Copy flags relevant to this component // Copy flags relevant to this component
if (oldData && newData) { if (oldData && newData) {
newData.flag |= oldData.flag & (this.c.FLAG_SELECTED | this.c.FLAG_LOAD_FAIL); newData.flag |=
} oldData.flag & (this.c.FLAG_SELECTED | this.c.FLAG_LOAD_FAIL);
}
}
mounted() {
this.hasFaceRect = false;
this.refresh();
}
async refresh() {
this.src = await this.getSrc();
}
/** Get src for image to show */
async getSrc() {
if (this.data.flag & this.c.FLAG_PLACEHOLDER) {
return null;
} else if (this.data.flag & this.c.FLAG_LOAD_FAIL) {
return errorsvg;
} else {
return this.url();
}
}
/** Get url of the photo */
url() {
let base = 256;
// Check if displayed size is larger than the image
if (this.data.dispH > base * 0.9 && this.data.dispW > base * 0.9) {
// Get a bigger image
// 1. No trickery here, just get one size bigger. This is to
// ensure that the images can be cached even after reflow.
// 2. Nextcloud only allows 4**x sized images, so technically
// this ends up being equivalent to 1024x1024.
base = 512;
} }
mounted() { // Make the shorter dimension equal to base
this.hasFaceRect = false; let size = base;
this.refresh(); if (this.data.w && this.data.h) {
size =
Math.floor(
(base * Math.max(this.data.w, this.data.h)) /
Math.min(this.data.w, this.data.h)
) - 1;
} }
async refresh() { return getPreviewUrl(this.data.fileid, this.data.etag, false, size);
this.src = await this.getSrc(); }
}
/** Set src with overlay face rect */
/** Get src for image to show */ async addFaceRect() {
async getSrc() { if (!this.data.facerect || this.hasFaceRect) return;
if (this.data.flag & this.c.FLAG_PLACEHOLDER) { this.hasFaceRect = true;
return null;
} else if (this.data.flag & this.c.FLAG_LOAD_FAIL) { const canvas = document.createElement("canvas");
return errorsvg; const context = canvas.getContext("2d");
} else { const img = this.$refs.img as HTMLImageElement;
return this.url();
} canvas.width = img.naturalWidth;
} canvas.height = img.naturalHeight;
context.drawImage(img, 0, 0);
/** Get url of the photo */ context.strokeStyle = "#00ff00";
url() { context.lineWidth = 2;
let base = 256; context.strokeRect(
this.data.facerect.x * img.naturalWidth,
// Check if displayed size is larger than the image this.data.facerect.y * img.naturalHeight,
if (this.data.dispH > base * 0.9 && this.data.dispW > base * 0.9) { this.data.facerect.w * img.naturalWidth,
// Get a bigger image this.data.facerect.h * img.naturalHeight
// 1. No trickery here, just get one size bigger. This is to );
// ensure that the images can be cached even after reflow.
// 2. Nextcloud only allows 4**x sized images, so technically canvas.toBlob(
// this ends up being equivalent to 1024x1024. (blob) => {
base = 512; this.src = URL.createObjectURL(blob);
} },
"image/jpeg",
// Make the shorter dimension equal to base 0.95
let size = base; );
if (this.data.w && this.data.h) { }
size = Math.floor(base * Math.max(this.data.w, this.data.h) / Math.min(this.data.w, this.data.h)) - 1;
} /** Post load tasks */
load() {
return getPreviewUrl(this.data.fileid, this.data.etag, false, size) this.addFaceRect();
} }
/** Set src with overlay face rect */ /** Error in loading image */
async addFaceRect() { error(e: any) {
if (!this.data.facerect || this.hasFaceRect) return; this.data.flag |= this.c.FLAG_LOAD_FAIL;
this.hasFaceRect = true; this.refresh();
}
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d'); /** Clear timers */
const img = this.$refs.img as HTMLImageElement; beforeUnmount() {
clearTimeout(this.touchTimer);
canvas.width = img.naturalWidth; }
canvas.height = img.naturalHeight;
context.drawImage(img, 0, 0); toggleSelect() {
context.strokeStyle = '#00ff00'; if (this.data.flag & this.c.FLAG_PLACEHOLDER) return;
context.lineWidth = 2; this.emitSelect(this.data);
context.strokeRect( }
this.data.facerect.x * img.naturalWidth,
this.data.facerect.y * img.naturalHeight, touchstart() {
this.data.facerect.w * img.naturalWidth, this.touchTimer = window.setTimeout(() => {
this.data.facerect.h * img.naturalHeight, this.toggleSelect();
); this.touchTimer = 0;
}, 600);
canvas.toBlob((blob) => { }
this.src = URL.createObjectURL(blob);
}, 'image/jpeg', 0.95) contextmenu(e: Event) {
} e.preventDefault();
e.stopPropagation();
/** Post load tasks */ }
load() {
this.addFaceRect(); touchend() {
} if (this.touchTimer) {
clearTimeout(this.touchTimer);
/** Error in loading image */ this.touchTimer = 0;
error(e: any) {
this.data.flag |= this.c.FLAG_LOAD_FAIL;
this.refresh();
}
/** Clear timers */
beforeUnmount() {
clearTimeout(this.touchTimer);
}
toggleSelect() {
if (this.data.flag & this.c.FLAG_PLACEHOLDER) return;
this.emitSelect(this.data);
}
touchstart() {
this.touchTimer = window.setTimeout(() => {
this.toggleSelect();
this.touchTimer = 0;
}, 600);
}
contextmenu(e: Event) {
e.preventDefault();
e.stopPropagation();
}
touchend() {
if (this.touchTimer) {
clearTimeout(this.touchTimer);
this.touchTimer = 0;
}
} }
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
/* Container and selection */ /* Container and selection */
.p-outer { .p-outer {
&.leaving { &.leaving {
transition: all 0.2s ease-in; transition: all 0.2s ease-in;
transform: scale(0.9); transform: scale(0.9);
opacity: 0; opacity: 0;
} }
} }
// Distance of icon from border // Distance of icon from border
@ -196,56 +212,75 @@ $icon-dist: min(10px, 6%);
/* Extra icons */ /* Extra icons */
.check-icon.select { .check-icon.select {
position: absolute; position: absolute;
top: $icon-dist; left: $icon-dist; top: $icon-dist;
z-index: 100; left: $icon-dist;
background-color: var(--color-main-background); z-index: 100;
border-radius: 50%; background-color: var(--color-main-background);
cursor: pointer; border-radius: 50%;
display: none; cursor: pointer;
display: none;
.p-outer:hover > & { display: flex; } .p-outer:hover > & {
.selected > & { display: flex; filter: invert(1); } display: flex;
}
.selected > & {
display: flex;
filter: invert(1);
}
} }
.video-icon, .star-icon { .video-icon,
position: absolute; .star-icon {
z-index: 100; position: absolute;
pointer-events: none; z-index: 100;
filter: invert(1) brightness(100); pointer-events: none;
filter: invert(1) brightness(100);
} }
.video-icon { .video-icon {
top: $icon-dist; right: $icon-dist; top: $icon-dist;
right: $icon-dist;
} }
.star-icon { .star-icon {
bottom: $icon-dist; left: $icon-dist; bottom: $icon-dist;
left: $icon-dist;
} }
/* Actual image */ /* Actual image */
div.img-outer { div.img-outer {
padding: 2px; padding: 2px;
box-sizing: border-box; box-sizing: border-box;
@media (max-width: 768px) { padding: 1px; } @media (max-width: 768px) {
padding: 1px;
}
transition: padding 0.1s ease; transition: padding 0.1s ease;
background-clip: content-box, padding-box; background-clip: content-box, padding-box;
background-color: var(--color-background-dark); background-color: var(--color-background-dark);
.selected > & { padding: calc($icon-dist - 2px); } .selected > & {
padding: calc($icon-dist - 2px);
}
> img { > img {
filter: contrast(1.05); // most real world images are a bit overexposed filter: contrast(1.05); // most real world images are a bit overexposed
background-clip: content-box; background-clip: content-box;
object-fit: cover; object-fit: cover;
cursor: pointer; cursor: pointer;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none; -webkit-touch-callout: none;
user-select: none; user-select: none;
transition: box-shadow 0.1s ease; transition: box-shadow 0.1s ease;
.selected > & { box-shadow: 0 0 4px 2px var(--color-primary); } .selected > & {
.p-outer.placeholder > & { display: none; } box-shadow: 0 0 4px 2px var(--color-primary);
.p-outer.error & { object-fit: contain; }
} }
.p-outer.placeholder > & {
display: none;
}
.p-outer.error & {
object-fit: contain;
}
}
} }
</style> </style>

View File

@ -1,255 +1,276 @@
<template> <template>
<router-link class="tag fill-block" :class="{ <router-link
hasPreview: previews.length > 0, class="tag fill-block"
onePreview: previews.length === 1, :class="{
hasError: error, hasPreview: previews.length > 0,
isFace: isFace, onePreview: previews.length === 1,
hasError: error,
isFace: isFace,
}" }"
:to="target" :to="target"
@click.native="openTag(data)"> @click.native="openTag(data)"
>
<div class="bbl">
<NcCounterBubble> {{ data.count }} </NcCounterBubble>
</div>
<div class="name">
{{ data.name }}
<span class="subtitle" v-if="subtitle"> {{ subtitle }} </span>
</div>
<div class="bbl"> <NcCounterBubble> {{ data.count }} </NcCounterBubble> </div> <div class="previews fill-block" ref="previews">
<div class="name"> <div class="img-outer" v-for="info of previews" :key="info.fileid">
{{ data.name }} <img
<span class="subtitle" v-if="subtitle"> {{ subtitle }} </span> class="fill-block"
</div> :class="{ error: info.flag & c.FLAG_LOAD_FAIL }"
:key="'fpreview-' + info.fileid"
<div class="previews fill-block" ref="previews"> :src="getPreviewUrl(info.fileid, info.etag)"
<div class="img-outer" v-for="info of previews" :key="info.fileid"> @error="info.flag |= c.FLAG_LOAD_FAIL"
<img />
class="fill-block" </div>
:class="{ 'error': info.flag & c.FLAG_LOAD_FAIL }" </div>
:key="'fpreview-' + info.fileid" </router-link>
:src="getPreviewUrl(info.fileid, info.etag)"
@error="info.flag |= c.FLAG_LOAD_FAIL" />
</div>
</div>
</router-link>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Watch, Mixins, Emit } from 'vue-property-decorator'; import { Component, Prop, Watch, Mixins, Emit } from "vue-property-decorator";
import { IAlbum, IPhoto, ITag } from '../../types'; import { IAlbum, IPhoto, ITag } from "../../types";
import { generateUrl } from '@nextcloud/router' import { generateUrl } from "@nextcloud/router";
import { getPreviewUrl } from "../../services/FileUtils"; import { getPreviewUrl } from "../../services/FileUtils";
import { getCurrentUser } from '@nextcloud/auth'; import { getCurrentUser } from "@nextcloud/auth";
import { NcCounterBubble } from '@nextcloud/vue'; import { NcCounterBubble } from "@nextcloud/vue";
import axios from '@nextcloud/axios'; import axios from "@nextcloud/axios";
import * as utils from "../../services/Utils"; import * as utils from "../../services/Utils";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import { constants } from '../../services/Utils'; import { constants } from "../../services/Utils";
@Component({ @Component({
components: { components: {
NcCounterBubble, NcCounterBubble,
}, },
}) })
export default class Tag extends Mixins(GlobalMixin) { export default class Tag extends Mixins(GlobalMixin) {
@Prop() data: ITag; @Prop() data: ITag;
@Prop() noNavigate: boolean; @Prop() noNavigate: boolean;
// Separate property because the one on data isn't reactive // Separate property because the one on data isn't reactive
private previews: IPhoto[] = []; private previews: IPhoto[] = [];
// Error occured fetching thumbs // Error occured fetching thumbs
private error = false; private error = false;
// Smaller subtitle // Smaller subtitle
private subtitle = ''; private subtitle = "";
/** /**
* Open tag event * Open tag event
* Unless noNavigate is set, the tag will be opened * Unless noNavigate is set, the tag will be opened
*/ */
@Emit('open') @Emit("open")
openTag(tag: ITag) {} openTag(tag: ITag) {}
mounted() { mounted() {
this.refreshPreviews(); this.refreshPreviews();
}
@Watch("data")
dataChanged() {
this.refreshPreviews();
}
getPreviewUrl(fileid: number, etag: string) {
if (this.isFace) {
return generateUrl(
"/apps/memories/api/faces/preview/" + this.data.fileid
);
}
return getPreviewUrl(fileid, etag, true, 256);
}
get isFace() {
return this.data.flag & constants.c.FLAG_IS_FACE;
}
get isAlbum() {
return this.data.flag & constants.c.FLAG_IS_ALBUM;
}
async refreshPreviews() {
// Reset state
this.error = false;
this.subtitle = "";
// Add dummy preview if face
if (this.isFace) {
this.previews = [{ fileid: 0, etag: "", flag: 0 }];
return;
} }
@Watch('data') // Add preview from last photo if album
dataChanged() { if (this.isAlbum) {
this.refreshPreviews(); const album = this.data as IAlbum;
if (album.last_added_photo > 0) {
this.previews = [{ fileid: album.last_added_photo, etag: "", flag: 0 }];
}
if (album.user !== getCurrentUser()?.uid) {
this.subtitle = `(${album.user})`;
}
return;
} }
getPreviewUrl(fileid: number, etag: string) { // Look for previews
if (this.isFace) { if (!this.data.previews) {
return generateUrl('/apps/memories/api/faces/preview/' + this.data.fileid); try {
const todayDayId = utils.dateToDayId(new Date());
const url = generateUrl(
`/apps/memories/api/tag-previews?tag=${this.data.name}`
);
const cacheUrl = `${url}&today=${Math.floor(todayDayId / 10)}`;
const cache = await utils.getCachedData(cacheUrl);
if (cache) {
this.data.previews = cache as any;
} else {
const res = await axios.get(url);
this.data.previews = res.data;
// Cache only if >= 4 previews
if (this.data.previews.length >= 4) {
utils.cacheData(cacheUrl, res.data);
}
} }
return getPreviewUrl(fileid, etag, true, 256); } catch (e) {
this.error = true;
return;
}
} }
get isFace() { // Reset flag
return this.data.flag & constants.c.FLAG_IS_FACE; this.data.previews.forEach((p) => (p.flag = 0));
// Get 4 or 1 preview(s)
let data = this.data.previews;
if (data.length < 4) {
data = data.slice(0, 1);
}
this.previews = data;
this.error = this.previews.length === 0;
}
/** Target URL to navigate to */
get target() {
if (this.noNavigate) return {};
if (this.isFace) {
const name = this.data.name || this.data.fileid.toString();
const user = this.data.user_id;
return { name: "people", params: { name, user } };
} }
get isAlbum() { if (this.isAlbum) {
return this.data.flag & constants.c.FLAG_IS_ALBUM; const user = (<IAlbum>this.data).user;
const name = this.data.name;
return { name: "albums", params: { user, name } };
} }
async refreshPreviews() { return { name: "tags", params: { name: this.data.name } };
// Reset state }
this.error = false;
this.subtitle = '';
// Add dummy preview if face
if (this.isFace) {
this.previews = [{ fileid: 0, etag: '', flag: 0 }];
return;
}
// Add preview from last photo if album
if (this.isAlbum) {
const album = this.data as IAlbum;
if (album.last_added_photo > 0) {
this.previews = [{ fileid: album.last_added_photo, etag: '', flag: 0 }];
}
if (album.user !== getCurrentUser()?.uid) {
this.subtitle = `(${album.user})`;
}
return;
}
// Look for previews
if (!this.data.previews) {
try {
const todayDayId = utils.dateToDayId(new Date());
const url = generateUrl(`/apps/memories/api/tag-previews?tag=${this.data.name}`);
const cacheUrl = `${url}&today=${Math.floor(todayDayId / 10)}`;
const cache = await utils.getCachedData(cacheUrl);
if (cache) {
this.data.previews = cache as any;
} else {
const res = await axios.get(url);
this.data.previews = res.data;
// Cache only if >= 4 previews
if (this.data.previews.length >= 4) {
utils.cacheData(cacheUrl, res.data);
}
}
} catch (e) {
this.error = true;
return;
}
}
// Reset flag
this.data.previews.forEach((p) => p.flag = 0);
// Get 4 or 1 preview(s)
let data = this.data.previews;
if (data.length < 4) {
data = data.slice(0, 1);
}
this.previews = data;
this.error = this.previews.length === 0;
}
/** Target URL to navigate to */
get target() {
if (this.noNavigate) return {};
if (this.isFace) {
const name = this.data.name || this.data.fileid.toString();
const user = this.data.user_id;
return { name: 'people', params: { name, user }};
}
if (this.isAlbum) {
const user = (<IAlbum>this.data).user;
const name = this.data.name;
return { name: 'albums', params: { user, name }};
}
return { name: 'tags', params: { name: this.data.name }};
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.tag, .name, .bubble, img { .tag,
cursor: pointer; .name,
.bubble,
img {
cursor: pointer;
} }
// Get rid of color of the bubble // Get rid of color of the bubble
.tag .bbl :deep .counter-bubble__counter { .tag .bbl :deep .counter-bubble__counter {
color: unset !important; color: unset !important;
} }
.name { .name {
z-index: 100; z-index: 100;
position: absolute; position: absolute;
top: 50%; width: 100%; top: 50%;
transform: translateY(-50%); width: 100%;
color: white; transform: translateY(-50%);
padding: 0 5%; color: white;
text-align: center; padding: 0 5%;
font-size: 1.2em; text-align: center;
word-wrap: break-word; font-size: 1.2em;
text-overflow: ellipsis; word-wrap: break-word;
line-height: 1em; text-overflow: ellipsis;
line-height: 1em;
> .subtitle { > .subtitle {
font-size: 0.7em; font-size: 0.7em;
margin-top: 2px; margin-top: 2px;
display: block; display: block;
} }
.isFace > & { .isFace > & {
top: unset; top: unset;
bottom: 10%; bottom: 10%;
transform: unset; transform: unset;
} }
} }
.bbl { .bbl {
z-index: 100; z-index: 100;
position: absolute; position: absolute;
top: 6px; right: 5px; top: 6px;
right: 5px;
} }
.previews { .previews {
z-index: 3; z-index: 3;
line-height: 0; line-height: 0;
position: absolute; position: absolute;
padding: 2px; padding: 2px;
box-sizing: border-box; box-sizing: border-box;
@media (max-width: 768px) { padding: 1px; } @media (max-width: 768px) {
padding: 1px;
}
.tag:not(.hasPreview) & { .tag:not(.hasPreview) & {
background-color: #444; background-color: #444;
background-clip: content-box; background-clip: content-box;
}
> .img-outer {
background-color: var(--color-background-dark);
padding: 0;
margin: 0;
width: 50%;
height: 50%;
overflow: hidden;
display: inline-block;
cursor: pointer;
.tag.onePreview > & {
width: 100%;
height: 100%;
} }
> .img-outer { > img {
background-color: var(--color-background-dark); object-fit: cover;
padding: 0; padding: 0;
margin: 0; filter: brightness(60%);
width: 50%; cursor: pointer;
height: 50%; transition: filter 0.2s ease-in-out;
overflow: hidden;
display: inline-block;
cursor: pointer;
.tag.onePreview > & { &.error {
width: 100%; height: 100%; display: none;
} }
.tag:hover & {
> img { filter: brightness(100%);
object-fit: cover; }
padding: 0;
filter: brightness(60%);
cursor: pointer;
transition: filter 0.2s ease-in-out;
&.error { display: none; }
.tag:hover & { filter: brightness(100%); }
}
} }
}
} }
</style> </style>

View File

@ -1,84 +1,92 @@
<template> <template>
<Modal @close="close" size="normal" v-if="show"> <Modal @close="close" size="normal" v-if="show">
<template #title> <template #title>
{{ t('memories', 'Add to album') }} {{ t("memories", "Add to album") }}
</template> </template>
<div class="outer"> <div class="outer">
<AlbumPicker @select="selectAlbum" /> <AlbumPicker @select="selectAlbum" />
<div v-if="processing" class="info-pad"> <div v-if="processing" class="info-pad">
{{ t('memories', 'Processing … {n}/{m}', { {{
n: photosDone, t("memories", "Processing … {n}/{m}", {
m: photos.length, n: photosDone,
}) }} m: photos.length,
</div> })
</div> }}
</Modal> </div>
</div>
</Modal>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Emit, Mixins } from 'vue-property-decorator'; import { Component, Emit, Mixins } from "vue-property-decorator";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import * as dav from '../../services/DavRequests'; import * as dav from "../../services/DavRequests";
import { showInfo } from '@nextcloud/dialogs'; import { showInfo } from "@nextcloud/dialogs";
import { IAlbum, IPhoto } from '../../types'; import { IAlbum, IPhoto } from "../../types";
import AlbumPicker from './AlbumPicker.vue'; import AlbumPicker from "./AlbumPicker.vue";
import Modal from './Modal.vue'; import Modal from "./Modal.vue";
@Component({ @Component({
components: { components: {
Modal, Modal,
AlbumPicker, AlbumPicker,
} },
}) })
export default class AddToAlbumModal extends Mixins(GlobalMixin) { export default class AddToAlbumModal extends Mixins(GlobalMixin) {
private show = false; private show = false;
private photos: IPhoto[] = []; private photos: IPhoto[] = [];
private photosDone: number = 0; private photosDone: number = 0;
private processing: boolean = false; private processing: boolean = false;
public open(photos: IPhoto[]) { public open(photos: IPhoto[]) {
this.photosDone = 0; this.photosDone = 0;
this.processing = false; this.processing = false;
this.show = true; this.show = true;
this.photos = photos; this.photos = photos;
}
@Emit("added")
public added(photos: IPhoto[]) {}
@Emit("close")
public close() {
this.photos = [];
this.processing = false;
this.show = false;
}
public async selectAlbum(album: IAlbum) {
const name = album.name || album.album_id.toString();
const gen = dav.addToAlbum(
album.user,
name,
this.photos.map((p) => p.fileid)
);
this.processing = true;
for await (const fids of gen) {
this.photosDone += fids.filter((f) => f).length;
this.added(this.photos.filter((p) => fids.includes(p.fileid)));
} }
@Emit('added') showInfo(
public added(photos: IPhoto[]) {} this.t("memories", "{n} photos added to album", { n: this.photosDone })
);
@Emit('close') this.close();
public close() { }
this.photos = [];
this.processing = false;
this.show = false;
}
public async selectAlbum(album: IAlbum) {
const name = album.name || album.album_id.toString();
const gen = dav.addToAlbum(album.user, name, this.photos.map(p => p.fileid));
this.processing = true;
for await (const fids of gen) {
this.photosDone += fids.filter(f => f).length;
this.added(this.photos.filter(p => fids.includes(p.fileid)));
}
showInfo(this.t('memories', '{n} photos added to album', { n: this.photosDone }));
this.close();
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.outer { .outer {
margin-top: 15px; margin-top: 15px;
} }
.info-pad { .info-pad {
margin-top: 6px; margin-top: 6px;
} }
</style> </style>

View File

@ -20,452 +20,533 @@
- -
--> -->
<template> <template>
<div class="manage-collaborators"> <div class="manage-collaborators">
<div class="manage-collaborators__subtitle"> <div class="manage-collaborators__subtitle">
{{ t('photos', 'Add people or groups who can edit your album') }} {{ t("photos", "Add people or groups who can edit your album") }}
</div> </div>
<form class="manage-collaborators__form" @submit.prevent> <form class="manage-collaborators__form" @submit.prevent>
<NcPopover ref="popover" <NcPopover ref="popover" :auto-size="true" :distance="0">
:auto-size="true" <label slot="trigger" class="manage-collaborators__form__input">
:distance="0"> <NcTextField
<label slot="trigger" class="manage-collaborators__form__input"> :value.sync="searchText"
<NcTextField :value.sync="searchText" autocomplete="off"
autocomplete="off" type="search"
type="search" name="search"
name="search" :aria-label="t('photos', 'Search for collaborators')"
:aria-label="t('photos', 'Search for collaborators')" aria-autocomplete="list"
aria-autocomplete="list" :aria-controls="`manage-collaborators__form__selection-${randomId} manage-collaborators__form__list-${randomId}`"
:aria-controls="`manage-collaborators__form__selection-${randomId} manage-collaborators__form__list-${randomId}`" :placeholder="t('photos', 'Search people or groups')"
:placeholder="t('photos', 'Search people or groups')" @input="searchCollaborators"
@input="searchCollaborators"> >
<Magnify :size="16" /> <Magnify :size="16" />
</NcTextField> </NcTextField>
<NcLoadingIcon v-if="loadingCollaborators" /> <NcLoadingIcon v-if="loadingCollaborators" />
</label> </label>
<ul v-if="searchResults.length !== 0" :id="`manage-collaborators__form__list-${randomId}`" class="manage-collaborators__form__list"> <ul
<li v-for="collaboratorKey of searchResults" :key="collaboratorKey"> v-if="searchResults.length !== 0"
<NcListItemIcon :id="availableCollaborators[collaboratorKey].id" :id="`manage-collaborators__form__list-${randomId}`"
class="manage-collaborators__form__list__result" class="manage-collaborators__form__list"
:title="availableCollaborators[collaboratorKey].id" >
:search="searchText" <li v-for="collaboratorKey of searchResults" :key="collaboratorKey">
:user="availableCollaborators[collaboratorKey].id" <NcListItemIcon
:display-name="availableCollaborators[collaboratorKey].label" :id="availableCollaborators[collaboratorKey].id"
:aria-label="t('photos', 'Add {collaboratorLabel} to the collaborators list', {collaboratorLabel: availableCollaborators[collaboratorKey].label})" class="manage-collaborators__form__list__result"
@click="selectEntity(collaboratorKey)" /> :title="availableCollaborators[collaboratorKey].id"
</li> :search="searchText"
</ul> :user="availableCollaborators[collaboratorKey].id"
<NcEmptyContent v-else :display-name="availableCollaborators[collaboratorKey].label"
key="emptycontent" :aria-label="
class="manage-collaborators__form__list--empty" t(
:title="t('photos', 'No collaborators available')"> 'photos',
<AccountGroup slot="icon" /> 'Add {collaboratorLabel} to the collaborators list',
</NcEmptyContent> {
</NcPopover> collaboratorLabel:
</form> availableCollaborators[collaboratorKey].label,
}
)
"
@click="selectEntity(collaboratorKey)"
/>
</li>
</ul>
<NcEmptyContent
v-else
key="emptycontent"
class="manage-collaborators__form__list--empty"
:title="t('photos', 'No collaborators available')"
>
<AccountGroup slot="icon" />
</NcEmptyContent>
</NcPopover>
</form>
<ul class="manage-collaborators__selection"> <ul class="manage-collaborators__selection">
<li v-for="collaboratorKey of listableSelectedCollaboratorsKeys" <li
:key="collaboratorKey" v-for="collaboratorKey of listableSelectedCollaboratorsKeys"
class="manage-collaborators__selection__item"> :key="collaboratorKey"
<NcListItemIcon :id="availableCollaborators[collaboratorKey].id" class="manage-collaborators__selection__item"
:display-name="availableCollaborators[collaboratorKey].label" >
:title="availableCollaborators[collaboratorKey].id" <NcListItemIcon
:user="availableCollaborators[collaboratorKey].id"> :id="availableCollaborators[collaboratorKey].id"
<NcButton type="tertiary" :display-name="availableCollaborators[collaboratorKey].label"
:aria-label="t('photos', 'Remove {collaboratorLabel} from the collaborators list', {collaboratorLabel: availableCollaborators[collaboratorKey].label})" :title="availableCollaborators[collaboratorKey].id"
@click="unselectEntity(collaboratorKey)"> :user="availableCollaborators[collaboratorKey].id"
<Close slot="icon" :size="20" /> >
</NcButton> <NcButton
</NcListItemIcon> type="tertiary"
</li> :aria-label="
</ul> t(
'photos',
'Remove {collaboratorLabel} from the collaborators list',
{
collaboratorLabel:
availableCollaborators[collaboratorKey].label,
}
)
"
@click="unselectEntity(collaboratorKey)"
>
<Close slot="icon" :size="20" />
</NcButton>
</NcListItemIcon>
</li>
</ul>
<div class="actions"> <div class="actions">
<div v-if="allowPublicLink" class="actions__public-link"> <div v-if="allowPublicLink" class="actions__public-link">
<template v-if="isPublicLinkSelected"> <template v-if="isPublicLinkSelected">
<NcButton class="manage-collaborators__public-link-button" <NcButton
:aria-label="t('photos', 'Copy the public link')" class="manage-collaborators__public-link-button"
:disabled="publicLink.id === ''" :aria-label="t('photos', 'Copy the public link')"
@click="copyPublicLink"> :disabled="publicLink.id === ''"
<template v-if="publicLinkCopied"> @click="copyPublicLink"
{{ t('photos', 'Public link copied!') }} >
</template> <template v-if="publicLinkCopied">
<template v-else> {{ t("photos", "Public link copied!") }}
{{ t('photos', 'Copy public link') }} </template>
</template> <template v-else>
<template #icon> {{ t("photos", "Copy public link") }}
<Check v-if="publicLinkCopied" /> </template>
<ContentCopy v-else /> <template #icon>
</template> <Check v-if="publicLinkCopied" />
</NcButton> <ContentCopy v-else />
<NcButton type="tertiary" </template>
:aria-label="t('photos', 'Delete the public link')" </NcButton>
:disabled="publicLink.id === ''" <NcButton
@click="deletePublicLink"> type="tertiary"
<NcLoadingIcon v-if="publicLink.id === ''" slot="icon" /> :aria-label="t('photos', 'Delete the public link')"
<Close v-else slot="icon" /> :disabled="publicLink.id === ''"
</NcButton> @click="deletePublicLink"
</template> >
<NcButton v-else <NcLoadingIcon v-if="publicLink.id === ''" slot="icon" />
class="manage-collaborators__public-link-button" <Close v-else slot="icon" />
@click="createPublicLinkForAlbum"> </NcButton>
<Earth slot="icon" /> </template>
{{ t('photos', 'Share via public link') }} <NcButton
</NcButton> v-else
</div> class="manage-collaborators__public-link-button"
@click="createPublicLinkForAlbum"
>
<Earth slot="icon" />
{{ t("photos", "Share via public link") }}
</NcButton>
</div>
<div class="actions__slot"> <div class="actions__slot">
<slot :collaborators="selectedCollaborators" /> <slot :collaborators="selectedCollaborators" />
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Mixins, Prop, Watch } from 'vue-property-decorator'; import { Component, Mixins, Prop, Watch } from "vue-property-decorator";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import Magnify from 'vue-material-design-icons/Magnify.vue' import Magnify from "vue-material-design-icons/Magnify.vue";
import Close from 'vue-material-design-icons/Close.vue' import Close from "vue-material-design-icons/Close.vue";
import Check from 'vue-material-design-icons/Check.vue' import Check from "vue-material-design-icons/Check.vue";
import ContentCopy from 'vue-material-design-icons/ContentCopy.vue' import ContentCopy from "vue-material-design-icons/ContentCopy.vue";
import AccountGroup from 'vue-material-design-icons/AccountGroup.vue' import AccountGroup from "vue-material-design-icons/AccountGroup.vue";
import Earth from 'vue-material-design-icons/Earth.vue' import Earth from "vue-material-design-icons/Earth.vue";
import axios from '@nextcloud/axios' import axios from "@nextcloud/axios";
import * as dav from '../../services/DavRequests'; import * as dav from "../../services/DavRequests";
import { showError } from '@nextcloud/dialogs' import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from '@nextcloud/auth' import { getCurrentUser } from "@nextcloud/auth";
import { generateOcsUrl, generateUrl } from '@nextcloud/router' import { generateOcsUrl, generateUrl } from "@nextcloud/router";
import { NcButton, NcListItemIcon, NcLoadingIcon, NcPopover, NcTextField, NcEmptyContent } from '@nextcloud/vue' import {
NcButton,
NcListItemIcon,
NcLoadingIcon,
NcPopover,
NcTextField,
NcEmptyContent,
} from "@nextcloud/vue";
import { Type } from "@nextcloud/sharing"; import { Type } from "@nextcloud/sharing";
type Collaborator = { type Collaborator = {
id: string, id: string;
label: string, label: string;
type: Type, type: Type;
} };
@Component({ @Component({
components: { components: {
Magnify, Magnify,
Close, Close,
AccountGroup, AccountGroup,
ContentCopy, ContentCopy,
Check, Check,
Earth, Earth,
NcLoadingIcon, NcLoadingIcon,
NcButton, NcButton,
NcListItemIcon, NcListItemIcon,
NcTextField, NcTextField,
NcPopover, NcPopover,
NcEmptyContent, NcEmptyContent,
} },
}) })
export default class AddToAlbumModal extends Mixins(GlobalMixin) { export default class AddToAlbumModal extends Mixins(GlobalMixin) {
@Prop() private albumName: string;
@Prop() collaborators: Collaborator[];
@Prop() allowPublicLink: boolean;
@Prop() private albumName: string; private searchText = "";
@Prop() collaborators: Collaborator[]; private availableCollaborators: { [key: string]: Collaborator } = {};
@Prop() allowPublicLink: boolean; private selectedCollaboratorsKeys: string[] = [];
private currentSearchResults = [];
private loadingAlbum = false;
private errorFetchingAlbum = null;
private loadingCollaborators = false;
private errorFetchingCollaborators = null;
private randomId = Math.random().toString().substring(2, 10);
private publicLinkCopied = false;
private config = {
minSearchStringLength:
parseInt(window.OC.config["sharing.minSearchStringLength"], 10) || 0,
};
private searchText = ''; get searchResults(): string[] {
private availableCollaborators: { [key: string]: Collaborator } = {}; return this.currentSearchResults
private selectedCollaboratorsKeys: string[] = []; .filter(({ id }) => id !== getCurrentUser().uid)
private currentSearchResults = []; .map(({ type, id }) => `${type}:${id}`)
private loadingAlbum = false; .filter(
private errorFetchingAlbum = null; (collaboratorKey) =>
private loadingCollaborators = false; !this.selectedCollaboratorsKeys.includes(collaboratorKey)
private errorFetchingCollaborators = null; );
private randomId = Math.random().toString().substring(2, 10); }
private publicLinkCopied = false;
private config = { get listableSelectedCollaboratorsKeys(): string[] {
minSearchStringLength: parseInt(window.OC.config['sharing.minSearchStringLength'], 10) || 0, return this.selectedCollaboratorsKeys.filter(
(collaboratorKey) =>
this.availableCollaborators[collaboratorKey].type !==
Type.SHARE_TYPE_LINK
);
}
get selectedCollaborators(): Collaborator[] {
return this.selectedCollaboratorsKeys.map(
(collaboratorKey) => this.availableCollaborators[collaboratorKey]
);
}
get isPublicLinkSelected(): boolean {
return this.selectedCollaboratorsKeys.includes(`${Type.SHARE_TYPE_LINK}`);
}
get publicLink(): Collaborator {
return this.availableCollaborators[Type.SHARE_TYPE_LINK];
}
@Watch("collaborators")
collaboratorsChanged(collaborators) {
this.populateCollaborators(collaborators);
}
mounted() {
this.searchCollaborators();
this.populateCollaborators(this.collaborators);
}
/**
* Fetch possible collaborators.
*/
async searchCollaborators() {
if (this.searchText.length >= 1) {
(<any>this.$refs.popover).$refs.popover.show();
}
try {
if (this.searchText.length < this.config.minSearchStringLength) {
return;
}
this.loadingCollaborators = true;
const response = await axios.get(
generateOcsUrl("core/autocomplete/get"),
{
params: {
search: this.searchText,
itemType: "share-recipients",
shareTypes: [Type.SHARE_TYPE_USER, Type.SHARE_TYPE_GROUP],
},
}
);
this.currentSearchResults = response.data.ocs.data.map((collaborator) => {
switch (collaborator.source) {
case "users":
return {
id: collaborator.id,
label: collaborator.label,
type: Type.SHARE_TYPE_USER,
};
case "groups":
return {
id: collaborator.id,
label: collaborator.label,
type: Type.SHARE_TYPE_GROUP,
};
default:
throw new Error(
`Invalid collaborator source ${collaborator.source}`
);
}
});
this.availableCollaborators = {
...this.availableCollaborators,
...this.currentSearchResults.reduce(this.indexCollaborators, {}),
};
} catch (error) {
this.errorFetchingCollaborators = error;
showError(this.t("photos", "Failed to fetch collaborators list."));
} finally {
this.loadingCollaborators = false;
}
}
/**
* Populate selectedCollaboratorsKeys and availableCollaborators.
*/
populateCollaborators(collaborators: Collaborator[]) {
const initialCollaborators = collaborators.reduce(
this.indexCollaborators,
{}
);
this.selectedCollaboratorsKeys = Object.keys(initialCollaborators);
this.availableCollaborators = {
3: {
id: "",
label: this.t("photos", "Public link"),
type: Type.SHARE_TYPE_LINK,
},
...this.availableCollaborators,
...initialCollaborators,
}; };
}
get searchResults(): string[] { /**
return this.currentSearchResults * @param {Object<string, Collaborator>} collaborators - Index of collaborators
.filter(({ id }) => id !== getCurrentUser().uid) * @param {Collaborator} collaborator - A collaborator
.map(({ type, id }) => `${type}:${id}`) */
.filter(collaboratorKey => !this.selectedCollaboratorsKeys.includes(collaboratorKey)) indexCollaborators(
} collaborators: { [s: string]: Collaborator },
collaborator: Collaborator
get listableSelectedCollaboratorsKeys(): string[] { ) {
return this.selectedCollaboratorsKeys return {
.filter(collaboratorKey => this.availableCollaborators[collaboratorKey].type !== Type.SHARE_TYPE_LINK) ...collaborators,
} [`${collaborator.type}${
collaborator.type === Type.SHARE_TYPE_LINK ? "" : ":"
get selectedCollaborators(): Collaborator[] { }${collaborator.type === Type.SHARE_TYPE_LINK ? "" : collaborator.id}`]:
return this.selectedCollaboratorsKeys collaborator,
.map((collaboratorKey) => this.availableCollaborators[collaboratorKey])
}
get isPublicLinkSelected(): boolean {
return this.selectedCollaboratorsKeys.includes(`${Type.SHARE_TYPE_LINK}`)
}
get publicLink(): Collaborator {
return this.availableCollaborators[Type.SHARE_TYPE_LINK]
}
@Watch('collaborators')
collaboratorsChanged(collaborators) {
this.populateCollaborators(collaborators)
}; };
}
mounted() { async createPublicLinkForAlbum() {
this.searchCollaborators() this.selectEntity(`${Type.SHARE_TYPE_LINK}`);
this.populateCollaborators(this.collaborators) await this.updateAlbumCollaborators();
} try {
this.loadingAlbum = true;
this.errorFetchingAlbum = null;
} catch (error) {
if (error.response?.status === 404) {
this.errorFetchingAlbum = 404;
} else {
this.errorFetchingAlbum = error;
}
/** showError(this.t("photos", "Failed to fetch album."));
* Fetch possible collaborators. } finally {
*/ this.loadingAlbum = false;
async searchCollaborators() { }
if (this.searchText.length >= 1) { }
(<any>this.$refs.popover).$refs.popover.show()
}
try { async deletePublicLink() {
if (this.searchText.length < this.config.minSearchStringLength) { this.unselectEntity(`${Type.SHARE_TYPE_LINK}`);
return this.availableCollaborators[3] = {
} id: "",
label: this.t("photos", "Public link"),
type: Type.SHARE_TYPE_LINK,
};
this.publicLinkCopied = false;
await this.updateAlbumCollaborators();
}
this.loadingCollaborators = true async updateAlbumCollaborators() {
const response = await axios.get(generateOcsUrl('core/autocomplete/get'), { try {
params: { const album = await dav.getAlbum(
search: this.searchText, getCurrentUser()?.uid.toString(),
itemType: 'share-recipients', this.albumName
shareTypes: [ );
Type.SHARE_TYPE_USER, await dav.updateAlbum(album, {
Type.SHARE_TYPE_GROUP, albumName: this.albumName,
], properties: {
}, collaborators: this.selectedCollaborators,
}) },
});
} catch (error) {
showError(this.t("photos", "Failed to update album."));
} finally {
this.loadingAlbum = false;
}
}
this.currentSearchResults = response.data.ocs.data async copyPublicLink() {
.map(collaborator => { await navigator.clipboard.writeText(
switch (collaborator.source) { `${window.location.protocol}//${window.location.host}${generateUrl(
case 'users': `apps/photos/public/${this.publicLink.id}`
return { id: collaborator.id, label: collaborator.label, type: Type.SHARE_TYPE_USER } )}`
case 'groups': );
return { id: collaborator.id, label: collaborator.label, type: Type.SHARE_TYPE_GROUP } this.publicLinkCopied = true;
default: setTimeout(() => {
throw new Error(`Invalid collaborator source ${collaborator.source}`) this.publicLinkCopied = false;
} }, 10000);
}) }
this.availableCollaborators = { selectEntity(collaboratorKey) {
...this.availableCollaborators, if (this.selectedCollaboratorsKeys.includes(collaboratorKey)) {
...this.currentSearchResults.reduce(this.indexCollaborators, {}), return;
}
} catch (error) {
this.errorFetchingCollaborators = error
showError(this.t('photos', 'Failed to fetch collaborators list.'))
} finally {
this.loadingCollaborators = false
}
} }
/** (<any>this.$refs.popover).$refs.popover.hide();
* Populate selectedCollaboratorsKeys and availableCollaborators. this.selectedCollaboratorsKeys.push(collaboratorKey);
*/ }
populateCollaborators(collaborators: Collaborator[]) {
const initialCollaborators = collaborators.reduce(this.indexCollaborators, {}) unselectEntity(collaboratorKey) {
this.selectedCollaboratorsKeys = Object.keys(initialCollaborators) const index = this.selectedCollaboratorsKeys.indexOf(collaboratorKey);
this.availableCollaborators = {
3: { if (index === -1) {
id: '', return;
label: this.t('photos', 'Public link'),
type: Type.SHARE_TYPE_LINK,
},
...this.availableCollaborators,
...initialCollaborators,
}
} }
/** this.selectedCollaboratorsKeys.splice(index, 1);
* @param {Object<string, Collaborator>} collaborators - Index of collaborators }
* @param {Collaborator} collaborator - A collaborator
*/
indexCollaborators(collaborators: { [s: string]: Collaborator; }, collaborator: Collaborator) {
return { ...collaborators, [`${collaborator.type}${collaborator.type === Type.SHARE_TYPE_LINK ? '' : ':'}${collaborator.type === Type.SHARE_TYPE_LINK ? '' : collaborator.id}`]: collaborator }
}
async createPublicLinkForAlbum() {
this.selectEntity(`${Type.SHARE_TYPE_LINK}`)
await this.updateAlbumCollaborators()
try {
this.loadingAlbum = true
this.errorFetchingAlbum = null
} catch (error) {
if (error.response?.status === 404) {
this.errorFetchingAlbum = 404
} else {
this.errorFetchingAlbum = error
}
showError(this.t('photos', 'Failed to fetch album.'))
} finally {
this.loadingAlbum = false
}
}
async deletePublicLink() {
this.unselectEntity(`${Type.SHARE_TYPE_LINK}`)
this.availableCollaborators[3] = {
id: '',
label: this.t('photos', 'Public link'),
type: Type.SHARE_TYPE_LINK,
}
this.publicLinkCopied = false
await this.updateAlbumCollaborators()
}
async updateAlbumCollaborators() {
try {
const album = await dav.getAlbum(getCurrentUser()?.uid.toString(), this.albumName);
await dav.updateAlbum(album, {
albumName: this.albumName,
properties: {
collaborators: this.selectedCollaborators,
},
})
} catch (error) {
showError(this.t('photos', 'Failed to update album.'))
} finally {
this.loadingAlbum = false
}
}
async copyPublicLink() {
await navigator.clipboard.writeText(`${window.location.protocol}//${window.location.host}${generateUrl(`apps/photos/public/${this.publicLink.id}`)}`)
this.publicLinkCopied = true
setTimeout(() => {
this.publicLinkCopied = false
}, 10000)
}
selectEntity(collaboratorKey) {
if (this.selectedCollaboratorsKeys.includes(collaboratorKey)) {
return
}
(<any>this.$refs.popover).$refs.popover.hide()
this.selectedCollaboratorsKeys.push(collaboratorKey)
}
unselectEntity(collaboratorKey) {
const index = this.selectedCollaboratorsKeys.indexOf(collaboratorKey)
if (index === -1) {
return
}
this.selectedCollaboratorsKeys.splice(index, 1)
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.manage-collaborators { .manage-collaborators {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 500px; height: 500px;
&__title { &__title {
font-weight: bold; font-weight: bold;
} }
&__subtitle { &__subtitle {
color: var(--color-text-lighter); color: var(--color-text-lighter);
} }
&__public-link-button { &__public-link-button {
margin: 4px 0; margin: 4px 0;
} }
&__form { &__form {
margin-top: 4px 0; margin-top: 4px 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
&__input { &__input {
position: relative; position: relative;
display: block; display: block;
input { input {
width: 100%; width: 100%;
padding-left: 34px; padding-left: 34px;
} }
.loading-icon { .loading-icon {
position: absolute; position: absolute;
top: calc(36px / 2 - 20px / 2); top: calc(36px / 2 - 20px / 2);
right: 8px; right: 8px;
} }
} }
&__list { &__list {
padding: 8px; padding: 8px;
height: 350px; height: 350px;
overflow: scroll; overflow: scroll;
&__result { &__result {
padding: 8px; padding: 8px;
border-radius: 100px; border-radius: 100px;
box-sizing: border-box; box-sizing: border-box;
&, & * { &,
cursor: pointer !important; & * {
} cursor: pointer !important;
}
&:hover { &:hover {
background: var(--color-background-dark); background: var(--color-background-dark);
} }
} }
&--empty { &--empty {
margin: 100px 0; margin: 100px 0;
} }
} }
} }
&__selection { &__selection {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-top: 8px; margin-top: 8px;
flex-grow: 1; flex-grow: 1;
&__item { &__item {
border-radius: var(--border-radius-pill); border-radius: var(--border-radius-pill);
padding: 0 8px; padding: 0 8px;
&:hover { &:hover {
background: var(--color-background-dark); background: var(--color-background-dark);
} }
} }
} }
.actions { .actions {
display: flex; display: flex;
margin-top: 8px; margin-top: 8px;
&__public-link { &__public-link {
display: flex; display: flex;
align-items: center; align-items: center;
button { button {
margin-left: 8px; margin-left: 8px;
} }
} }
&__slot { &__slot {
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
} }
} }
} }
</style> </style>

View File

@ -1,87 +1,90 @@
<template> <template>
<Modal @close="close" size="normal" v-if="show"> <Modal @close="close" size="normal" v-if="show">
<template #title> <template #title>
<template v-if="!album"> <template v-if="!album">
{{ t('memories', 'Create new album') }} {{ t("memories", "Create new album") }}
</template> </template>
<template v-else> <template v-else>
{{ t('memories', 'Edit album details') }} {{ t("memories", "Edit album details") }}
</template> </template>
</template> </template>
<div class="outer"> <div class="outer">
<AlbumForm <AlbumForm
:album="album" :album="album"
:display-back-button="false" :display-back-button="false"
:title="t('photos', 'New album')" :title="t('photos', 'New album')"
@done="done" /> @done="done"
</div> />
</Modal> </div>
</Modal>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Emit, Mixins } from 'vue-property-decorator'; import { Component, Emit, Mixins } from "vue-property-decorator";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import { showError } from '@nextcloud/dialogs' import { showError } from "@nextcloud/dialogs";
import * as dav from '../../services/DavRequests'; import * as dav from "../../services/DavRequests";
import Modal from './Modal.vue'; import Modal from "./Modal.vue";
import AlbumForm from './AlbumForm.vue'; import AlbumForm from "./AlbumForm.vue";
@Component({ @Component({
components: { components: {
Modal, Modal,
AlbumForm, AlbumForm,
} },
}) })
export default class AlbumCreateModal extends Mixins(GlobalMixin) { export default class AlbumCreateModal extends Mixins(GlobalMixin) {
private show = false; private show = false;
private album: any = null; private album: any = null;
/**
* Open the modal
* @param edit If true, the modal will be opened in edit mode
*/
public async open(edit: boolean) {
if (edit) {
try {
this.album = await dav.getAlbum(this.$route.params.user, this.$route.params.name);
} catch (e) {
console.error(e);
showError(this.t('photos', 'Could not load the selected album'));
return;
}
} else {
this.album = null;
}
this.show = true;
/**
* Open the modal
* @param edit If true, the modal will be opened in edit mode
*/
public async open(edit: boolean) {
if (edit) {
try {
this.album = await dav.getAlbum(
this.$route.params.user,
this.$route.params.name
);
} catch (e) {
console.error(e);
showError(this.t("photos", "Could not load the selected album"));
return;
}
} else {
this.album = null;
} }
@Emit('close') this.show = true;
public close() { }
this.show = false;
}
public done({ album }: any) { @Emit("close")
if (!this.album || album.basename !== this.album.basename) { public close() {
const user = album.filename.split('/')[2]; this.show = false;
const name = album.basename; }
this.$router.push({ name: 'albums', params: { user, name } });
} public done({ album }: any) {
this.close(); if (!this.album || album.basename !== this.album.basename) {
const user = album.filename.split("/")[2];
const name = album.basename;
this.$router.push({ name: "albums", params: { user, name } });
} }
this.close();
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.outer { .outer {
margin-top: 15px; margin-top: 15px;
} }
.info-pad { .info-pad {
margin-top: 6px; margin-top: 6px;
} }
</style> </style>

View File

@ -1,81 +1,91 @@
<template> <template>
<Modal @close="close" v-if="show"> <Modal @close="close" v-if="show">
<template #title> <template #title>
{{ t('memories', 'Remove Album') }} {{ t("memories", "Remove Album") }}
</template> </template>
<span> <span>
{{ t('memories', 'Are you sure you want to permanently remove album "{name}"?', { name }) }} {{
</span> t(
"memories",
'Are you sure you want to permanently remove album "{name}"?',
{ name }
)
}}
</span>
<template #buttons> <template #buttons>
<NcButton @click="save" class="button" type="error"> <NcButton @click="save" class="button" type="error">
{{ t('memories', 'Delete') }} {{ t("memories", "Delete") }}
</NcButton> </NcButton>
</template> </template>
</Modal> </Modal>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Emit, Mixins, Watch } from 'vue-property-decorator'; import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
import { NcButton, NcTextField } from '@nextcloud/vue'; import { NcButton, NcTextField } from "@nextcloud/vue";
import { showError } from '@nextcloud/dialogs'; import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from '@nextcloud/auth'; import { getCurrentUser } from "@nextcloud/auth";
import Modal from './Modal.vue'; import Modal from "./Modal.vue";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import client from '../../services/DavClient'; import client from "../../services/DavClient";
@Component({ @Component({
components: { components: {
NcButton, NcButton,
NcTextField, NcTextField,
Modal, Modal,
} },
}) })
export default class AlbumDeleteModal extends Mixins(GlobalMixin) { export default class AlbumDeleteModal extends Mixins(GlobalMixin) {
private user: string = ""; private user: string = "";
private name: string = ""; private name: string = "";
private show = false; private show = false;
@Emit('close') @Emit("close")
public close() { public close() {
this.show = false; this.show = false;
} }
public open() { public open() {
const user = this.$route.params.user || ''; const user = this.$route.params.user || "";
if (this.$route.params.user !== getCurrentUser().uid) { if (this.$route.params.user !== getCurrentUser().uid) {
showError(this.t('memories', 'Only user "{user}" can delete this album', { user })); showError(
return; this.t("memories", 'Only user "{user}" can delete this album', { user })
} );
this.show = true; return;
} }
this.show = true;
}
@Watch('$route') @Watch("$route")
async routeChange(from: any, to: any) { async routeChange(from: any, to: any) {
this.refreshParams(); this.refreshParams();
} }
mounted() { mounted() {
this.refreshParams(); this.refreshParams();
} }
public refreshParams() { public refreshParams() {
this.user = this.$route.params.user || ''; this.user = this.$route.params.user || "";
this.name = this.$route.params.name || ''; this.name = this.$route.params.name || "";
} }
public async save() { public async save() {
try { try {
await client.deleteFile(`/photos/${this.user}/albums/${this.name}`) await client.deleteFile(`/photos/${this.user}/albums/${this.name}`);
this.$router.push({ name: 'albums' }); this.$router.push({ name: "albums" });
this.close(); this.close();
} catch (error) { } catch (error) {
console.log(error); console.log(error);
showError(this.t('photos', 'Failed to delete {name}.', { showError(
name: this.name, this.t("photos", "Failed to delete {name}.", {
})); name: this.name,
} })
);
} }
}
} }
</script> </script>

View File

@ -20,244 +20,274 @@
- -
--> -->
<template> <template>
<form v-if="!showCollaboratorView" class="album-form" @submit.prevent="submit"> <form
<div class="form-inputs"> v-if="!showCollaboratorView"
<NcTextField ref="nameInput" class="album-form"
:value.sync="albumName" @submit.prevent="submit"
type="text" >
name="name" <div class="form-inputs">
:required="true" <NcTextField
autofocus="true" ref="nameInput"
:placeholder="t('photos', 'Name of the album')" /> :value.sync="albumName"
<label> type="text"
<NcTextField :value.sync="albumLocation" name="name"
name="location" :required="true"
type="text" autofocus="true"
:placeholder="t('photos', 'Location of the album')" /> :placeholder="t('photos', 'Name of the album')"
</label> />
</div> <label>
<div class="form-buttons"> <NcTextField
<span class="left-buttons"> :value.sync="albumLocation"
<NcButton v-if="displayBackButton" name="location"
:aria-label="t('photos', 'Go back to the previous view.')" type="text"
type="tertiary" :placeholder="t('photos', 'Location of the album')"
@click="back"> />
{{ t('photos', 'Back') }} </label>
</NcButton> </div>
</span> <div class="form-buttons">
<span class="right-buttons"> <span class="left-buttons">
<NcButton v-if="sharingEnabled && !editMode" <NcButton
:aria-label="t('photos', 'Go to the add collaborators view.')" v-if="displayBackButton"
type="secondary" :aria-label="t('photos', 'Go back to the previous view.')"
:disabled="albumName.trim() === '' || loading" type="tertiary"
@click="showCollaboratorView = true"> @click="back"
<template #icon> >
<AccountMultiplePlus /> {{ t("photos", "Back") }}
</template> </NcButton>
{{ t('photos', 'Add collaborators') }} </span>
</NcButton> <span class="right-buttons">
<NcButton :aria-label="editMode ? t('photos', 'Save.') : t('photos', 'Create the album.')" <NcButton
type="primary" v-if="sharingEnabled && !editMode"
:disabled="albumName === '' || loading" :aria-label="t('photos', 'Go to the add collaborators view.')"
@click="submit()"> type="secondary"
<template #icon> :disabled="albumName.trim() === '' || loading"
<NcLoadingIcon v-if="loading" /> @click="showCollaboratorView = true"
<Send v-else /> >
</template> <template #icon>
{{ editMode ? t('photos', 'Save') : t('photos', 'Create album') }} <AccountMultiplePlus />
</NcButton> </template>
</span> {{ t("photos", "Add collaborators") }}
</div> </NcButton>
</form> <NcButton
<AlbumCollaborators v-else :aria-label="
:album-name="albumName" editMode ? t('photos', 'Save.') : t('photos', 'Create the album.')
:allow-public-link="false" "
:collaborators="[]"> type="primary"
<template slot-scope="{collaborators}"> :disabled="albumName === '' || loading"
<span class="left-buttons"> @click="submit()"
<NcButton :aria-label="t('photos', 'Back to the new album form.')" >
type="tertiary" <template #icon>
@click="showCollaboratorView = false"> <NcLoadingIcon v-if="loading" />
{{ t('photos', 'Back') }} <Send v-else />
</NcButton> </template>
</span> {{ editMode ? t("photos", "Save") : t("photos", "Create album") }}
<span class="right-buttons"> </NcButton>
<NcButton :aria-label="editMode ? t('photos', 'Save.') : t('photos', 'Create the album.')" </span>
type="primary" </div>
:disabled="albumName.trim() === '' || loading" </form>
@click="submit(collaborators)"> <AlbumCollaborators
<template #icon> v-else
<NcLoadingIcon v-if="loading" /> :album-name="albumName"
<Send v-else /> :allow-public-link="false"
</template> :collaborators="[]"
{{ editMode ? t('photos', 'Save') : t('photos', 'Create album') }} >
</NcButton> <template slot-scope="{ collaborators }">
</span> <span class="left-buttons">
</template> <NcButton
</AlbumCollaborators> :aria-label="t('photos', 'Back to the new album form.')"
type="tertiary"
@click="showCollaboratorView = false"
>
{{ t("photos", "Back") }}
</NcButton>
</span>
<span class="right-buttons">
<NcButton
:aria-label="
editMode ? t('photos', 'Save.') : t('photos', 'Create the album.')
"
type="primary"
:disabled="albumName.trim() === '' || loading"
@click="submit(collaborators)"
>
<template #icon>
<NcLoadingIcon v-if="loading" />
<Send v-else />
</template>
{{ editMode ? t("photos", "Save") : t("photos", "Create album") }}
</NcButton>
</span>
</template>
</AlbumCollaborators>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator'; import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import { getCurrentUser } from '@nextcloud/auth' import { getCurrentUser } from "@nextcloud/auth";
import { NcButton, NcLoadingIcon, NcTextField } from '@nextcloud/vue' import { NcButton, NcLoadingIcon, NcTextField } from "@nextcloud/vue";
import moment from 'moment'; import moment from "moment";
import * as dav from '../../services/DavRequests'; import * as dav from "../../services/DavRequests";
import AlbumCollaborators from './AlbumCollaborators.vue' import AlbumCollaborators from "./AlbumCollaborators.vue";
import Send from 'vue-material-design-icons/Send.vue'
import AccountMultiplePlus from 'vue-material-design-icons/AccountMultiplePlus.vue'
import Send from "vue-material-design-icons/Send.vue";
import AccountMultiplePlus from "vue-material-design-icons/AccountMultiplePlus.vue";
@Component({ @Component({
components: { components: {
NcButton, NcButton,
NcLoadingIcon, NcLoadingIcon,
NcTextField, NcTextField,
AlbumCollaborators, AlbumCollaborators,
Send, Send,
AccountMultiplePlus, AccountMultiplePlus,
}, },
}) })
export default class AlbumForm extends Mixins(GlobalMixin) { export default class AlbumForm extends Mixins(GlobalMixin) {
@Prop() private album: any; @Prop() private album: any;
@Prop() private displayBackButton: boolean; @Prop() private displayBackButton: boolean;
private showCollaboratorView = false; private showCollaboratorView = false;
private albumName = ''; private albumName = "";
private albumLocation = ''; private albumLocation = "";
private loading = false; private loading = false;
/** /**
* @return Whether sharing is enabled. * @return Whether sharing is enabled.
*/ */
get editMode(): boolean { get editMode(): boolean {
return Boolean(this.album); return Boolean(this.album);
}
/**
* @return Whether sharing is enabled.
*/
get sharingEnabled(): boolean {
return window.OC.Share !== undefined;
}
mounted() {
if (this.editMode) {
this.albumName = this.album.basename;
this.albumLocation = this.album.location;
} }
this.$nextTick(() => {
(<any>this.$refs.nameInput).$el.getElementsByTagName("input")[0].focus();
});
}
/** submit(collaborators = []) {
* @return Whether sharing is enabled. if (this.albumName === "" || this.loading) {
*/ return;
get sharingEnabled(): boolean {
return window.OC.Share !== undefined
} }
if (this.editMode) {
mounted() { this.handleUpdateAlbum();
if (this.editMode) { } else {
this.albumName = this.album.basename this.handleCreateAlbum(collaborators);
this.albumLocation = this.album.location
}
this.$nextTick(() => {
(<any>this.$refs.nameInput).$el.getElementsByTagName('input')[0].focus()
})
}
submit(collaborators = []) {
if (this.albumName === '' || this.loading) {
return
}
if (this.editMode) {
this.handleUpdateAlbum()
} else {
this.handleCreateAlbum(collaborators)
}
} }
}
async handleCreateAlbum(collaborators = []) { async handleCreateAlbum(collaborators = []) {
try { try {
this.loading = true this.loading = true;
let album = { let album = {
basename: this.albumName, basename: this.albumName,
filename: `/photos/${getCurrentUser().uid}/albums/${this.albumName}`, filename: `/photos/${getCurrentUser().uid}/albums/${this.albumName}`,
nbItems: 0, nbItems: 0,
location: this.albumLocation, location: this.albumLocation,
lastPhoto: -1, lastPhoto: -1,
date: moment().format('MMMM YYYY'), date: moment().format("MMMM YYYY"),
collaborators, collaborators,
} };
await dav.createAlbum(album.basename); await dav.createAlbum(album.basename);
if (this.albumLocation !== '' || collaborators.length !== 0) { if (this.albumLocation !== "" || collaborators.length !== 0) {
album = await dav.updateAlbum(album, { album = await dav.updateAlbum(album, {
albumName: this.albumName, albumName: this.albumName,
properties: { properties: {
location: this.albumLocation, location: this.albumLocation,
collaborators, collaborators,
}, },
}); });
} }
this.$emit('done', { album }) this.$emit("done", { album });
} finally { } finally {
this.loading = false this.loading = false;
}
} }
}
async handleUpdateAlbum() { async handleUpdateAlbum() {
try { try {
this.loading = true this.loading = true;
let album = { ...this.album } let album = { ...this.album };
if (this.album.basename !== this.albumName) { if (this.album.basename !== this.albumName) {
album = await dav.renameAlbum(this.album, { currentAlbumName: this.album.basename, newAlbumName: this.albumName }) album = await dav.renameAlbum(this.album, {
} currentAlbumName: this.album.basename,
if (this.album.location !== this.albumLocation) { newAlbumName: this.albumName,
album.location = await dav.updateAlbum(this.album, { albumName: this.albumName, properties: { location: this.albumLocation } }) });
} }
this.$emit('done', { album }) if (this.album.location !== this.albumLocation) {
} finally { album.location = await dav.updateAlbum(this.album, {
this.loading = false albumName: this.albumName,
} properties: { location: this.albumLocation },
});
}
this.$emit("done", { album });
} finally {
this.loading = false;
} }
}
@Emit('back') @Emit("back")
back() {} back() {}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.album-form { .album-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 350px; height: 350px;
padding: 16px; padding: 16px;
.form-title { .form-title {
font-weight: bold; font-weight: bold;
} }
.form-subtitle { .form-subtitle {
color: var(--color-text-lighter); color: var(--color-text-lighter);
} }
.form-inputs { .form-inputs {
flex-grow: 1; flex-grow: 1;
justify-items: flex-end; justify-items: flex-end;
input { input {
width: 100%; width: 100%;
} }
label { label {
display: flex; display: flex;
margin-top: 16px; margin-top: 16px;
:deep svg { :deep svg {
margin-right: 12px; margin-right: 12px;
} }
} }
} }
.form-buttons { .form-buttons {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
.left-buttons, .right-buttons { .left-buttons,
display: flex; .right-buttons {
} display: flex;
.right-buttons { }
justify-content: flex-end; .right-buttons {
} justify-content: flex-end;
button { }
margin-right: 16px; button {
} margin-right: 16px;
} }
}
} }
.left-buttons { .left-buttons {
flex-grow: 1; flex-grow: 1;
} }
</style> </style>

View File

@ -20,168 +20,185 @@
- -
--> -->
<template> <template>
<div v-if="!showAlbumCreationForm" class="album-picker"> <div v-if="!showAlbumCreationForm" class="album-picker">
<NcLoadingIcon v-if="loadingAlbums" class="loading-icon" /> <NcLoadingIcon v-if="loadingAlbums" class="loading-icon" />
<ul class="albums-container"> <ul class="albums-container">
<NcListItem v-for="album in albums" <NcListItem
:key="album.album_id" v-for="album in albums"
class="album" :key="album.album_id"
:title="getAlbumName(album)" class="album"
:aria-label="t('photos', 'Add selection to album {albumName}', {albumName: getAlbumName(album)})" :title="getAlbumName(album)"
@click="pickAlbum(album)"> :aria-label="
<template slot="icon"> t('photos', 'Add selection to album {albumName}', {
<img v-if="album.last_added_photo !== -1" class="album__image" :src="album.last_added_photo | toCoverUrl"> albumName: getAlbumName(album),
<div v-else class="album__image album__image--placeholder"> })
<ImageMultiple :size="32" /> "
</div> @click="pickAlbum(album)"
</template> >
<template slot="icon">
<img
v-if="album.last_added_photo !== -1"
class="album__image"
:src="album.last_added_photo | toCoverUrl"
/>
<div v-else class="album__image album__image--placeholder">
<ImageMultiple :size="32" />
</div>
</template>
<template slot="subtitle"> <template slot="subtitle">
{{ n('photos', '%n item', '%n items', album.count) }} {{ n("photos", "%n item", "%n items", album.count) }}
<!-- TODO: finish collaboration --> <!-- TODO: finish collaboration -->
<!-- {{ n('photos', 'Share with %n user', 'Share with %n users', album.isShared) }}--> <!-- {{ n('photos', 'Share with %n user', 'Share with %n users', album.isShared) }}-->
</template> </template>
</NcListItem> </NcListItem>
</ul> </ul>
<NcButton :aria-label="t('photos', 'Create a new album.')" <NcButton
class="new-album-button" :aria-label="t('photos', 'Create a new album.')"
type="tertiary" class="new-album-button"
@click="showAlbumCreationForm = true"> type="tertiary"
<template #icon> @click="showAlbumCreationForm = true"
<Plus /> >
</template> <template #icon>
{{ t('photos', 'Create new album') }} <Plus />
</NcButton> </template>
</div> {{ t("photos", "Create new album") }}
</NcButton>
</div>
<AlbumForm v-else <AlbumForm
:display-back-button="true" v-else
:title="t('photos', 'New album')" :display-back-button="true"
@back="showAlbumCreationForm = false" :title="t('photos', 'New album')"
@done="albumCreatedHandler" /> @back="showAlbumCreationForm = false"
@done="albumCreatedHandler"
/>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Emit, Mixins } from 'vue-property-decorator'; import { Component, Emit, Mixins } from "vue-property-decorator";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import { getCurrentUser } from '@nextcloud/auth'; import { getCurrentUser } from "@nextcloud/auth";
import AlbumForm from './AlbumForm.vue' import AlbumForm from "./AlbumForm.vue";
import Plus from 'vue-material-design-icons/Plus.vue' import Plus from "vue-material-design-icons/Plus.vue";
import ImageMultiple from 'vue-material-design-icons/ImageMultiple.vue' import ImageMultiple from "vue-material-design-icons/ImageMultiple.vue";
import { NcButton, NcListItem, NcLoadingIcon } from '@nextcloud/vue' import { NcButton, NcListItem, NcLoadingIcon } from "@nextcloud/vue";
import { generateUrl } from '@nextcloud/router' import { generateUrl } from "@nextcloud/router";
import { IAlbum } from '../../types'; import { IAlbum } from "../../types";
import axios from '@nextcloud/axios' import axios from "@nextcloud/axios";
@Component({ @Component({
components: { components: {
AlbumForm, AlbumForm,
Plus, Plus,
ImageMultiple, ImageMultiple,
NcButton, NcButton,
NcListItem, NcListItem,
NcLoadingIcon, NcLoadingIcon,
},
filters: {
toCoverUrl(fileId: string) {
return generateUrl(
`/apps/photos/api/v1/preview/${fileId}?x=${256}&y=${256}`
);
}, },
filters: { },
toCoverUrl(fileId: string) {
return generateUrl(`/apps/photos/api/v1/preview/${fileId}?x=${256}&y=${256}`)
}
}
}) })
export default class AlbumPicker extends Mixins(GlobalMixin) { export default class AlbumPicker extends Mixins(GlobalMixin) {
private showAlbumCreationForm = false; private showAlbumCreationForm = false;
private albums: IAlbum[] = []; private albums: IAlbum[] = [];
private loadingAlbums = true; private loadingAlbums = true;
mounted() { mounted() {
this.loadAlbums(); this.loadAlbums();
}
albumCreatedHandler() {
this.showAlbumCreationForm = false;
this.loadAlbums();
}
getAlbumName(album: IAlbum) {
if (album.user === getCurrentUser()?.uid) {
return album.name;
} }
return `${album.name} (${album.user})`;
}
albumCreatedHandler() { async loadAlbums() {
this.showAlbumCreationForm = false try {
this.loadAlbums(); const res = await axios.get<IAlbum[]>(
generateUrl("/apps/memories/api/albums?t=3")
);
this.albums = res.data;
} catch (e) {
console.error(e);
} finally {
this.loadingAlbums = false;
} }
}
getAlbumName(album: IAlbum) { @Emit("select")
if (album.user === getCurrentUser()?.uid) { pickAlbum(album: IAlbum) {}
return album.name
}
return `${album.name} (${album.user})`
}
async loadAlbums() {
try {
const res = await axios.get<IAlbum[]>(generateUrl('/apps/memories/api/albums?t=3'));
this.albums = res.data;
} catch (e) {
console.error(e);
} finally {
this.loadingAlbums = false;
}
}
@Emit('select')
pickAlbum(album: IAlbum) {}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.album-picker { .album-picker {
h2 { h2 {
display: flex; display: flex;
align-items: center; align-items: center;
height: 60px; height: 60px;
.loading-icon { .loading-icon {
margin-left: 32px; margin-left: 32px;
} }
} }
.albums-container { .albums-container {
min-height: 150px; min-height: 150px;
max-height: 350px; max-height: 350px;
overflow-x: scroll; overflow-x: scroll;
padding: 2px; padding: 2px;
.album { .album {
:deep .list-item {
padding: 8px 16px;
box-sizing: border-box;
}
:deep .list-item { &:not(:last-child) {
padding: 8px 16px; margin-bottom: 16px;
box-sizing: border-box; }
}
&:not(:last-child) { &__image {
margin-bottom: 16px; width: 40px;
} height: 40px;
object-fit: cover;
border-radius: var(--border-radius);
&__image { &--placeholder {
width: 40px; background: var(--color-primary-light);
height: 40px;
object-fit: cover;
border-radius: var(--border-radius);
&--placeholder { :deep .material-design-icon {
background: var(--color-primary-light); width: 100%;
height: 100%;
:deep .material-design-icon { .material-design-icon__svg {
width: 100%; fill: var(--color-primary);
height: 100%; }
}
}
}
}
}
.material-design-icon__svg { .new-album-button {
fill: var(--color-primary); margin-top: 32px;
} }
}
}
}
}
}
.new-album-button {
margin-top: 32px;
}
} }
</style> </style>

View File

@ -1,76 +1,83 @@
<template> <template>
<Modal @close="close" v-if="show"> <Modal @close="close" v-if="show">
<template #title> <template #title>
{{ t('memories', 'Share Album') }} {{ t("memories", "Share Album") }}
</template> </template>
<AlbumCollaborators v-if="album" <AlbumCollaborators
:album-name="album.basename" v-if="album"
:collaborators="album.collaborators" :album-name="album.basename"
:public-link="album.publicLink"> :collaborators="album.collaborators"
<template slot-scope="{collaborators}"> :public-link="album.publicLink"
<NcButton :aria-label="t('photos', 'Save collaborators for this album.')" >
type="primary" <template slot-scope="{ collaborators }">
:disabled="loadingAddCollaborators" <NcButton
@click="handleSetCollaborators(collaborators)"> :aria-label="t('photos', 'Save collaborators for this album.')"
<template #icon> type="primary"
<NcLoadingIcon v-if="loadingAddCollaborators" /> :disabled="loadingAddCollaborators"
</template> @click="handleSetCollaborators(collaborators)"
{{ t('photos', 'Save') }} >
</NcButton> <template #icon>
</template> <NcLoadingIcon v-if="loadingAddCollaborators" />
</AlbumCollaborators> </template>
</Modal> {{ t("photos", "Save") }}
</NcButton>
</template>
</AlbumCollaborators>
</Modal>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Emit, Mixins } from 'vue-property-decorator'; import { Component, Emit, Mixins } from "vue-property-decorator";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import { NcButton, NcLoadingIcon } from '@nextcloud/vue'; import { NcButton, NcLoadingIcon } from "@nextcloud/vue";
import * as dav from '../../services/DavRequests'; import * as dav from "../../services/DavRequests";
import Modal from './Modal.vue'; import Modal from "./Modal.vue";
import AlbumCollaborators from './AlbumCollaborators.vue'; import AlbumCollaborators from "./AlbumCollaborators.vue";
@Component({ @Component({
components: { components: {
NcButton, NcButton,
NcLoadingIcon, NcLoadingIcon,
Modal, Modal,
AlbumCollaborators, AlbumCollaborators,
} },
}) })
export default class AlbumShareModal extends Mixins(GlobalMixin) { export default class AlbumShareModal extends Mixins(GlobalMixin) {
private album: any = null; private album: any = null;
private show = false; private show = false;
private loadingAddCollaborators = false; private loadingAddCollaborators = false;
@Emit('close') @Emit("close")
public close() { public close() {
this.show = false; this.show = false;
this.album = null; this.album = null;
} }
public async open() { public async open() {
this.show = true; this.show = true;
this.loadingAddCollaborators = true; this.loadingAddCollaborators = true;
const user = this.$route.params.user || ''; const user = this.$route.params.user || "";
const name = this.$route.params.name || ''; const name = this.$route.params.name || "";
this.album = await dav.getAlbum(user, name); this.album = await dav.getAlbum(user, name);
this.loadingAddCollaborators = false; this.loadingAddCollaborators = false;
} }
async handleSetCollaborators(collaborators: any[]) { async handleSetCollaborators(collaborators: any[]) {
try { try {
this.loadingAddCollaborators = true this.loadingAddCollaborators = true;
await dav.updateAlbum(this.album, { albumName: this.album.basename, properties: { collaborators } }) await dav.updateAlbum(this.album, {
this.close(); albumName: this.album.basename,
} catch (error) { properties: { collaborators },
console.error(error); });
} finally { this.close();
this.loadingAddCollaborators = false } catch (error) {
} console.error(error);
} finally {
this.loadingAddCollaborators = false;
} }
}
} }
</script> </script>

View File

@ -1,425 +1,463 @@
<template> <template>
<Modal <Modal v-if="photos.length > 0" @close="close">
v-if="photos.length > 0" <template #title>
@close="close"> {{ t("memories", "Edit Date/Time") }}
</template>
<template #title> <template #buttons>
{{ t('memories', 'Edit Date/Time') }} <NcButton @click="save" class="button" type="error" v-if="longDateStr">
</template> {{ t("memories", "Update Exif") }}
</NcButton>
</template>
<template #buttons> <div v-if="longDateStr">
<NcButton @click="save" class="button" type="error" v-if="longDateStr"> <span v-if="photos.length > 1"> [{{ t("memories", "Newest") }}] </span>
{{ t('memories', 'Update Exif') }} {{ longDateStr }}
</NcButton>
</template>
<div v-if="longDateStr"> <div class="fields">
<span v-if="photos.length > 1"> <NcTextField
[{{ t('memories', 'Newest') }}] :value.sync="year"
</span> class="field"
{{ longDateStr }} @input="newestChange()"
:label="t('memories', 'Year')"
:label-visible="true"
:placeholder="t('memories', 'Year')"
/>
<NcTextField
:value.sync="month"
class="field"
@input="newestChange()"
:label="t('memories', 'Month')"
:label-visible="true"
:placeholder="t('memories', 'Month')"
/>
<NcTextField
:value.sync="day"
class="field"
@input="newestChange()"
:label="t('memories', 'Day')"
:label-visible="true"
:placeholder="t('memories', 'Day')"
/>
<NcTextField
:value.sync="hour"
class="field"
@input="newestChange(true)"
:label="t('memories', 'Time')"
:label-visible="true"
:placeholder="t('memories', 'Hour')"
/>
<NcTextField
:value.sync="minute"
class="field"
@input="newestChange(true)"
:label="t('memories', 'Minute')"
:placeholder="t('memories', 'Minute')"
/>
</div>
<div class="fields"> <div v-if="photos.length > 1" class="oldest">
<NcTextField :value.sync="year" <span> [{{ t("memories", "Oldest") }}] </span>
class="field" {{ longDateStrLast }}
@input="newestChange()"
:label="t('memories', 'Year')" :label-visible="true"
:placeholder="t('memories', 'Year')" />
<NcTextField :value.sync="month"
class="field"
@input="newestChange()"
:label="t('memories', 'Month')" :label-visible="true"
:placeholder="t('memories', 'Month')" />
<NcTextField :value.sync="day"
class="field"
@input="newestChange()"
:label="t('memories', 'Day')" :label-visible="true"
:placeholder="t('memories', 'Day')" />
<NcTextField :value.sync="hour"
class="field"
@input="newestChange(true)"
:label="t('memories', 'Time')" :label-visible="true"
:placeholder="t('memories', 'Hour')" />
<NcTextField :value.sync="minute"
class="field"
@input="newestChange(true)"
:label="t('memories', 'Minute')"
:placeholder="t('memories', 'Minute')" />
</div>
<div v-if="photos.length > 1" class="oldest"> <div class="fields">
<span> <NcTextField
[{{ t('memories', 'Oldest') }}] :value.sync="yearLast"
</span> class="field"
{{ longDateStrLast }} :label="t('memories', 'Year')"
:label-visible="true"
<div class="fields"> :placeholder="t('memories', 'Year')"
<NcTextField :value.sync="yearLast" />
class="field" <NcTextField
:label="t('memories', 'Year')" :label-visible="true" :value.sync="monthLast"
:placeholder="t('memories', 'Year')" /> class="field"
<NcTextField :value.sync="monthLast" :label="t('memories', 'Month')"
class="field" :label-visible="true"
:label="t('memories', 'Month')" :label-visible="true" :placeholder="t('memories', 'Month')"
:placeholder="t('memories', 'Month')" /> />
<NcTextField :value.sync="dayLast" <NcTextField
class="field" :value.sync="dayLast"
:label="t('memories', 'Day')" :label-visible="true" class="field"
:placeholder="t('memories', 'Day')" /> :label="t('memories', 'Day')"
<NcTextField :value.sync="hourLast" :label-visible="true"
class="field" :placeholder="t('memories', 'Day')"
:label="t('memories', 'Time')" :label-visible="true" />
:placeholder="t('memories', 'Hour')" /> <NcTextField
<NcTextField :value.sync="minuteLast" :value.sync="hourLast"
class="field" class="field"
:label="t('memories', 'Minute')" :label="t('memories', 'Time')"
:placeholder="t('memories', 'Minute')" /> :label-visible="true"
</div> :placeholder="t('memories', 'Hour')"
</div> />
<NcTextField
<div v-if="processing" class="info-pad"> :value.sync="minuteLast"
{{ t('memories', 'Processing … {n}/{m}', { class="field"
n: photosDone, :label="t('memories', 'Minute')"
m: photos.length, :placeholder="t('memories', 'Minute')"
}) }} />
</div>
<div class="info-pad warn">
{{ t('memories', 'This feature modifies files in your storage to update Exif data.') }}
{{ t('memories', 'Exercise caution and make sure you have backups.') }}
</div>
</div> </div>
</div>
<div v-else> <div v-if="processing" class="info-pad">
{{ t('memories', 'Loading data … {n}/{m}', { {{
n: photosDone, t("memories", "Processing … {n}/{m}", {
m: photos.length, n: photosDone,
}) }} m: photos.length,
</div> })
</Modal> }}
</div>
<div class="info-pad warn">
{{
t(
"memories",
"This feature modifies files in your storage to update Exif data."
)
}}
{{ t("memories", "Exercise caution and make sure you have backups.") }}
</div>
</div>
<div v-else>
{{
t("memories", "Loading data … {n}/{m}", {
n: photosDone,
m: photos.length,
})
}}
</div>
</Modal>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Emit, Mixins } from 'vue-property-decorator'; import { Component, Emit, Mixins } from "vue-property-decorator";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import { IPhoto } from '../../types'; import { IPhoto } from "../../types";
import { NcButton, NcTextField } from '@nextcloud/vue'; import { NcButton, NcTextField } from "@nextcloud/vue";
import { showError } from '@nextcloud/dialogs' import { showError } from "@nextcloud/dialogs";
import { generateUrl } from '@nextcloud/router' import { generateUrl } from "@nextcloud/router";
import Modal from './Modal.vue'; import Modal from "./Modal.vue";
import axios from '@nextcloud/axios' import axios from "@nextcloud/axios";
import * as utils from '../../services/Utils'; import * as utils from "../../services/Utils";
import * as dav from "../../services/DavRequests"; import * as dav from "../../services/DavRequests";
const INFO_API_URL = '/apps/memories/api/info/{id}'; const INFO_API_URL = "/apps/memories/api/info/{id}";
const EDIT_API_URL = '/apps/memories/api/edit/{id}'; const EDIT_API_URL = "/apps/memories/api/edit/{id}";
@Component({ @Component({
components: { components: {
NcButton, NcButton,
NcTextField, NcTextField,
Modal, Modal,
} },
}) })
export default class EditDate extends Mixins(GlobalMixin) { export default class EditDate extends Mixins(GlobalMixin) {
@Emit('refresh') emitRefresh(val: boolean) {} @Emit("refresh") emitRefresh(val: boolean) {}
private photos: IPhoto[] = []; private photos: IPhoto[] = [];
private photosDone: number = 0; private photosDone: number = 0;
private processing: boolean = false; private processing: boolean = false;
private longDateStr: string = ''; private longDateStr: string = "";
private year: string = "0"; private year: string = "0";
private month: string = "0"; private month: string = "0";
private day: string = "0"; private day: string = "0";
private hour: string = "0"; private hour: string = "0";
private minute: string = "0"; private minute: string = "0";
private second: string = "0"; private second: string = "0";
private longDateStrLast: string = ''; private longDateStrLast: string = "";
private yearLast: string = "0"; private yearLast: string = "0";
private monthLast: string = "0"; private monthLast: string = "0";
private dayLast: string = "0"; private dayLast: string = "0";
private hourLast: string = "0"; private hourLast: string = "0";
private minuteLast: string = "0"; private minuteLast: string = "0";
private secondLast: string = "0"; private secondLast: string = "0";
public async open(photos: IPhoto[]) { public async open(photos: IPhoto[]) {
this.photos = photos; this.photos = photos;
if (photos.length === 0) { if (photos.length === 0) {
return; return;
}
this.photosDone = 0;
this.longDateStr = "";
const calls = photos.map((p) => async () => {
try {
const res = await axios.get<any>(
generateUrl(INFO_API_URL, { id: p.fileid })
);
if (typeof res.data.datetaken !== "number") {
console.error("Invalid date for", p.fileid);
return;
} }
this.photosDone = 0; p.datetaken = res.data.datetaken * 1000;
this.longDateStr = ''; } catch (error) {
console.error("Failed to get date info for", p.fileid, error);
} finally {
this.photosDone++;
}
});
const calls = photos.map((p) => async () => { for await (const _ of dav.runInParallel(calls, 10)) {
try { // nothing to do
const res = await axios.get<any>(generateUrl(INFO_API_URL, { id: p.fileid }));
if (typeof res.data.datetaken !== "number") {
console.error("Invalid date for", p.fileid);
return;
}
p.datetaken = res.data.datetaken * 1000;
} catch (error) {
console.error('Failed to get date info for', p.fileid, error);
} finally {
this.photosDone++;
}
});
for await (const _ of dav.runInParallel(calls, 10)) {
// nothing to do
}
// Remove photos without datetaken
this.photos = this.photos.filter((p) => p.datetaken !== undefined);
// Sort photos by datetaken descending
this.photos.sort((a, b) => b.datetaken - a.datetaken);
// Get date of newest photo
let date = new Date(this.photos[0].datetaken);
this.year = date.getUTCFullYear().toString();
this.month = (date.getUTCMonth() + 1).toString();
this.day = date.getUTCDate().toString();
this.hour = date.getUTCHours().toString();
this.minute = date.getUTCMinutes().toString();
this.second = date.getUTCSeconds().toString();
this.longDateStr = utils.getLongDateStr(date, false, true);
// Get date of oldest photo
if (this.photos.length > 1) {
date = new Date(this.photos[this.photos.length - 1].datetaken);
this.yearLast = date.getUTCFullYear().toString();
this.monthLast = (date.getUTCMonth() + 1).toString();
this.dayLast = date.getUTCDate().toString();
this.hourLast = date.getUTCHours().toString();
this.minuteLast = date.getUTCMinutes().toString();
this.secondLast = date.getUTCSeconds().toString();
this.longDateStrLast = utils.getLongDateStr(date, false, true);
}
} }
public newestChange(time=false) { // Remove photos without datetaken
if (this.photos.length === 0) { this.photos = this.photos.filter((p) => p.datetaken !== undefined);
return;
}
// Set the last date to have the same offset to newest date // Sort photos by datetaken descending
try { this.photos.sort((a, b) => b.datetaken - a.datetaken);
const date = new Date(this.photos[0].datetaken);
const dateLast = new Date(this.photos[this.photos.length - 1].datetaken);
const dateNew = this.getDate(); // Get date of newest photo
const offset = dateNew.getTime() - date.getTime(); let date = new Date(this.photos[0].datetaken);
const dateLastNew = new Date(dateLast.getTime() + offset); this.year = date.getUTCFullYear().toString();
this.month = (date.getUTCMonth() + 1).toString();
this.day = date.getUTCDate().toString();
this.hour = date.getUTCHours().toString();
this.minute = date.getUTCMinutes().toString();
this.second = date.getUTCSeconds().toString();
this.longDateStr = utils.getLongDateStr(date, false, true);
this.yearLast = dateLastNew.getUTCFullYear().toString(); // Get date of oldest photo
this.monthLast = (dateLastNew.getUTCMonth() + 1).toString(); if (this.photos.length > 1) {
this.dayLast = dateLastNew.getUTCDate().toString(); date = new Date(this.photos[this.photos.length - 1].datetaken);
this.yearLast = date.getUTCFullYear().toString();
this.monthLast = (date.getUTCMonth() + 1).toString();
this.dayLast = date.getUTCDate().toString();
this.hourLast = date.getUTCHours().toString();
this.minuteLast = date.getUTCMinutes().toString();
this.secondLast = date.getUTCSeconds().toString();
this.longDateStrLast = utils.getLongDateStr(date, false, true);
}
}
if (time) { public newestChange(time = false) {
this.hourLast = dateLastNew.getUTCHours().toString(); if (this.photos.length === 0) {
this.minuteLast = dateLastNew.getUTCMinutes().toString(); return;
this.secondLast = dateLastNew.getUTCSeconds().toString();
}
} catch (error) {}
} }
public close() { // Set the last date to have the same offset to newest date
this.photos = []; try {
const date = new Date(this.photos[0].datetaken);
const dateLast = new Date(this.photos[this.photos.length - 1].datetaken);
const dateNew = this.getDate();
const offset = dateNew.getTime() - date.getTime();
const dateLastNew = new Date(dateLast.getTime() + offset);
this.yearLast = dateLastNew.getUTCFullYear().toString();
this.monthLast = (dateLastNew.getUTCMonth() + 1).toString();
this.dayLast = dateLastNew.getUTCDate().toString();
if (time) {
this.hourLast = dateLastNew.getUTCHours().toString();
this.minuteLast = dateLastNew.getUTCMinutes().toString();
this.secondLast = dateLastNew.getUTCSeconds().toString();
}
} catch (error) {}
}
public close() {
this.photos = [];
}
public async saveOne() {
// Make PATCH request to update date
try {
this.processing = true;
const res = await axios.patch<any>(
generateUrl(EDIT_API_URL, { id: this.photos[0].fileid }),
{
date: this.getExifFormat(this.getDate()),
}
);
this.emitRefresh(true);
this.close();
} catch (e) {
if (e.response?.data?.message) {
showError(e.response.data.message);
} else {
showError(e);
}
} finally {
this.processing = false;
}
}
public async saveMany() {
if (this.processing) {
return;
} }
public async saveOne() { // Get difference between newest and oldest date
// Make PATCH request to update date const date = new Date(this.photos[0].datetaken);
try { const dateLast = new Date(this.photos[this.photos.length - 1].datetaken);
this.processing = true; const diff = date.getTime() - dateLast.getTime();
const res = await axios.patch<any>(generateUrl(EDIT_API_URL, { id: this.photos[0].fileid }), {
date: this.getExifFormat(this.getDate()), // Get new difference between newest and oldest date
}); let dateNew: Date;
this.emitRefresh(true); let dateLastNew: Date;
this.close(); let diffNew: number;
} catch (e) {
if (e.response?.data?.message) { try {
showError(e.response.data.message); dateNew = this.getDate();
} else { dateLastNew = this.getDateLast();
showError(e); diffNew = dateNew.getTime() - dateLastNew.getTime();
} } catch (e) {
} finally { showError(e);
this.processing = false; return;
}
} }
public async saveMany() { // Validate if the old is still old
if (this.processing) { if (diffNew < 0) {
return; showError("The newest date must be newer than the oldest date");
} return;
// Get difference between newest and oldest date
const date = new Date(this.photos[0].datetaken);
const dateLast = new Date(this.photos[this.photos.length - 1].datetaken);
const diff = date.getTime() - dateLast.getTime();
// Get new difference between newest and oldest date
let dateNew: Date;
let dateLastNew: Date;
let diffNew: number;
try {
dateNew = this.getDate();
dateLastNew = this.getDateLast();
diffNew = dateNew.getTime() - dateLastNew.getTime();
} catch (e) {
showError(e);
return;
}
// Validate if the old is still old
if (diffNew < 0) {
showError("The newest date must be newer than the oldest date");
return;
}
// Mark processing
this.processing = true;
this.photosDone = 0;
// Create PATCH requests
const calls = this.photos.map((p) => async () => {
try {
let pDate = new Date(p.datetaken);
// Fallback to start date if invalid date
if (isNaN(pDate.getTime())) {
pDate = date;
}
const offset = date.getTime() - pDate.getTime();
const scale = diff > 0 ? (diffNew / diff) : 0;
const pDateNew = new Date(dateNew.getTime() - offset * scale);
const res = await axios.patch<any>(generateUrl(EDIT_API_URL, { id: p.fileid }), {
date: this.getExifFormat(pDateNew),
});
} catch (e) {
if (e.response?.data?.message) {
showError(e.response.data.message);
} else {
showError(e);
}
} finally {
this.photosDone++;
}
});
for await (const _ of dav.runInParallel(calls, 10)) {
// nothing to do
}
this.processing = false;
this.emitRefresh(true);
this.close();
} }
public async save() { // Mark processing
if (this.photos.length === 0) { this.processing = true;
return; this.photosDone = 0;
// Create PATCH requests
const calls = this.photos.map((p) => async () => {
try {
let pDate = new Date(p.datetaken);
// Fallback to start date if invalid date
if (isNaN(pDate.getTime())) {
pDate = date;
} }
if (this.photos.length === 1) { const offset = date.getTime() - pDate.getTime();
return await this.saveOne(); const scale = diff > 0 ? diffNew / diff : 0;
const pDateNew = new Date(dateNew.getTime() - offset * scale);
const res = await axios.patch<any>(
generateUrl(EDIT_API_URL, { id: p.fileid }),
{
date: this.getExifFormat(pDateNew),
}
);
} catch (e) {
if (e.response?.data?.message) {
showError(e.response.data.message);
} else {
showError(e);
} }
} finally {
this.photosDone++;
}
});
return await this.saveMany(); for await (const _ of dav.runInParallel(calls, 10)) {
// nothing to do
}
this.processing = false;
this.emitRefresh(true);
this.close();
}
public async save() {
if (this.photos.length === 0) {
return;
} }
private getExifFormat(date: Date) { if (this.photos.length === 1) {
const year = date.getUTCFullYear().toString().padStart(4, "0"); return await this.saveOne();
const month = (date.getUTCMonth() + 1).toString().padStart(2, "0");
const day = date.getUTCDate().toString().padStart(2, "0");
const hour = date.getUTCHours().toString().padStart(2, "0");
const minute = date.getUTCMinutes().toString().padStart(2, "0");
const second = date.getUTCSeconds().toString().padStart(2, "0");
return `${year}:${month}:${day} ${hour}:${minute}:${second}`;
} }
public getDate() { return await this.saveMany();
const dateNew = new Date(); }
const year = parseInt(this.year, 10);
const month = parseInt(this.month, 10) - 1;
const day = parseInt(this.day, 10);
const hour = parseInt(this.hour, 10);
const minute = parseInt(this.minute, 10);
const second = parseInt(this.second, 10) || 0;
if (isNaN(year)) throw new Error("Invalid year"); private getExifFormat(date: Date) {
if (isNaN(month)) throw new Error("Invalid month"); const year = date.getUTCFullYear().toString().padStart(4, "0");
if (isNaN(day)) throw new Error("Invalid day"); const month = (date.getUTCMonth() + 1).toString().padStart(2, "0");
if (isNaN(hour)) throw new Error("Invalid hour"); const day = date.getUTCDate().toString().padStart(2, "0");
if (isNaN(minute)) throw new Error("Invalid minute"); const hour = date.getUTCHours().toString().padStart(2, "0");
if (isNaN(second)) throw new Error("Invalid second"); const minute = date.getUTCMinutes().toString().padStart(2, "0");
const second = date.getUTCSeconds().toString().padStart(2, "0");
return `${year}:${month}:${day} ${hour}:${minute}:${second}`;
}
dateNew.setUTCFullYear(year); public getDate() {
dateNew.setUTCMonth(month); const dateNew = new Date();
dateNew.setUTCDate(day); const year = parseInt(this.year, 10);
dateNew.setUTCHours(hour); const month = parseInt(this.month, 10) - 1;
dateNew.setUTCMinutes(minute); const day = parseInt(this.day, 10);
dateNew.setUTCSeconds(second); const hour = parseInt(this.hour, 10);
return dateNew; const minute = parseInt(this.minute, 10);
} const second = parseInt(this.second, 10) || 0;
public getDateLast() { if (isNaN(year)) throw new Error("Invalid year");
const dateNew = new Date(); if (isNaN(month)) throw new Error("Invalid month");
const year = parseInt(this.yearLast, 10); if (isNaN(day)) throw new Error("Invalid day");
const month = parseInt(this.monthLast, 10) - 1; if (isNaN(hour)) throw new Error("Invalid hour");
const day = parseInt(this.dayLast, 10); if (isNaN(minute)) throw new Error("Invalid minute");
const hour = parseInt(this.hourLast, 10); if (isNaN(second)) throw new Error("Invalid second");
const minute = parseInt(this.minuteLast, 10);
const second = parseInt(this.secondLast, 10) || 0;
if (isNaN(year)) throw new Error("Invalid last year"); dateNew.setUTCFullYear(year);
if (isNaN(month)) throw new Error("Invalid last month"); dateNew.setUTCMonth(month);
if (isNaN(day)) throw new Error("Invalid last day"); dateNew.setUTCDate(day);
if (isNaN(hour)) throw new Error("Invalid last hour"); dateNew.setUTCHours(hour);
if (isNaN(minute)) throw new Error("Invalid last minute"); dateNew.setUTCMinutes(minute);
if (isNaN(second)) throw new Error("Invalid last second"); dateNew.setUTCSeconds(second);
return dateNew;
}
dateNew.setUTCFullYear(year); public getDateLast() {
dateNew.setUTCMonth(month); const dateNew = new Date();
dateNew.setUTCDate(day); const year = parseInt(this.yearLast, 10);
dateNew.setUTCHours(hour); const month = parseInt(this.monthLast, 10) - 1;
dateNew.setUTCMinutes(minute); const day = parseInt(this.dayLast, 10);
dateNew.setUTCSeconds(second); const hour = parseInt(this.hourLast, 10);
return dateNew; const minute = parseInt(this.minuteLast, 10);
} const second = parseInt(this.secondLast, 10) || 0;
if (isNaN(year)) throw new Error("Invalid last year");
if (isNaN(month)) throw new Error("Invalid last month");
if (isNaN(day)) throw new Error("Invalid last day");
if (isNaN(hour)) throw new Error("Invalid last hour");
if (isNaN(minute)) throw new Error("Invalid last minute");
if (isNaN(second)) throw new Error("Invalid last second");
dateNew.setUTCFullYear(year);
dateNew.setUTCMonth(month);
dateNew.setUTCDate(day);
dateNew.setUTCHours(hour);
dateNew.setUTCMinutes(minute);
dateNew.setUTCSeconds(second);
return dateNew;
}
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.fields { .fields {
.field { .field {
width: 4.1em; width: 4.1em;
display: inline-block; display: inline-block;
} }
:deep label { :deep label {
font-size: 0.8em; font-size: 0.8em;
padding: 0 !important; padding: 0 !important;
padding-left: 3px !important; padding-left: 3px !important;
} }
} }
.oldest { .oldest {
margin-top: 10px; margin-top: 10px;
} }
.info-pad { .info-pad {
margin-top: 6px; margin-top: 6px;
margin-bottom: 2px; margin-bottom: 2px;
&.warn { &.warn {
color: #f44336; color: #f44336;
font-size: 0.8em; font-size: 0.8em;
line-height: 1em; line-height: 1em;
} }
} }
</style> </style>

View File

@ -1,79 +1,87 @@
<template> <template>
<Modal @close="close" v-if="show"> <Modal @close="close" v-if="show">
<template #title> <template #title>
{{ t('memories', 'Remove person') }} {{ t("memories", "Remove person") }}
</template> </template>
<span>{{ t('memories', 'Are you sure you want to remove {name}?', { name }) }}</span> <span>{{
t("memories", "Are you sure you want to remove {name}?", { name })
}}</span>
<template #buttons> <template #buttons>
<NcButton @click="save" class="button" type="error"> <NcButton @click="save" class="button" type="error">
{{ t('memories', 'Delete') }} {{ t("memories", "Delete") }}
</NcButton> </NcButton>
</template> </template>
</Modal> </Modal>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Emit, Mixins, Watch } from 'vue-property-decorator'; import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
import { NcButton, NcTextField } from '@nextcloud/vue'; import { NcButton, NcTextField } from "@nextcloud/vue";
import { showError } from '@nextcloud/dialogs'; import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from '@nextcloud/auth'; import { getCurrentUser } from "@nextcloud/auth";
import Modal from './Modal.vue'; import Modal from "./Modal.vue";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import client from '../../services/DavClient'; import client from "../../services/DavClient";
@Component({ @Component({
components: { components: {
NcButton, NcButton,
NcTextField, NcTextField,
Modal, Modal,
} },
}) })
export default class FaceDeleteModal extends Mixins(GlobalMixin) { export default class FaceDeleteModal extends Mixins(GlobalMixin) {
private user: string = ""; private user: string = "";
private name: string = ""; private name: string = "";
private show = false; private show = false;
@Emit('close') @Emit("close")
public close() { public close() {
this.show = false; this.show = false;
} }
public open() { public open() {
const user = this.$route.params.user || ''; const user = this.$route.params.user || "";
if (this.$route.params.user !== getCurrentUser().uid) { if (this.$route.params.user !== getCurrentUser().uid) {
showError(this.t('memories', 'Only user "{user}" can delete this person', { user })); showError(
return; this.t("memories", 'Only user "{user}" can delete this person', {
} user,
this.show = true; })
);
return;
} }
this.show = true;
}
@Watch('$route') @Watch("$route")
async routeChange(from: any, to: any) { async routeChange(from: any, to: any) {
this.refreshParams(); this.refreshParams();
} }
mounted() { mounted() {
this.refreshParams(); this.refreshParams();
} }
public refreshParams() { public refreshParams() {
this.user = this.$route.params.user || ''; this.user = this.$route.params.user || "";
this.name = this.$route.params.name || ''; this.name = this.$route.params.name || "";
} }
public async save() { public async save() {
try { try {
await client.deleteFile(`/recognize/${this.user}/faces/${this.name}`) await client.deleteFile(`/recognize/${this.user}/faces/${this.name}`);
this.$router.push({ name: 'people' }); this.$router.push({ name: "people" });
this.close(); this.close();
} catch (error) { } catch (error) {
console.log(error); console.log(error);
showError(this.t('photos', 'Failed to delete {name}.', { showError(
name: this.name, this.t("photos", "Failed to delete {name}.", {
})); name: this.name,
} })
);
} }
}
} }
</script> </script>

View File

@ -1,97 +1,109 @@
<template> <template>
<Modal @close="close" v-if="show"> <Modal @close="close" v-if="show">
<template #title> <template #title>
{{ t('memories', 'Rename person') }} {{ t("memories", "Rename person") }}
</template> </template>
<div class="fields"> <div class="fields">
<NcTextField :value.sync="name" <NcTextField
class="field" :value.sync="name"
:label="t('memories', 'Name')" :label-visible="false" class="field"
:placeholder="t('memories', 'Name')" :label="t('memories', 'Name')"
@keypress.enter="save()" /> :label-visible="false"
</div> :placeholder="t('memories', 'Name')"
@keypress.enter="save()"
/>
</div>
<template #buttons> <template #buttons>
<NcButton @click="save" class="button" type="primary"> <NcButton @click="save" class="button" type="primary">
{{ t('memories', 'Update') }} {{ t("memories", "Update") }}
</NcButton> </NcButton>
</template> </template>
</Modal> </Modal>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Emit, Mixins, Watch } from 'vue-property-decorator'; import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
import { NcButton, NcTextField } from '@nextcloud/vue'; import { NcButton, NcTextField } from "@nextcloud/vue";
import { showError } from '@nextcloud/dialogs'; import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from '@nextcloud/auth'; import { getCurrentUser } from "@nextcloud/auth";
import Modal from './Modal.vue'; import Modal from "./Modal.vue";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import client from '../../services/DavClient'; import client from "../../services/DavClient";
@Component({ @Component({
components: { components: {
NcButton, NcButton,
NcTextField, NcTextField,
Modal, Modal,
} },
}) })
export default class FaceEditModal extends Mixins(GlobalMixin) { export default class FaceEditModal extends Mixins(GlobalMixin) {
private user: string = ""; private user: string = "";
private name: string = ""; private name: string = "";
private oldName: string = ""; private oldName: string = "";
private show = false; private show = false;
@Emit('close') @Emit("close")
public close() { public close() {
this.show = false; this.show = false;
} }
public open() { public open() {
const user = this.$route.params.user || ''; const user = this.$route.params.user || "";
if (this.$route.params.user !== getCurrentUser().uid) { if (this.$route.params.user !== getCurrentUser().uid) {
showError(this.t('memories', 'Only user "{user}" can update this person', { user })); showError(
return; this.t("memories", 'Only user "{user}" can update this person', {
} user,
this.show = true; })
);
return;
} }
this.show = true;
}
@Watch('$route') @Watch("$route")
async routeChange(from: any, to: any) { async routeChange(from: any, to: any) {
this.refreshParams(); this.refreshParams();
} }
mounted() { mounted() {
this.refreshParams(); this.refreshParams();
} }
public refreshParams() { public refreshParams() {
this.user = this.$route.params.user || ''; this.user = this.$route.params.user || "";
this.name = this.$route.params.name || ''; this.name = this.$route.params.name || "";
this.oldName = this.$route.params.name || ''; this.oldName = this.$route.params.name || "";
} }
public async save() { public async save() {
try { try {
await client.moveFile( await client.moveFile(
`/recognize/${this.user}/faces/${this.oldName}`, `/recognize/${this.user}/faces/${this.oldName}`,
`/recognize/${this.user}/faces/${this.name}`, `/recognize/${this.user}/faces/${this.name}`
); );
this.$router.push({ name: 'people', params: { user: this.user, name: this.name } }); this.$router.push({
this.close(); name: "people",
} catch (error) { params: { user: this.user, name: this.name },
console.log(error); });
showError(this.t('photos', 'Failed to rename {oldName} to {name}.', { this.close();
oldName: this.oldName, } catch (error) {
name: this.name, console.log(error);
})); showError(
} this.t("photos", "Failed to rename {oldName} to {name}.", {
oldName: this.oldName,
name: this.name,
})
);
} }
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.fields { .fields {
margin-top: 8px; margin-top: 8px;
} }
</style> </style>

View File

@ -1,83 +1,83 @@
<template> <template>
<div class="outer" v-if="detail"> <div class="outer" v-if="detail">
<div class="photo" v-for="photo of detail" :key="photo.fileid" > <div class="photo" v-for="photo of detail" :key="photo.fileid">
<Tag :data="photo" :noNavigate="true" @open="clickFace" /> <Tag :data="photo" :noNavigate="true" @open="clickFace" />
</div>
</div>
<div v-else>
{{ t('memories', 'Loading …') }}
</div> </div>
</div>
<div v-else>
{{ t("memories", "Loading …") }}
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Emit, Mixins, Watch } from 'vue-property-decorator'; import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
import { IPhoto, ITag } from '../../types'; import { IPhoto, ITag } from "../../types";
import Tag from '../frame/Tag.vue'; import Tag from "../frame/Tag.vue";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import * as dav from '../../services/DavRequests'; import * as dav from "../../services/DavRequests";
@Component({ @Component({
components: { components: {
Tag, Tag,
} },
}) })
export default class FaceMergeModal extends Mixins(GlobalMixin) { export default class FaceMergeModal extends Mixins(GlobalMixin) {
private user: string = ""; private user: string = "";
private name: string = ""; private name: string = "";
private detail: IPhoto[] | null = null; private detail: IPhoto[] | null = null;
@Emit('close') @Emit("close")
public close() {} public close() {}
@Watch('$route') @Watch("$route")
async routeChange(from: any, to: any) { async routeChange(from: any, to: any) {
this.refreshParams(); this.refreshParams();
} }
mounted() { mounted() {
this.refreshParams(); this.refreshParams();
} }
public async refreshParams() { public async refreshParams() {
this.user = this.$route.params.user || ''; this.user = this.$route.params.user || "";
this.name = this.$route.params.name || ''; this.name = this.$route.params.name || "";
this.detail = null; this.detail = null;
const data = await dav.getPeopleData(); const data = await dav.getPeopleData();
let detail = data[0].detail; let detail = data[0].detail;
detail.forEach((photo: IPhoto) => { detail.forEach((photo: IPhoto) => {
photo.flag = this.c.FLAG_IS_FACE | this.c.FLAG_IS_TAG; photo.flag = this.c.FLAG_IS_FACE | this.c.FLAG_IS_TAG;
}); });
detail = detail.filter((photo: ITag) => { detail = detail.filter((photo: ITag) => {
const pname = photo.name || photo.fileid.toString(); const pname = photo.name || photo.fileid.toString();
return photo.user_id !== this.user || pname !== this.name; return photo.user_id !== this.user || pname !== this.name;
}); });
this.detail = detail; this.detail = detail;
} }
@Emit('select') @Emit("select")
public async clickFace(face: ITag) {} public async clickFace(face: ITag) {}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.outer { .outer {
width: 100%; width: 100%;
max-height: calc(90vh - 80px - 4em); max-height: calc(90vh - 80px - 4em);
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
} }
.photo { .photo {
display: inline-block; display: inline-block;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
vertical-align: top; vertical-align: top;
font-size: 0.8em; font-size: 0.8em;
max-width: 120px; max-width: 120px;
width: calc(33.33%); width: calc(33.33%);
aspect-ratio: 1/1; aspect-ratio: 1/1;
} }
</style> </style>

View File

@ -1,136 +1,156 @@
<template> <template>
<Modal @close="close" size="large" v-if="show"> <Modal @close="close" size="large" v-if="show">
<template #title> <template #title>
{{ t('memories', 'Merge {name} with person', { name: $route.params.name }) }} {{
</template> t("memories", "Merge {name} with person", { name: $route.params.name })
}}
</template>
<div class="outer"> <div class="outer">
<FaceList @select="clickFace" /> <FaceList @select="clickFace" />
<div v-if="procesingTotal > 0" class="info-pad"> <div v-if="procesingTotal > 0" class="info-pad">
{{ t('memories', 'Processing … {n}/{m}', { {{
n: processing, t("memories", "Processing … {n}/{m}", {
m: procesingTotal, n: processing,
}) }} m: procesingTotal,
</div> })
</div> }}
</div>
</div>
<template #buttons> <template #buttons>
<NcButton @click="close" class="button" type="error"> <NcButton @click="close" class="button" type="error">
{{ t('memories', 'Cancel') }} {{ t("memories", "Cancel") }}
</NcButton> </NcButton>
</template> </template>
</Modal> </Modal>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Emit, Mixins } from 'vue-property-decorator'; import { Component, Emit, Mixins } from "vue-property-decorator";
import { NcButton, NcTextField } from '@nextcloud/vue'; import { NcButton, NcTextField } from "@nextcloud/vue";
import { showError } from '@nextcloud/dialogs'; import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from '@nextcloud/auth'; import { getCurrentUser } from "@nextcloud/auth";
import { IFileInfo, ITag } from '../../types'; import { IFileInfo, ITag } from "../../types";
import Tag from '../frame/Tag.vue'; import Tag from "../frame/Tag.vue";
import FaceList from './FaceList.vue'; import FaceList from "./FaceList.vue";
import Modal from './Modal.vue'; import Modal from "./Modal.vue";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import client from '../../services/DavClient'; import client from "../../services/DavClient";
import * as dav from '../../services/DavRequests'; import * as dav from "../../services/DavRequests";
@Component({ @Component({
components: { components: {
NcButton, NcButton,
NcTextField, NcTextField,
Modal, Modal,
Tag, Tag,
FaceList, FaceList,
} },
}) })
export default class FaceMergeModal extends Mixins(GlobalMixin) { export default class FaceMergeModal extends Mixins(GlobalMixin) {
private processing = 0; private processing = 0;
private procesingTotal = 0; private procesingTotal = 0;
private show = false; private show = false;
@Emit('close') @Emit("close")
public close() { public close() {
this.show = false; this.show = false;
}
public open() {
const user = this.$route.params.user || "";
if (this.$route.params.user !== getCurrentUser().uid) {
showError(
this.t("memories", 'Only user "{user}" can update this person', {
user,
})
);
return;
}
this.show = true;
}
public async clickFace(face: ITag) {
const user = this.$route.params.user || "";
const name = this.$route.params.name || "";
const newName = face.name || face.fileid.toString();
if (
!confirm(
this.t(
"memories",
"Are you sure you want to merge {name} with {newName}?",
{ name, newName }
)
)
) {
return;
} }
public open() { try {
const user = this.$route.params.user || ''; // Get all files for current face
if (this.$route.params.user !== getCurrentUser().uid) { let res = (await client.getDirectoryContents(
showError(this.t('memories', 'Only user "{user}" can update this person', { user })); `/recognize/${user}/faces/${name}`,
return; { details: true }
)) as any;
let data: IFileInfo[] = res.data;
this.procesingTotal = data.length;
// Don't try too much
let failures = 0;
// Create move calls
const calls = data.map((p) => async () => {
// Short circuit if we have too many failures
if (failures === 10) {
showError(this.t("memories", "Too many failures, aborting"));
failures++;
} }
this.show = true; if (failures >= 10) return;
}
public async clickFace(face: ITag) { // Move to new face with webdav
const user = this.$route.params.user || ''; try {
const name = this.$route.params.name || ''; await client.moveFile(
`/recognize/${user}/faces/${name}/${p.basename}`,
const newName = face.name || face.fileid.toString(); `/recognize/${face.user_id}/faces/${newName}/${p.basename}`
if (!confirm(this.t('memories', 'Are you sure you want to merge {name} with {newName}?', { name, newName}))) { );
return; } catch (e) {
console.error(e);
showError(this.t("memories", "Error while moving {basename}", p));
failures++;
} finally {
this.processing++;
} }
});
for await (const _ of dav.runInParallel(calls, 10)) {
// nothing to do
}
try { // Go to new face
// Get all files for current face if (failures === 0) {
let res = await client.getDirectoryContents( this.$router.push({
`/recognize/${user}/faces/${name}`, { details: true } name: "people",
) as any; params: { user: face.user_id, name: newName },
let data: IFileInfo[] = res.data; });
this.procesingTotal = data.length; this.close();
}
// Don't try too much } catch (error) {
let failures = 0; console.error(error);
showError(this.t("photos", "Failed to move {name}.", { name }));
// Create move calls
const calls = data.map((p) => async () => {
// Short circuit if we have too many failures
if (failures === 10) {
showError(this.t('memories', 'Too many failures, aborting'));
failures++;
}
if (failures >= 10) return;
// Move to new face with webdav
try {
await client.moveFile(
`/recognize/${user}/faces/${name}/${p.basename}`,
`/recognize/${face.user_id}/faces/${newName}/${p.basename}`
)
} catch (e) {
console.error(e);
showError(this.t('memories', 'Error while moving {basename}', p));
failures++;
} finally {
this.processing++;
}
});
for await (const _ of dav.runInParallel(calls, 10)) {
// nothing to do
}
// Go to new face
if (failures === 0) {
this.$router.push({ name: 'people', params: { user: face.user_id, name: newName } });
this.close();
}
} catch (error) {
console.error(error);
showError(this.t('photos', 'Failed to move {name}.', { name }));
}
} }
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.outer { .outer {
margin-top: 15px; margin-top: 15px;
} }
.info-pad { .info-pad {
margin-top: 6px; margin-top: 6px;
margin-bottom: 2px; margin-bottom: 2px;
} }
</style> </style>

View File

@ -1,129 +1,141 @@
<template> <template>
<Modal @close="close" size="large" v-if="show"> <Modal @close="close" size="large" v-if="show">
<template #title> <template #title>
{{ t('memories', 'Move selected photos to person') }} {{ t("memories", "Move selected photos to person") }}
</template> </template>
<div class="outer"> <div class="outer">
<FaceList @select="clickFace" /> <FaceList @select="clickFace" />
</div> </div>
<template #buttons> <template #buttons>
<NcButton @click="close" class="button" type="error"> <NcButton @click="close" class="button" type="error">
{{ t('memories', 'Cancel') }} {{ t("memories", "Cancel") }}
</NcButton> </NcButton>
</template> </template>
</Modal> </Modal>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator'; import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import { NcButton, NcTextField } from '@nextcloud/vue'; import { NcButton, NcTextField } from "@nextcloud/vue";
import { showError } from '@nextcloud/dialogs'; import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from '@nextcloud/auth'; import { getCurrentUser } from "@nextcloud/auth";
import { IPhoto, ITag } from '../../types'; import { IPhoto, ITag } from "../../types";
import Tag from '../frame/Tag.vue'; import Tag from "../frame/Tag.vue";
import FaceList from './FaceList.vue'; import FaceList from "./FaceList.vue";
import Modal from './Modal.vue'; import Modal from "./Modal.vue";
import client from '../../services/DavClient'; import client from "../../services/DavClient";
import * as dav from '../../services/DavRequests'; import * as dav from "../../services/DavRequests";
@Component({ @Component({
components: { components: {
NcButton, NcButton,
NcTextField, NcTextField,
Modal, Modal,
Tag, Tag,
FaceList, FaceList,
} },
}) })
export default class FaceMoveModal extends Mixins(GlobalMixin) { export default class FaceMoveModal extends Mixins(GlobalMixin) {
private show = false; private show = false;
private photos: IPhoto[] = []; private photos: IPhoto[] = [];
@Prop() @Prop()
private updateLoading: (delta: number) => void; private updateLoading: (delta: number) => void;
public open(photos: IPhoto[]) { public open(photos: IPhoto[]) {
if (this.photos.length) { if (this.photos.length) {
// is processing // is processing
return; return;
}
// check ownership
const user = this.$route.params.user || '';
if (this.$route.params.user !== getCurrentUser().uid) {
showError(this.t('memories', 'Only user "{user}" can update this person', { user }));
return;
}
this.show = true;
this.photos = photos;
} }
@Emit('close') // check ownership
public close() { const user = this.$route.params.user || "";
this.photos = []; if (this.$route.params.user !== getCurrentUser().uid) {
this.show = false; showError(
this.t("memories", 'Only user "{user}" can update this person', {
user,
})
);
return;
} }
@Emit('moved') this.show = true;
public moved(list: IPhoto[]) {} this.photos = photos;
}
public async clickFace(face: ITag) { @Emit("close")
const user = this.$route.params.user || ''; public close() {
const name = this.$route.params.name || ''; this.photos = [];
this.show = false;
}
const newName = face.name || face.fileid.toString(); @Emit("moved")
public moved(list: IPhoto[]) {}
if (!confirm(this.t('memories', 'Are you sure you want to move the selected photos from {name} to {newName}?', { name, newName}))) { public async clickFace(face: ITag) {
return; const user = this.$route.params.user || "";
} const name = this.$route.params.name || "";
try { const newName = face.name || face.fileid.toString();
this.show = false;
this.updateLoading(1);
// Create map to return IPhoto later if (
let photoMap = new Map<number, IPhoto>(); !confirm(
for (const photo of this.photos) { this.t(
photoMap.set(photo.fileid, photo); "memories",
} "Are you sure you want to move the selected photos from {name} to {newName}?",
{ name, newName }
let data = await dav.getFiles(this.photos.map(p => p.fileid)); )
)
// Create move calls ) {
const calls = data.map((p) => async () => { return;
try {
await client.moveFile(
`/recognize/${user}/faces/${name}/${p.fileid}-${p.basename}`,
`/recognize/${face.user_id}/faces/${newName}/${p.fileid}-${p.basename}`
)
return photoMap.get(p.fileid);
} catch (e) {
console.error(e);
showError(this.t('memories', 'Error while moving {basename}', p));
}
});
for await (const resp of dav.runInParallel(calls, 10)) {
this.moved(resp);
}
} catch (error) {
console.error(error);
showError(this.t('photos', 'Failed to move {name}.', { name }));
} finally {
this.updateLoading(-1);
this.close();
}
} }
try {
this.show = false;
this.updateLoading(1);
// Create map to return IPhoto later
let photoMap = new Map<number, IPhoto>();
for (const photo of this.photos) {
photoMap.set(photo.fileid, photo);
}
let data = await dav.getFiles(this.photos.map((p) => p.fileid));
// Create move calls
const calls = data.map((p) => async () => {
try {
await client.moveFile(
`/recognize/${user}/faces/${name}/${p.fileid}-${p.basename}`,
`/recognize/${face.user_id}/faces/${newName}/${p.fileid}-${p.basename}`
);
return photoMap.get(p.fileid);
} catch (e) {
console.error(e);
showError(this.t("memories", "Error while moving {basename}", p));
}
});
for await (const resp of dav.runInParallel(calls, 10)) {
this.moved(resp);
}
} catch (error) {
console.error(error);
showError(this.t("photos", "Failed to move {name}.", { name }));
} finally {
this.updateLoading(-1);
this.close();
}
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.outer { .outer {
margin-top: 15px; margin-top: 15px;
} }
</style> </style>

View File

@ -1,56 +1,53 @@
<template> <template>
<NcModal <NcModal :size="size" @close="close" :outTransition="true">
:size="size" <div class="container">
@close="close" <div class="head">
:outTransition="true"> <span> <slot name="title"></slot> </span>
<div class="container"> </div>
<div class="head">
<span> <slot name="title"></slot> </span>
</div>
<slot></slot> <slot></slot>
<div class="buttons"> <div class="buttons">
<slot name="buttons"></slot> <slot name="buttons"></slot>
</div> </div>
</div> </div>
</NcModal> </NcModal>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Emit, Prop, Vue } from 'vue-property-decorator'; import { Component, Emit, Prop, Vue } from "vue-property-decorator";
import { NcModal } from '@nextcloud/vue'; import { NcModal } from "@nextcloud/vue";
@Component({ @Component({
components: { components: {
NcModal, NcModal,
} },
}) })
export default class Modal extends Vue { export default class Modal extends Vue {
@Prop({default: 'small'}) private size?: string; @Prop({ default: "small" }) private size?: string;
@Emit('close') @Emit("close")
public close() {} public close() {}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.container { .container {
margin: 20px; margin: 20px;
.head { .head {
font-weight: 500; font-weight: 500;
font-size: 1.15em; font-size: 1.15em;
margin-bottom: 5px; margin-bottom: 5px;
} }
:deep .buttons { :deep .buttons {
margin-top: 10px; margin-top: 10px;
text-align: right; text-align: right;
> button { > button {
display: inline-block !important; display: inline-block !important;
}
} }
}
} }
</style> </style>

View File

@ -1,123 +1,139 @@
<template> <template>
<div class="album-top-matter"> <div class="album-top-matter">
<NcActions v-if="!isAlbumList"> <NcActions v-if="!isAlbumList">
<NcActionButton :aria-label="t('memories', 'Back')" @click="back()"> <NcActionButton :aria-label="t('memories', 'Back')" @click="back()">
{{ t('memories', 'Back') }} {{ t("memories", "Back") }}
<template #icon> <BackIcon :size="20" /> </template> <template #icon> <BackIcon :size="20" /> </template>
</NcActionButton> </NcActionButton>
</NcActions> </NcActions>
<div class="name">{{ name }}</div> <div class="name">{{ name }}</div>
<div class="right-actions"> <div class="right-actions">
<NcActions :inline="1"> <NcActions :inline="1">
<NcActionButton :aria-label="t('memories', 'Create new album')" @click="$refs.createModal.open(false)" close-after-click <NcActionButton
v-if="isAlbumList"> :aria-label="t('memories', 'Create new album')"
{{ t('memories', 'Create new album') }} @click="$refs.createModal.open(false)"
<template #icon> <PlusIcon :size="20" /> </template> close-after-click
</NcActionButton> v-if="isAlbumList"
<NcActionButton :aria-label="t('memories', 'Share album')" @click="$refs.shareModal.open(false)" close-after-click >
v-if="!isAlbumList"> {{ t("memories", "Create new album") }}
{{ t('memories', 'Share album') }} <template #icon> <PlusIcon :size="20" /> </template>
<template #icon> <ShareIcon :size="20" /> </template> </NcActionButton>
</NcActionButton> <NcActionButton
<NcActionButton :aria-label="t('memories', 'Edit album details')" @click="$refs.createModal.open(true)" close-after-click :aria-label="t('memories', 'Share album')"
v-if="!isAlbumList"> @click="$refs.shareModal.open(false)"
{{ t('memories', 'Edit album details') }} close-after-click
<template #icon> <EditIcon :size="20" /> </template> v-if="!isAlbumList"
</NcActionButton> >
<NcActionButton :aria-label="t('memories', 'Delete album')" @click="$refs.deleteModal.open()" close-after-click {{ t("memories", "Share album") }}
v-if="!isAlbumList"> <template #icon> <ShareIcon :size="20" /> </template>
{{ t('memories', 'Delete album') }} </NcActionButton>
<template #icon> <DeleteIcon :size="20" /> </template> <NcActionButton
</NcActionButton> :aria-label="t('memories', 'Edit album details')"
</NcActions> @click="$refs.createModal.open(true)"
</div> close-after-click
v-if="!isAlbumList"
<AlbumCreateModal ref="createModal" /> >
<AlbumDeleteModal ref="deleteModal" /> {{ t("memories", "Edit album details") }}
<AlbumShareModal ref="shareModal" /> <template #icon> <EditIcon :size="20" /> </template>
</NcActionButton>
<NcActionButton
:aria-label="t('memories', 'Delete album')"
@click="$refs.deleteModal.open()"
close-after-click
v-if="!isAlbumList"
>
{{ t("memories", "Delete album") }}
<template #icon> <DeleteIcon :size="20" /> </template>
</NcActionButton>
</NcActions>
</div> </div>
<AlbumCreateModal ref="createModal" />
<AlbumDeleteModal ref="deleteModal" />
<AlbumShareModal ref="shareModal" />
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Mixins, Watch } from 'vue-property-decorator'; import { Component, Mixins, Watch } from "vue-property-decorator";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import UserConfig from "../../mixins/UserConfig"; import UserConfig from "../../mixins/UserConfig";
import AlbumCreateModal from '../modal/AlbumCreateModal.vue'; import AlbumCreateModal from "../modal/AlbumCreateModal.vue";
import AlbumDeleteModal from '../modal/AlbumDeleteModal.vue'; import AlbumDeleteModal from "../modal/AlbumDeleteModal.vue";
import AlbumShareModal from '../modal/AlbumShareModal.vue'; import AlbumShareModal from "../modal/AlbumShareModal.vue";
import { NcActions, NcActionButton, NcActionCheckbox } from '@nextcloud/vue'; import { NcActions, NcActionButton, NcActionCheckbox } from "@nextcloud/vue";
import BackIcon from 'vue-material-design-icons/ArrowLeft.vue'; import BackIcon from "vue-material-design-icons/ArrowLeft.vue";
import EditIcon from 'vue-material-design-icons/Pencil.vue'; import EditIcon from "vue-material-design-icons/Pencil.vue";
import DeleteIcon from 'vue-material-design-icons/Close.vue'; import DeleteIcon from "vue-material-design-icons/Close.vue";
import PlusIcon from 'vue-material-design-icons/Plus.vue'; import PlusIcon from "vue-material-design-icons/Plus.vue";
import ShareIcon from 'vue-material-design-icons/ShareVariant.vue'; import ShareIcon from "vue-material-design-icons/ShareVariant.vue";
@Component({ @Component({
components: { components: {
NcActions, NcActions,
NcActionButton, NcActionButton,
NcActionCheckbox, NcActionCheckbox,
AlbumCreateModal, AlbumCreateModal,
AlbumDeleteModal, AlbumDeleteModal,
AlbumShareModal, AlbumShareModal,
BackIcon, BackIcon,
EditIcon, EditIcon,
DeleteIcon, DeleteIcon,
PlusIcon, PlusIcon,
ShareIcon, ShareIcon,
}, },
}) })
export default class AlbumTopMatter extends Mixins(GlobalMixin, UserConfig) { export default class AlbumTopMatter extends Mixins(GlobalMixin, UserConfig) {
private name: string = ''; private name: string = "";
get isAlbumList() { get isAlbumList() {
return !Boolean(this.$route.params.name); return !Boolean(this.$route.params.name);
} }
@Watch('$route') @Watch("$route")
async routeChange(from: any, to: any) { async routeChange(from: any, to: any) {
this.createMatter(); this.createMatter();
} }
mounted() { mounted() {
this.createMatter(); this.createMatter();
} }
createMatter() { createMatter() {
this.name = this.$route.params.name || this.t('memories', 'Albums'); this.name = this.$route.params.name || this.t("memories", "Albums");
} }
back() { back() {
this.$router.push({ name: 'albums' }); this.$router.push({ name: "albums" });
} }
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.album-top-matter { .album-top-matter {
display: flex; display: flex;
vertical-align: middle; vertical-align: middle;
.name { .name {
font-size: 1.3em; font-size: 1.3em;
font-weight: 400; font-weight: 400;
line-height: 40px; line-height: 40px;
padding-left: 3px; padding-left: 3px;
flex-grow: 1; flex-grow: 1;
} }
.right-actions { .right-actions {
margin-right: 40px; margin-right: 40px;
z-index: 50; z-index: 50;
@media (max-width: 768px) { @media (max-width: 768px) {
margin-right: 10px; margin-right: 10px;
}
} }
}
} }
</style> </style>

View File

@ -1,116 +1,135 @@
<template> <template>
<div v-if="name" class="face-top-matter"> <div v-if="name" class="face-top-matter">
<NcActions> <NcActions>
<NcActionButton :aria-label="t('memories', 'Back')" @click="back()"> <NcActionButton :aria-label="t('memories', 'Back')" @click="back()">
{{ t('memories', 'Back') }} {{ t("memories", "Back") }}
<template #icon> <BackIcon :size="20" /> </template> <template #icon> <BackIcon :size="20" /> </template>
</NcActionButton> </NcActionButton>
</NcActions> </NcActions>
<div class="name">{{ name }}</div> <div class="name">{{ name }}</div>
<div class="right-actions"> <div class="right-actions">
<NcActions :inline="1"> <NcActions :inline="1">
<NcActionButton :aria-label="t('memories', 'Rename person')" @click="$refs.editModal.open()" close-after-click> <NcActionButton
{{ t('memories', 'Rename person') }} :aria-label="t('memories', 'Rename person')"
<template #icon> <EditIcon :size="20" /> </template> @click="$refs.editModal.open()"
</NcActionButton> close-after-click
<NcActionButton :aria-label="t('memories', 'Merge with different person')" @click="$refs.mergeModal.open()" close-after-click> >
{{ t('memories', 'Merge with different person') }} {{ t("memories", "Rename person") }}
<template #icon> <MergeIcon :size="20" /> </template> <template #icon> <EditIcon :size="20" /> </template>
</NcActionButton> </NcActionButton>
<NcActionCheckbox :aria-label="t('memories', 'Mark person in preview')" :checked.sync="config_showFaceRect" @change="changeShowFaceRect"> <NcActionButton
{{ t('memories', 'Mark person in preview') }} :aria-label="t('memories', 'Merge with different person')"
</NcActionCheckbox> @click="$refs.mergeModal.open()"
<NcActionButton :aria-label="t('memories', 'Remove person')" @click="$refs.deleteModal.open()" close-after-click> close-after-click
{{ t('memories', 'Remove person') }} >
<template #icon> <DeleteIcon :size="20" /> </template> {{ t("memories", "Merge with different person") }}
</NcActionButton> <template #icon> <MergeIcon :size="20" /> </template>
</NcActions> </NcActionButton>
</div> <NcActionCheckbox
:aria-label="t('memories', 'Mark person in preview')"
<FaceEditModal ref="editModal" /> :checked.sync="config_showFaceRect"
<FaceDeleteModal ref="deleteModal" /> @change="changeShowFaceRect"
<FaceMergeModal ref="mergeModal" /> >
{{ t("memories", "Mark person in preview") }}
</NcActionCheckbox>
<NcActionButton
:aria-label="t('memories', 'Remove person')"
@click="$refs.deleteModal.open()"
close-after-click
>
{{ t("memories", "Remove person") }}
<template #icon> <DeleteIcon :size="20" /> </template>
</NcActionButton>
</NcActions>
</div> </div>
<FaceEditModal ref="editModal" />
<FaceDeleteModal ref="deleteModal" />
<FaceMergeModal ref="mergeModal" />
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Mixins, Watch } from 'vue-property-decorator'; import { Component, Mixins, Watch } from "vue-property-decorator";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import UserConfig from "../../mixins/UserConfig"; import UserConfig from "../../mixins/UserConfig";
import { NcActions, NcActionButton, NcActionCheckbox } from '@nextcloud/vue'; import { NcActions, NcActionButton, NcActionCheckbox } from "@nextcloud/vue";
import FaceEditModal from '../modal/FaceEditModal.vue'; import FaceEditModal from "../modal/FaceEditModal.vue";
import FaceDeleteModal from '../modal/FaceDeleteModal.vue'; import FaceDeleteModal from "../modal/FaceDeleteModal.vue";
import FaceMergeModal from '../modal/FaceMergeModal.vue'; import FaceMergeModal from "../modal/FaceMergeModal.vue";
import BackIcon from 'vue-material-design-icons/ArrowLeft.vue'; import BackIcon from "vue-material-design-icons/ArrowLeft.vue";
import EditIcon from 'vue-material-design-icons/Pencil.vue'; import EditIcon from "vue-material-design-icons/Pencil.vue";
import DeleteIcon from 'vue-material-design-icons/Close.vue'; import DeleteIcon from "vue-material-design-icons/Close.vue";
import MergeIcon from 'vue-material-design-icons/Merge.vue'; import MergeIcon from "vue-material-design-icons/Merge.vue";
@Component({ @Component({
components: { components: {
NcActions, NcActions,
NcActionButton, NcActionButton,
NcActionCheckbox, NcActionCheckbox,
FaceEditModal, FaceEditModal,
FaceDeleteModal, FaceDeleteModal,
FaceMergeModal, FaceMergeModal,
BackIcon, BackIcon,
EditIcon, EditIcon,
DeleteIcon, DeleteIcon,
MergeIcon, MergeIcon,
}, },
}) })
export default class FaceTopMatter extends Mixins(GlobalMixin, UserConfig) { export default class FaceTopMatter extends Mixins(GlobalMixin, UserConfig) {
private name: string = ''; private name: string = "";
@Watch('$route') @Watch("$route")
async routeChange(from: any, to: any) { async routeChange(from: any, to: any) {
this.createMatter(); this.createMatter();
} }
mounted() { mounted() {
this.createMatter(); this.createMatter();
} }
createMatter() { createMatter() {
this.name = this.$route.params.name || ''; this.name = this.$route.params.name || "";
} }
back() { back() {
this.$router.push({ name: 'people' }); this.$router.push({ name: "people" });
} }
changeShowFaceRect() { changeShowFaceRect() {
localStorage.setItem('memories_showFaceRect', this.config_showFaceRect ? '1' : '0'); localStorage.setItem(
setTimeout(() => { "memories_showFaceRect",
this.$router.go(0); // refresh page this.config_showFaceRect ? "1" : "0"
}, 500); );
} setTimeout(() => {
this.$router.go(0); // refresh page
}, 500);
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.face-top-matter { .face-top-matter {
display: flex; display: flex;
vertical-align: middle; vertical-align: middle;
.name { .name {
font-size: 1.3em; font-size: 1.3em;
font-weight: 400; font-weight: 400;
line-height: 40px; line-height: 40px;
padding-left: 3px; padding-left: 3px;
flex-grow: 1; flex-grow: 1;
} }
.right-actions { .right-actions {
margin-right: 40px; margin-right: 40px;
z-index: 50; z-index: 50;
@media (max-width: 768px) { @media (max-width: 768px) {
margin-right: 10px; margin-right: 10px;
}
} }
}
} }
</style> </style>

View File

@ -1,60 +1,66 @@
<template> <template>
<NcBreadcrumbs v-if="topMatter"> <NcBreadcrumbs v-if="topMatter">
<NcBreadcrumb title="Home" :to="{ name: 'folders' }"> <NcBreadcrumb title="Home" :to="{ name: 'folders' }">
<template #icon> <template #icon>
<HomeIcon :size="20" /> <HomeIcon :size="20" />
</template> </template>
</NcBreadcrumb> </NcBreadcrumb>
<NcBreadcrumb v-for="folder in topMatter.list" :key="folder.path" :title="folder.text" <NcBreadcrumb
:to="{ name: 'folders', params: { path: folder.path }}" /> v-for="folder in topMatter.list"
</NcBreadcrumbs> :key="folder.path"
:title="folder.text"
:to="{ name: 'folders', params: { path: folder.path } }"
/>
</NcBreadcrumbs>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Mixins, Watch } from 'vue-property-decorator'; import { Component, Mixins, Watch } from "vue-property-decorator";
import { TopMatterFolder, TopMatterType } from "../../types"; import { TopMatterFolder, TopMatterType } from "../../types";
import { NcBreadcrumbs, NcBreadcrumb } from '@nextcloud/vue'; import { NcBreadcrumbs, NcBreadcrumb } from "@nextcloud/vue";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import HomeIcon from 'vue-material-design-icons/Home.vue'; import HomeIcon from "vue-material-design-icons/Home.vue";
@Component({ @Component({
components: { components: {
NcBreadcrumbs, NcBreadcrumbs,
NcBreadcrumb, NcBreadcrumb,
HomeIcon, HomeIcon,
} },
}) })
export default class FolderTopMatter extends Mixins(GlobalMixin) { export default class FolderTopMatter extends Mixins(GlobalMixin) {
private topMatter?: TopMatterFolder = null; private topMatter?: TopMatterFolder = null;
@Watch('$route') @Watch("$route")
async routeChange(from: any, to: any) { async routeChange(from: any, to: any) {
this.createMatter(); this.createMatter();
} }
mounted() { mounted() {
this.createMatter(); this.createMatter();
} }
createMatter() { createMatter() {
if (this.$route.name === 'folders') { if (this.$route.name === "folders") {
let path: any = this.$route.params.path || ''; let path: any = this.$route.params.path || "";
if (typeof path === 'string') { if (typeof path === "string") {
path = path.split('/'); path = path.split("/");
} }
this.topMatter = { this.topMatter = {
type: TopMatterType.FOLDER, type: TopMatterType.FOLDER,
list: path.filter(x => x).map((x, idx, arr) => { list: path
return { .filter((x) => x)
text: x, .map((x, idx, arr) => {
path: arr.slice(0, idx + 1).join('/'), return {
} text: x,
}), path: arr.slice(0, idx + 1).join("/"),
}; };
} else { }),
this.topMatter = null; };
} } else {
this.topMatter = null;
} }
}
} }
</script> </script>

View File

@ -1,190 +1,210 @@
<template> <template>
<div class="outer" v-show="years.length > 0"> <div class="outer" v-show="years.length > 0">
<div class="inner" ref="inner"> <div class="inner" ref="inner">
<div v-for="year of years" class="group" :key="year.year" @click="click(year)"> <div
<img class="fill-block" v-for="year of years"
:src="year.url" /> class="group"
:key="year.year"
@click="click(year)"
>
<img class="fill-block" :src="year.url" />
<div class="overlay"> <div class="overlay">
{{ year.text }} {{ year.text }}
</div>
</div>
</div>
<div class="left-btn dir-btn" v-if="hasLeft">
<NcActions>
<NcActionButton
:aria-label="t('memories', 'Move left')"
@click="moveLeft">
{{ t('memories', 'Move left') }}
<template #icon> <LeftMoveIcon :size="28" /> </template>
</NcActionButton>
</NcActions>
</div>
<div class="right-btn dir-btn" v-if="hasRight">
<NcActions>
<NcActionButton
:aria-label="t('memories', 'Move right')"
@click="moveRight">
{{ t('memories', 'Move right') }}
<template #icon> <RightMoveIcon :size="28" /> </template>
</NcActionButton>
</NcActions>
</div> </div>
</div>
</div> </div>
<div class="left-btn dir-btn" v-if="hasLeft">
<NcActions>
<NcActionButton
:aria-label="t('memories', 'Move left')"
@click="moveLeft"
>
{{ t("memories", "Move left") }}
<template #icon> <LeftMoveIcon :size="28" /> </template>
</NcActionButton>
</NcActions>
</div>
<div class="right-btn dir-btn" v-if="hasRight">
<NcActions>
<NcActionButton
:aria-label="t('memories', 'Move right')"
@click="moveRight"
>
{{ t("memories", "Move right") }}
<template #icon> <RightMoveIcon :size="28" /> </template>
</NcActionButton>
</NcActions>
</div>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator'; import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import { NcActions, NcActionButton } from '@nextcloud/vue'; import { NcActions, NcActionButton } from "@nextcloud/vue";
import * as utils from "../../services/Utils"; import * as utils from "../../services/Utils";
import * as dav from '../../services/DavRequests'; import * as dav from "../../services/DavRequests";
import { ViewerManager } from "../../services/Viewer"; import { ViewerManager } from "../../services/Viewer";
import { IPhoto } from '../../types'; import { IPhoto } from "../../types";
import { getPreviewUrl } from "../../services/FileUtils"; import { getPreviewUrl } from "../../services/FileUtils";
import LeftMoveIcon from 'vue-material-design-icons/ChevronLeft.vue'; import LeftMoveIcon from "vue-material-design-icons/ChevronLeft.vue";
import RightMoveIcon from 'vue-material-design-icons/ChevronRight.vue'; import RightMoveIcon from "vue-material-design-icons/ChevronRight.vue";
interface IYear { interface IYear {
year: number; year: number;
url: string; url: string;
preview: IPhoto; preview: IPhoto;
photos: IPhoto[]; photos: IPhoto[];
text: string; text: string;
}; }
@Component({ @Component({
name: 'OnThisDay', name: "OnThisDay",
components: { components: {
NcActions, NcActions,
NcActionButton, NcActionButton,
LeftMoveIcon, LeftMoveIcon,
RightMoveIcon, RightMoveIcon,
} },
}) })
export default class OnThisDay extends Mixins(GlobalMixin) { export default class OnThisDay extends Mixins(GlobalMixin) {
private getPreviewUrl = getPreviewUrl; private getPreviewUrl = getPreviewUrl;
@Emit('load') @Emit("load")
onload() {} onload() {}
private years: IYear[] = [] private years: IYear[] = [];
private hasRight = false; private hasRight = false;
private hasLeft = false; private hasLeft = false;
private scrollStack: number[] = []; private scrollStack: number[] = [];
/** /**
* Nextcloud viewer proxy * Nextcloud viewer proxy
* Can't use the timeline instance because these photos * Can't use the timeline instance because these photos
* might not be in view, so can't delete them * might not be in view, so can't delete them
*/ */
@Prop() @Prop()
private viewerManager!: ViewerManager; private viewerManager!: ViewerManager;
mounted() { mounted() {
const inner = this.$refs.inner as HTMLElement; const inner = this.$refs.inner as HTMLElement;
inner.addEventListener('scroll', this.onScroll.bind(this)); inner.addEventListener("scroll", this.onScroll.bind(this));
this.refresh(); this.refresh();
}
async refresh() {
// Look for cache
const dayIdToday = utils.dateToDayId(new Date());
const cacheUrl = `/onthisday/${dayIdToday}`;
const cache = await utils.getCachedData<IPhoto[]>(cacheUrl);
if (cache) this.process(cache);
// Network request
const photos = await dav.getOnThisDayRaw();
utils.cacheData(cacheUrl, photos);
// Check if exactly same as cache
if (
cache?.length === photos.length &&
cache.every((p, i) => p.fileid === photos[i].fileid)
)
return;
this.process(photos);
}
async process(photos: IPhoto[]) {
this.years = [];
let currentYear = 9999;
for (const photo of photos) {
const dateTaken = utils.dayIdToDate(photo.dayid);
const year = dateTaken.getUTCFullYear();
if (year !== currentYear) {
this.years.push({
year,
url: "",
preview: null,
photos: [],
text: utils.getFromNowStr(dateTaken),
});
currentYear = year;
}
const yearObj = this.years[this.years.length - 1];
yearObj.photos.push(photo);
} }
async refresh() { // For each year, randomly choose 10 photos to display
// Look for cache for (const year of this.years) {
const dayIdToday = utils.dateToDayId(new Date()); year.photos = utils.randomSubarray(year.photos, 10);
const cacheUrl = `/onthisday/${dayIdToday}`;
const cache = await utils.getCachedData<IPhoto[]>(cacheUrl);
if (cache) this.process(cache);
// Network request
const photos = await dav.getOnThisDayRaw();
utils.cacheData(cacheUrl, photos);
// Check if exactly same as cache
if (cache?.length === photos.length &&
cache.every((p, i) => p.fileid === photos[i].fileid)) return;
this.process(photos);
} }
async process(photos: IPhoto[]) { // Choose preview photo
this.years = []; for (const year of this.years) {
// Try to prioritize landscape photos on desktop
if (window.innerWidth <= 600) {
const landscape = year.photos.filter((p) => p.w > p.h);
year.preview = utils.randomChoice(landscape);
}
let currentYear = 9999; // Get random photo
year.preview ||= utils.randomChoice(year.photos);
for (const photo of photos) { year.url = getPreviewUrl(
const dateTaken = utils.dayIdToDate(photo.dayid); year.preview.fileid,
const year = dateTaken.getUTCFullYear(); year.preview.etag,
false,
if (year !== currentYear) { 512
this.years.push({ );
year,
url: '',
preview: null,
photos: [],
text: utils.getFromNowStr(dateTaken),
});
currentYear = year;
}
const yearObj = this.years[this.years.length - 1];
yearObj.photos.push(photo);
}
// For each year, randomly choose 10 photos to display
for (const year of this.years) {
year.photos = utils.randomSubarray(year.photos, 10);
}
// Choose preview photo
for (const year of this.years) {
// Try to prioritize landscape photos on desktop
if (window.innerWidth <= 600) {
const landscape = year.photos.filter(p => p.w > p.h);
year.preview = utils.randomChoice(landscape)
}
// Get random photo
year.preview ||= utils.randomChoice(year.photos);
year.url = getPreviewUrl(year.preview.fileid, year.preview.etag, false, 512);
}
await this.$nextTick();
this.onScroll();
this.onload();
} }
moveLeft() { await this.$nextTick();
const inner = this.$refs.inner as HTMLElement; this.onScroll();
inner.scrollBy(-(this.scrollStack.pop() || inner.clientWidth), 0); this.onload();
} }
moveRight() { moveLeft() {
const inner = this.$refs.inner as HTMLElement; const inner = this.$refs.inner as HTMLElement;
const innerRect = inner.getBoundingClientRect(); inner.scrollBy(-(this.scrollStack.pop() || inner.clientWidth), 0);
const nextChild = Array.from(inner.children).map(c => c.getBoundingClientRect()).find((rect) => }
rect.right > innerRect.right
);
let scroll = nextChild ? (nextChild.left - innerRect.left) : inner.clientWidth; moveRight() {
scroll = Math.min(inner.scrollWidth - inner.scrollLeft - inner.clientWidth, scroll); const inner = this.$refs.inner as HTMLElement;
this.scrollStack.push(scroll); const innerRect = inner.getBoundingClientRect();
inner.scrollBy(scroll, 0); const nextChild = Array.from(inner.children)
} .map((c) => c.getBoundingClientRect())
.find((rect) => rect.right > innerRect.right);
onScroll() { let scroll = nextChild
const inner = this.$refs.inner as HTMLElement; ? nextChild.left - innerRect.left
if (!inner) return; : inner.clientWidth;
this.hasLeft = inner.scrollLeft > 0; scroll = Math.min(
this.hasRight = (inner.clientWidth + inner.scrollLeft < inner.scrollWidth - 20); inner.scrollWidth - inner.scrollLeft - inner.clientWidth,
} scroll
);
this.scrollStack.push(scroll);
inner.scrollBy(scroll, 0);
}
click(year: IYear) { onScroll() {
const allPhotos = this.years.flatMap(y => y.photos); const inner = this.$refs.inner as HTMLElement;
this.viewerManager.open(year.preview, allPhotos); if (!inner) return;
} this.hasLeft = inner.scrollLeft > 0;
this.hasRight =
inner.clientWidth + inner.scrollLeft < inner.scrollWidth - 20;
}
click(year: IYear) {
const allPhotos = this.years.flatMap((y) => y.photos);
this.viewerManager.open(year.preview, allPhotos);
}
} }
</script> </script>
@ -193,97 +213,107 @@ $height: 200px;
$mobHeight: 150px; $mobHeight: 150px;
.outer { .outer {
width: calc(100% - 50px); width: calc(100% - 50px);
height: $height; height: $height;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
padding: 0 calc(28px * 0.6); padding: 0 calc(28px * 0.6);
// Sloppy: ideally this should be done in Timeline // Sloppy: ideally this should be done in Timeline
// to put a gap between the title and this // to put a gap between the title and this
margin-top: 10px; margin-top: 10px;
.inner {
height: calc(100% + 20px);
white-space: nowrap;
overflow-x: scroll;
scroll-behavior: smooth;
border-radius: 10px;
}
:deep .dir-btn button {
transform: scale(0.6);
box-shadow: var(--color-main-text) 0 0 3px 0 !important;
background-color: var(--color-main-background) !important;
}
.left-btn {
position: absolute;
top: 50%;
left: 0;
transform: translate(-10%, -50%);
}
.right-btn {
position: absolute;
top: 50%;
right: 0;
transform: translate(10%, -50%);
}
@media (max-width: 768px) {
width: 98%;
padding: 0;
.inner { .inner {
height: calc(100% + 20px); padding: 0 8px;
white-space: nowrap;
overflow-x: scroll;
scroll-behavior: smooth;
border-radius: 10px;
} }
.dir-btn {
:deep .dir-btn button { display: none;
transform: scale(0.6);
box-shadow: var(--color-main-text) 0 0 3px 0 !important;
background-color: var(--color-main-background) !important;
}
.left-btn {
position: absolute;
top: 50%; left: 0;
transform: translate(-10%, -50%);
}
.right-btn {
position: absolute;
top: 50%; right: 0;
transform: translate(10%, -50%);
}
@media (max-width: 768px) {
width: 98%;
padding: 0;
.inner { padding: 0 8px; }
.dir-btn { display: none; }
}
@media (max-width: 600px) {
height: $mobHeight;
} }
}
@media (max-width: 600px) {
height: $mobHeight;
}
} }
.group { .group {
height: $height; height: $height;
aspect-ratio: 4/3; aspect-ratio: 4/3;
display: inline-block; display: inline-block;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
&:not(:last-of-type) { margin-right: 8px; } &:not(:last-of-type) {
margin-right: 8px;
}
img { img {
cursor: inherit; cursor: inherit;
object-fit: cover; object-fit: cover;
border-radius: 10px; border-radius: 10px;
background-color: var(--color-background-dark); background-color: var(--color-background-dark);
background-clip: padding-box, content-box; background-clip: padding-box, content-box;
} }
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.2);
border-radius: 10px;
display: flex;
align-items: end;
justify-content: center;
color: white;
font-size: 1.2em;
padding: 5%;
white-space: normal;
cursor: inherit;
transition: background-color 0.2s ease-in-out;
}
&:hover .overlay {
background-color: transparent;
}
@media (max-width: 600px) {
aspect-ratio: 3/4;
height: $mobHeight;
.overlay { .overlay {
position: absolute; font-size: 1.1em;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.2);
border-radius: 10px;
display: flex;
align-items: end;
justify-content: center;
color: white;
font-size: 1.2em;
padding: 5%;
white-space: normal;
cursor: inherit;
transition: background-color 0.2s ease-in-out;
}
&:hover .overlay {
background-color: transparent;
}
@media (max-width: 600px) {
aspect-ratio: 3/4;
height: $mobHeight;
.overlay { font-size: 1.1em; }
} }
}
} }
</style> </style>

View File

@ -1,63 +1,63 @@
<template> <template>
<div v-if="name" class="tag-top-matter"> <div v-if="name" class="tag-top-matter">
<NcActions> <NcActions>
<NcActionButton :aria-label="t('memories', 'Back')" @click="back()"> <NcActionButton :aria-label="t('memories', 'Back')" @click="back()">
{{ t('memories', 'Back') }} {{ t("memories", "Back") }}
<template #icon> <BackIcon :size="20" /> </template> <template #icon> <BackIcon :size="20" /> </template>
</NcActionButton> </NcActionButton>
</NcActions> </NcActions>
<span class="name">{{ name }}</span> <span class="name">{{ name }}</span>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Mixins, Watch } from 'vue-property-decorator'; import { Component, Mixins, Watch } from "vue-property-decorator";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import { NcActions, NcActionButton } from '@nextcloud/vue'; import { NcActions, NcActionButton } from "@nextcloud/vue";
import BackIcon from 'vue-material-design-icons/ArrowLeft.vue'; import BackIcon from "vue-material-design-icons/ArrowLeft.vue";
@Component({ @Component({
components: { components: {
NcActions, NcActions,
NcActionButton, NcActionButton,
BackIcon, BackIcon,
}, },
}) })
export default class TagTopMatter extends Mixins(GlobalMixin) { export default class TagTopMatter extends Mixins(GlobalMixin) {
private name: string = ''; private name: string = "";
@Watch('$route') @Watch("$route")
async routeChange(from: any, to: any) { async routeChange(from: any, to: any) {
this.createMatter(); this.createMatter();
} }
mounted() { mounted() {
this.createMatter(); this.createMatter();
} }
createMatter() { createMatter() {
this.name = this.$route.params.name || ''; this.name = this.$route.params.name || "";
} }
back() { back() {
this.$router.push({ name: 'tags' }); this.$router.push({ name: "tags" });
} }
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.tag-top-matter { .tag-top-matter {
.name { .name {
font-size: 1.3em; font-size: 1.3em;
font-weight: 400; font-weight: 400;
line-height: 42px; line-height: 42px;
display: inline-block; display: inline-block;
vertical-align: top; vertical-align: top;
} }
button { button {
display: inline-block; display: inline-block;
} }
} }
</style> </style>

View File

@ -1,53 +1,62 @@
<template> <template>
<div class="top-matter" v-if="type"> <div class="top-matter" v-if="type">
<FolderTopMatter v-if="type === 1" /> <FolderTopMatter v-if="type === 1" />
<TagTopMatter v-else-if="type === 2" /> <TagTopMatter v-else-if="type === 2" />
<FaceTopMatter v-else-if="type === 3" /> <FaceTopMatter v-else-if="type === 3" />
<AlbumTopMatter v-else-if="type === 4" /> <AlbumTopMatter v-else-if="type === 4" />
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Mixins, Watch } from 'vue-property-decorator'; import { Component, Mixins, Watch } from "vue-property-decorator";
import FolderTopMatter from "./FolderTopMatter.vue"; import FolderTopMatter from "./FolderTopMatter.vue";
import TagTopMatter from "./TagTopMatter.vue"; import TagTopMatter from "./TagTopMatter.vue";
import FaceTopMatter from "./FaceTopMatter.vue"; import FaceTopMatter from "./FaceTopMatter.vue";
import AlbumTopMatter from "./AlbumTopMatter.vue"; import AlbumTopMatter from "./AlbumTopMatter.vue";
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from "../../mixins/GlobalMixin";
import { TopMatterType } from '../../types'; import { TopMatterType } from "../../types";
@Component({ @Component({
components: { components: {
FolderTopMatter, FolderTopMatter,
TagTopMatter, TagTopMatter,
FaceTopMatter, FaceTopMatter,
AlbumTopMatter, AlbumTopMatter,
}, },
}) })
export default class TopMatter extends Mixins(GlobalMixin) { export default class TopMatter extends Mixins(GlobalMixin) {
public type: TopMatterType = TopMatterType.NONE; public type: TopMatterType = TopMatterType.NONE;
@Watch('$route') @Watch("$route")
async routeChange(from: any, to: any) { async routeChange(from: any, to: any) {
this.setTopMatter(); this.setTopMatter();
} }
mounted() { mounted() {
this.setTopMatter(); this.setTopMatter();
} }
/** Create top matter */ /** Create top matter */
setTopMatter() { setTopMatter() {
this.type = (() => { this.type = (() => {
switch (this.$route.name) { switch (this.$route.name) {
case 'folders': return TopMatterType.FOLDER; case "folders":
case 'tags': return this.$route.params.name ? TopMatterType.TAG : TopMatterType.NONE; return TopMatterType.FOLDER;
case 'people': return this.$route.params.name ? TopMatterType.FACE : TopMatterType.NONE; case "tags":
case 'albums': return TopMatterType.ALBUM; return this.$route.params.name
default: return TopMatterType.NONE; ? TopMatterType.TAG
} : TopMatterType.NONE;
})(); case "people":
} return this.$route.params.name
? TopMatterType.FACE
: TopMatterType.NONE;
case "albums":
return TopMatterType.ALBUM;
default:
return TopMatterType.NONE;
}
})();
}
} }
</script> </script>

View File

@ -19,30 +19,34 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
* *
*/ */
import 'reflect-metadata' import "reflect-metadata";
import Vue from 'vue' import Vue from "vue";
import VueVirtualScroller from 'vue-virtual-scroller' import VueVirtualScroller from "vue-virtual-scroller";
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import App from './App.vue' import App from "./App.vue";
import router from './router' import router from "./router";
Vue.use(VueVirtualScroller) Vue.use(VueVirtualScroller);
// https://github.com/nextcloud/photos/blob/156f280c0476c483cb9ce81769ccb0c1c6500a4e/src/main.js // https://github.com/nextcloud/photos/blob/156f280c0476c483cb9ce81769ccb0c1c6500a4e/src/main.js
// TODO: remove when we have a proper fileinfo standalone library // TODO: remove when we have a proper fileinfo standalone library
// original scripts are loaded from // original scripts are loaded from
// https://github.com/nextcloud/server/blob/5bf3d1bb384da56adbf205752be8f840aac3b0c5/lib/private/legacy/template.php#L120-L122 // https://github.com/nextcloud/server/blob/5bf3d1bb384da56adbf205752be8f840aac3b0c5/lib/private/legacy/template.php#L120-L122
window.addEventListener('DOMContentLoaded', () => { window.addEventListener("DOMContentLoaded", () => {
if (!globalThis.OCA.Files) { if (!globalThis.OCA.Files) {
globalThis.OCA.Files = {} globalThis.OCA.Files = {};
} }
// register unused client for the sidebar to have access to its parser methods // register unused client for the sidebar to have access to its parser methods
Object.assign(globalThis.OCA.Files, { App: { fileList: { filesClient: globalThis.OC.Files.getClient() } } }, globalThis.OCA.Files) Object.assign(
}) globalThis.OCA.Files,
{ App: { fileList: { filesClient: globalThis.OC.Files.getClient() } } },
globalThis.OCA.Files
);
});
export default new Vue({ export default new Vue({
el: '#content', el: "#content",
router, router,
render: h => h(App), render: (h) => h(App),
}) });

View File

@ -1,13 +1,13 @@
import { Component, Vue } from 'vue-property-decorator'; import { Component, Vue } from "vue-property-decorator";
import { translate as t, translatePlural as n } from '@nextcloud/l10n' import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import { constants } from '../services/Utils'; import { constants } from "../services/Utils";
@Component @Component
export default class GlobalMixin extends Vue { export default class GlobalMixin extends Vue {
public readonly t = t; public readonly t = t;
public readonly n = n; public readonly n = n;
public readonly c = constants.c; public readonly c = constants.c;
public readonly TagDayID = constants.TagDayID; public readonly TagDayID = constants.TagDayID;
public readonly TagDayIDValueSet = constants.TagDayIDValueSet; public readonly TagDayIDValueSet = constants.TagDayIDValueSet;
} }

View File

@ -20,60 +20,60 @@
* *
*/ */
import { Component, Vue } from 'vue-property-decorator'; import { Component, Vue } from "vue-property-decorator";
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' import { emit, subscribe, unsubscribe } from "@nextcloud/event-bus";
import { generateUrl } from '@nextcloud/router' import { generateUrl } from "@nextcloud/router";
import { loadState } from '@nextcloud/initial-state' import { loadState } from "@nextcloud/initial-state";
import axios from '@nextcloud/axios' import axios from "@nextcloud/axios";
const eventName = 'memories:user-config-changed' const eventName = "memories:user-config-changed";
const localSettings = ['squareThumbs', 'showFaceRect']; const localSettings = ["squareThumbs", "showFaceRect"];
@Component @Component
export default class UserConfig extends Vue { export default class UserConfig extends Vue {
config_timelinePath: string = loadState('memories', 'timelinePath') || ''; config_timelinePath: string = loadState("memories", "timelinePath") || "";
config_foldersPath: string = loadState('memories', 'foldersPath') || '/'; config_foldersPath: string = loadState("memories", "foldersPath") || "/";
config_showHidden = loadState('memories', 'showHidden') === "true"; config_showHidden = loadState("memories", "showHidden") === "true";
config_tagsEnabled = Boolean(loadState('memories', 'systemtags')); config_tagsEnabled = Boolean(loadState("memories", "systemtags"));
config_recognizeEnabled = Boolean(loadState('memories', 'recognize')); config_recognizeEnabled = Boolean(loadState("memories", "recognize"));
config_mapsEnabled = Boolean(loadState('memories', 'maps')); config_mapsEnabled = Boolean(loadState("memories", "maps"));
config_albumsEnabled = Boolean(loadState('memories', 'albums')); config_albumsEnabled = Boolean(loadState("memories", "albums"));
config_squareThumbs = localStorage.getItem('memories_squareThumbs') === '1'; config_squareThumbs = localStorage.getItem("memories_squareThumbs") === "1";
config_showFaceRect = localStorage.getItem('memories_showFaceRect') === '1'; config_showFaceRect = localStorage.getItem("memories_showFaceRect") === "1";
config_eventName = eventName; config_eventName = eventName;
created() { created() {
subscribe(eventName, this.updateLocalSetting) subscribe(eventName, this.updateLocalSetting);
}
beforeDestroy() {
unsubscribe(eventName, this.updateLocalSetting);
}
updateLocalSetting({ setting, value }) {
this["config_" + setting] = value;
}
async updateSetting(setting: string) {
const value = this["config_" + setting];
if (localSettings.includes(setting)) {
if (typeof value === "boolean") {
localStorage.setItem("memories_" + setting, value ? "1" : "0");
} else {
localStorage.setItem("memories_" + setting, value);
}
} else {
// Long time save setting
await axios.put(generateUrl("apps/memories/api/config/" + setting), {
value: value.toString(),
});
} }
beforeDestroy() { // Visible elements update setting
unsubscribe(eventName, this.updateLocalSetting) emit(eventName, { setting, value });
} }
}
updateLocalSetting({ setting, value }) {
this['config_' + setting] = value
}
async updateSetting(setting: string) {
const value = this['config_' + setting]
if (localSettings.includes(setting)) {
if (typeof value === 'boolean') {
localStorage.setItem('memories_' + setting, value ? '1' : '0')
} else {
localStorage.setItem('memories_' + setting, value)
}
} else {
// Long time save setting
await axios.put(generateUrl('apps/memories/api/config/' + setting), {
value: value.toString(),
});
}
// Visible elements update setting
emit(eventName, { setting, value });
}
}

View File

@ -20,120 +20,120 @@
* *
*/ */
import { generateUrl } from '@nextcloud/router' import { generateUrl } from "@nextcloud/router";
import { translate as t, translatePlural as n } from '@nextcloud/l10n' import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import Router from 'vue-router' import Router from "vue-router";
import Vue from 'vue' import Vue from "vue";
import Timeline from './components/Timeline.vue'; import Timeline from "./components/Timeline.vue";
Vue.use(Router) Vue.use(Router);
/** /**
* Parse the path of a route : join the elements of the array and return a single string with slashes * Parse the path of a route : join the elements of the array and return a single string with slashes
* + always lead current path with a slash * + always lead current path with a slash
* *
* @param {string | Array} path path arguments to parse * @param {string | Array} path path arguments to parse
* @return {string} * @return {string}
*/ */
const parsePathParams = (path) => { const parsePathParams = (path) => {
return `/${Array.isArray(path) ? path.join('/') : path || ''}` return `/${Array.isArray(path) ? path.join("/") : path || ""}`;
} };
export default new Router({ export default new Router({
mode: 'history', mode: "history",
// if index.php is in the url AND we got this far, then it's working: // if index.php is in the url AND we got this far, then it's working:
// let's keep using index.php in the url // let's keep using index.php in the url
base: generateUrl('/apps/memories'), base: generateUrl("/apps/memories"),
linkActiveClass: 'active', linkActiveClass: "active",
routes: [ routes: [
{ {
path: '/', path: "/",
component: Timeline, component: Timeline,
name: 'timeline', name: "timeline",
props: route => ({ props: (route) => ({
rootTitle: t('memories', 'Timeline'), rootTitle: t("memories", "Timeline"),
}), }),
}, },
{ {
path: '/folders/:path*', path: "/folders/:path*",
component: Timeline, component: Timeline,
name: 'folders', name: "folders",
props: route => ({ props: (route) => ({
rootTitle: t('memories', 'Folders'), rootTitle: t("memories", "Folders"),
}), }),
}, },
{ {
path: '/favorites', path: "/favorites",
component: Timeline, component: Timeline,
name: 'favorites', name: "favorites",
props: route => ({ props: (route) => ({
rootTitle: t('memories', 'Favorites'), rootTitle: t("memories", "Favorites"),
}), }),
}, },
{ {
path: '/videos', path: "/videos",
component: Timeline, component: Timeline,
name: 'videos', name: "videos",
props: route => ({ props: (route) => ({
rootTitle: t('memories', 'Videos'), rootTitle: t("memories", "Videos"),
}), }),
}, },
{ {
path: '/albums/:user?/:name?', path: "/albums/:user?/:name?",
component: Timeline, component: Timeline,
name: 'albums', name: "albums",
props: route => ({ props: (route) => ({
rootTitle: t('memories', 'Albums'), rootTitle: t("memories", "Albums"),
}), }),
}, },
{ {
path: '/archive', path: "/archive",
component: Timeline, component: Timeline,
name: 'archive', name: "archive",
props: route => ({ props: (route) => ({
rootTitle: t('memories', 'Archive'), rootTitle: t("memories", "Archive"),
}), }),
}, },
{ {
path: '/thisday', path: "/thisday",
component: Timeline, component: Timeline,
name: 'thisday', name: "thisday",
props: route => ({ props: (route) => ({
rootTitle: t('memories', 'On this day'), rootTitle: t("memories", "On this day"),
}), }),
}, },
{ {
path: '/people/:user?/:name?', path: "/people/:user?/:name?",
component: Timeline, component: Timeline,
name: 'people', name: "people",
props: route => ({ props: (route) => ({
rootTitle: t('memories', 'People'), rootTitle: t("memories", "People"),
}), }),
}, },
{ {
path: '/tags/:name*', path: "/tags/:name*",
component: Timeline, component: Timeline,
name: 'tags', name: "tags",
props: route => ({ props: (route) => ({
rootTitle: t('memories', 'Tags'), rootTitle: t("memories", "Tags"),
}), }),
}, },
{ {
path: '/maps', path: "/maps",
name: 'maps', name: "maps",
// router-link doesn't support external url, let's force the redirect // router-link doesn't support external url, let's force the redirect
beforeEnter() { beforeEnter() {
window.open(generateUrl('/apps/maps'), '_blank') window.open(generateUrl("/apps/maps"), "_blank");
}, },
}, },
], ],
}) });

View File

@ -20,26 +20,30 @@
* *
*/ */
import * as webdav from 'webdav' import * as webdav from "webdav";
import axios from '@nextcloud/axios' import axios from "@nextcloud/axios";
import parseUrl from 'url-parse' import parseUrl from "url-parse";
import { generateRemoteUrl } from '@nextcloud/router' import { generateRemoteUrl } from "@nextcloud/router";
// Monkey business // Monkey business
import * as rq from 'webdav/dist/node/request'; import * as rq from "webdav/dist/node/request";
(<any>rq).prepareRequestOptionsOld = rq.prepareRequestOptions.bind(rq); (<any>rq).prepareRequestOptionsOld = rq.prepareRequestOptions.bind(rq);
(<any>rq).prepareRequestOptions = (function(requestOptions, context, userOptions) { (<any>rq).prepareRequestOptions = function (
requestOptions.method = userOptions.method || requestOptions.method; requestOptions,
return this.prepareRequestOptionsOld(requestOptions, context, userOptions); context,
}).bind(rq); userOptions
) {
requestOptions.method = userOptions.method || requestOptions.method;
return this.prepareRequestOptionsOld(requestOptions, context, userOptions);
}.bind(rq);
// force our axios // force our axios
const patcher = webdav.getPatcher() const patcher = webdav.getPatcher();
patcher.patch('request', axios) patcher.patch("request", axios);
// init webdav client on default dav endpoint // init webdav client on default dav endpoint
const remote = generateRemoteUrl('dav') const remote = generateRemoteUrl("dav");
const client = webdav.createClient(remote) const client = webdav.createClient(remote);
export const remotePath = parseUrl(remote).pathname export const remotePath = parseUrl(remote).pathname;
export default client export default client;

View File

@ -1,9 +1,9 @@
export * from './dav/base'; export * from "./dav/base";
export * from './dav/albums'; export * from "./dav/albums";
export * from './dav/archive'; export * from "./dav/archive";
export * from './dav/download'; export * from "./dav/download";
export * from './dav/face'; export * from "./dav/face";
export * from './dav/favorites'; export * from "./dav/favorites";
export * from './dav/folders'; export * from "./dav/folders";
export * from './dav/onthisday'; export * from "./dav/onthisday";
export * from './dav/tags'; export * from "./dav/tags";

View File

@ -19,113 +19,136 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
* *
*/ */
import camelcase from 'camelcase' import camelcase from "camelcase";
import { isNumber } from './NumberUtils' import { isNumber } from "./NumberUtils";
import { generateUrl } from '@nextcloud/router' import { generateUrl } from "@nextcloud/router";
/** /**
* Get an url encoded path * Get an url encoded path
* *
* @param {string} path the full path * @param {string} path the full path
* @return {string} url encoded file path * @return {string} url encoded file path
*/ */
const encodeFilePath = function(path) { const encodeFilePath = function (path) {
const pathSections = (path.startsWith('/') ? path : `/${path}`).split('/') const pathSections = (path.startsWith("/") ? path : `/${path}`).split("/");
let relativePath = '' let relativePath = "";
pathSections.forEach((section) => { pathSections.forEach((section) => {
if (section !== '') { if (section !== "") {
relativePath += '/' + encodeURIComponent(section) relativePath += "/" + encodeURIComponent(section);
} }
}) });
return relativePath return relativePath;
} };
/** /**
* Extract dir and name from file path * Extract dir and name from file path
* *
* @param {string} path the full path * @param {string} path the full path
* @return {string[]} [dirPath, fileName] * @return {string[]} [dirPath, fileName]
*/ */
const extractFilePaths = function(path) { const extractFilePaths = function (path) {
const pathSections = path.split('/') const pathSections = path.split("/");
const fileName = pathSections[pathSections.length - 1] const fileName = pathSections[pathSections.length - 1];
const dirPath = pathSections.slice(0, pathSections.length - 1).join('/') const dirPath = pathSections.slice(0, pathSections.length - 1).join("/");
return [dirPath, fileName] return [dirPath, fileName];
} };
/** /**
* Sorting comparison function * Sorting comparison function
* *
* @param {object} fileInfo1 file 1 fileinfo * @param {object} fileInfo1 file 1 fileinfo
* @param {object} fileInfo2 file 2 fileinfo * @param {object} fileInfo2 file 2 fileinfo
* @param {string} key key to sort with * @param {string} key key to sort with
* @param {boolean} [asc=true] sort ascending? * @param {boolean} [asc=true] sort ascending?
* @return {number} * @return {number}
*/ */
const sortCompare = function(fileInfo1, fileInfo2, key, asc = true) { const sortCompare = function (fileInfo1, fileInfo2, key, asc = true) {
// favorite always first
if (fileInfo1.isFavorite && !fileInfo2.isFavorite) {
return -1;
} else if (!fileInfo1.isFavorite && fileInfo2.isFavorite) {
return 1;
}
// favorite always first // if this is a number, let's sort by integer
if (fileInfo1.isFavorite && !fileInfo2.isFavorite) { if (isNumber(fileInfo1[key]) && isNumber(fileInfo2[key])) {
return -1 return asc
} else if (!fileInfo1.isFavorite && fileInfo2.isFavorite) { ? Number(fileInfo2[key]) - Number(fileInfo1[key])
return 1 : Number(fileInfo1[key]) - Number(fileInfo2[key]);
} }
// if this is a number, let's sort by integer // else we sort by string, so let's sort directories first
if (isNumber(fileInfo1[key]) && isNumber(fileInfo2[key])) { if (fileInfo1.type !== "file" && fileInfo2.type === "file") {
return asc return asc ? -1 : 1;
? Number(fileInfo2[key]) - Number(fileInfo1[key]) } else if (fileInfo1.type === "file" && fileInfo2.type !== "file") {
: Number(fileInfo1[key]) - Number(fileInfo2[key]) return asc ? 1 : -1;
} }
// else we sort by string, so let's sort directories first // if this is a date, let's sort by date
if (fileInfo1.type !== 'file' && fileInfo2.type === 'file') { if (
return asc ? -1 : 1 isNumber(new Date(fileInfo1[key]).getTime()) &&
} else if (fileInfo1.type === 'file' && fileInfo2.type !== 'file') { isNumber(new Date(fileInfo2[key]).getTime())
return asc ? 1 : -1 ) {
} return asc
? new Date(fileInfo2[key]).getTime() - new Date(fileInfo1[key]).getTime()
: new Date(fileInfo1[key]).getTime() - new Date(fileInfo2[key]).getTime();
}
// if this is a date, let's sort by date // finally sort by name
if (isNumber(new Date(fileInfo1[key]).getTime()) && isNumber(new Date(fileInfo2[key]).getTime())) { return asc
return asc ? fileInfo1[key]
? new Date(fileInfo2[key]).getTime() - new Date(fileInfo1[key]).getTime() ?.toString()
: new Date(fileInfo1[key]).getTime() - new Date(fileInfo2[key]).getTime() ?.localeCompare(
} fileInfo2[key].toString(),
globalThis.OC.getLanguage()
) || 1
: -fileInfo1[key]
?.toString()
?.localeCompare(
fileInfo2[key].toString(),
globalThis.OC.getLanguage()
) || -1;
};
// finally sort by name const genFileInfo = function (obj) {
return asc const fileInfo = {};
? fileInfo1[key]?.toString()?.localeCompare(fileInfo2[key].toString(), globalThis.OC.getLanguage()) || 1
: -fileInfo1[key]?.toString()?.localeCompare(fileInfo2[key].toString(), globalThis.OC.getLanguage()) || -1
}
const genFileInfo = function(obj) { Object.keys(obj).forEach((key) => {
const fileInfo = {} const data = obj[key];
Object.keys(obj).forEach(key => { // flatten object if any
const data = obj[key] if (!!data && typeof data === "object") {
Object.assign(fileInfo, genFileInfo(data));
} else {
// format key and add it to the fileInfo
if (data === "false") {
fileInfo[camelcase(key)] = false;
} else if (data === "true") {
fileInfo[camelcase(key)] = true;
} else {
fileInfo[camelcase(key)] = isNumber(data) ? Number(data) : data;
}
}
});
return fileInfo;
};
// flatten object if any const getPreviewUrl = function (
if (!!data && typeof data === 'object') { fileid: number,
Object.assign(fileInfo, genFileInfo(data)) etag: string,
} else { square: boolean,
// format key and add it to the fileInfo size: number
if (data === 'false') { ): string {
fileInfo[camelcase(key)] = false const a = square ? "0" : "1";
} else if (data === 'true') { return generateUrl(
fileInfo[camelcase(key)] = true `/core/preview?fileId=${fileid}&c=${etag}&x=${size}&y=${size}&forceIcon=0&a=${a}`
} else { );
fileInfo[camelcase(key)] = isNumber(data) };
? Number(data)
: data
}
}
})
return fileInfo
}
const getPreviewUrl = function(fileid: number, etag: string, square: boolean, size: number): string { export {
const a = square ? '0' : '1' encodeFilePath,
return generateUrl(`/core/preview?fileId=${fileid}&c=${etag}&x=${size}&y=${size}&forceIcon=0&a=${a}`); extractFilePaths,
} sortCompare,
genFileInfo,
export { encodeFilePath, extractFilePaths, sortCompare, genFileInfo, getPreviewUrl } getPreviewUrl,
};

View File

@ -7,221 +7,236 @@ import justifiedLayout from "justified-layout";
* Otherwise, use flickr/justified-layout (at least for now). * Otherwise, use flickr/justified-layout (at least for now).
*/ */
export function getLayout( export function getLayout(
input: { input: {
width: number, width: number;
height: number, height: number;
forceSquare: boolean, forceSquare: boolean;
}[], }[],
opts: { opts: {
rowWidth: number, rowWidth: number;
rowHeight: number, rowHeight: number;
squareMode: boolean, squareMode: boolean;
numCols: number, numCols: number;
allowBreakout: boolean, allowBreakout: boolean;
seed: number, seed: number;
} }
): { ): {
top: number, top: number;
left: number, left: number;
width: number, width: number;
height: number, height: number;
rowHeight?: number, rowHeight?: number;
}[] { }[] {
if (input.length === 0) return []; if (input.length === 0) return [];
if (!opts.squareMode) { if (!opts.squareMode) {
return justifiedLayout((input), { return justifiedLayout(input, {
containerPadding: 0, containerPadding: 0,
boxSpacing: 0, boxSpacing: 0,
containerWidth: opts.rowWidth, containerWidth: opts.rowWidth,
targetRowHeight: opts.rowHeight, targetRowHeight: opts.rowHeight,
targetRowHeightTolerance: 0.1, targetRowHeightTolerance: 0.1,
}).boxes; }).boxes;
}
// RNG
const rand = mulberry32(opts.seed);
// Binary flags
const FLAG_USE = 1 << 0;
const FLAG_USED = 1 << 1;
const FLAG_USE4 = 1 << 2;
const FLAG_USE6 = 1 << 3;
const FLAG_BREAKOUT = 1 << 4;
// Create 2d matrix to work in
const matrix: number[][] = [];
// Fill in the matrix
let row = 0;
let col = 0;
let photoId = 0;
while (photoId < input.length) {
// Check if we reached the end of row
if (col >= opts.numCols) {
row++;
col = 0;
} }
// RNG // Make sure we have this and the next few rows
const rand = mulberry32(opts.seed); while (row + 3 >= matrix.length) {
matrix.push(new Array(opts.numCols).fill(0));
// Binary flags
const FLAG_USE = 1 << 0;
const FLAG_USED = 1 << 1;
const FLAG_USE4 = 1 << 2;
const FLAG_USE6 = 1 << 3;
const FLAG_BREAKOUT = 1 << 4;
// Create 2d matrix to work in
const matrix: number[][] = [];
// Fill in the matrix
let row = 0;
let col = 0;
let photoId = 0;
while (photoId < input.length) {
// Check if we reached the end of row
if (col >= opts.numCols) {
row++; col = 0;
}
// Make sure we have this and the next few rows
while (row + 3 >= matrix.length) {
matrix.push(new Array(opts.numCols).fill(0));
}
// Check if already used
if (matrix[row][col] & FLAG_USED) {
col++; continue;
}
// Use this slot
matrix[row][col] |= FLAG_USE;
// Check if previous row has something used
// or something beside this is used
// We don't do these one after another
if (!opts.allowBreakout ||
(row > 0 && matrix[row-1].some(v => v & FLAG_USED)) ||
(col > 0 && matrix[row][col-1] & FLAG_USED)
) {
photoId++; col++; continue;
}
// Number of photos left
const numLeft = input.length-photoId-1;
// Number of photos needed for perfect fill after using n
const needFill = (n: number) => ((opts.numCols-col-2) + (n/2-1)*(opts.numCols-2));
let canUse4 =
// We have enough space
(row + 1 < matrix.length && col+1 < opts.numCols) &&
// This cannot end up being a widow (conservative)
// Also make sure the next row gets fully filled, otherwise looks weird
(numLeft === needFill(4) || numLeft >= needFill(4)+opts.numCols);
let canUse6 =
// Image is portrait
input[photoId].height > input[photoId].width &&
// We have enough space
(row + 2 < matrix.length && col+1 < opts.numCols) &&
// This cannot end up being a widow (conservative)
// Also make sure the next row gets fully filled, otherwise looks weird
(numLeft === needFill(6) || numLeft >= needFill(6)+2*opts.numCols);
let canBreakout =
// First column only
col === 0 &&
// Image is landscape
input[photoId].width > input[photoId].height &&
// The next row gets filled
(numLeft === 0 || numLeft >= opts.numCols);
// Probably folders or tags or faces
if (input[photoId].forceSquare) {
// We are square already. Everything below is else-if.
}
// Full width breakout
else if (canBreakout && rand() < (input.length > 0 ? 0.25 : 0.1)) {
matrix[row][col] |= FLAG_BREAKOUT;
for (let i = 1; i < opts.numCols; i++) {
matrix[row][i] |= FLAG_USED;
}
}
// Use 6 vertically
else if (canUse6 && rand() < 0.2) {
matrix[row][col] |= FLAG_USE6;
matrix[row+1][col] |= FLAG_USED;
matrix[row+2][col] |= FLAG_USED;
matrix[row][col+1] |= FLAG_USED;
matrix[row+1][col+1] |= FLAG_USED;
matrix[row+2][col+1] |= FLAG_USED;
}
// Use 4 box
else if (canUse4 && rand() < 0.35) {
matrix[row][col] |= FLAG_USE4;
matrix[row+1][col] |= FLAG_USED;
matrix[row][col+1] |= FLAG_USED;
matrix[row+1][col+1] |= FLAG_USED;
}
// Go ahead
photoId++; col++;
} }
// Square layout matrix // Check if already used
const absMatrix: { if (matrix[row][col] & FLAG_USED) {
top: number, col++;
left: number, continue;
width: number,
height: number,
}[] = [];
let currTop = 0;
row = 0; col = 0; photoId = 0;
while (photoId < input.length) {
// Check if we reached the end of row
if (col >= opts.numCols) {
row++; col = 0;
currTop += opts.rowHeight;
continue;
}
// Skip if used
if (!(matrix[row][col] & FLAG_USE)) {
col++; continue;
}
// Create basic object
const sqsize = opts.rowHeight;
const p = {
top: currTop,
left: col * sqsize,
width: sqsize,
height: sqsize,
rowHeight: opts.rowHeight,
}
// Use twice the space
const v = matrix[row][col];
if (v & FLAG_USE4) {
p.width *= 2;
p.height *= 2;
col += 2;
} else if (v & FLAG_USE6) {
p.width *= 2;
p.height *= 3;
col += 2;
} else if (v & FLAG_BREAKOUT) {
p.width *= opts.numCols;
p.height = input[photoId].height * p.width / input[photoId].width;
p.rowHeight = p.height;
col += opts.numCols;
} else {
col++;
}
absMatrix.push(p);
photoId++;
} }
return absMatrix; // Use this slot
matrix[row][col] |= FLAG_USE;
// Check if previous row has something used
// or something beside this is used
// We don't do these one after another
if (
!opts.allowBreakout ||
(row > 0 && matrix[row - 1].some((v) => v & FLAG_USED)) ||
(col > 0 && matrix[row][col - 1] & FLAG_USED)
) {
photoId++;
col++;
continue;
}
// Number of photos left
const numLeft = input.length - photoId - 1;
// Number of photos needed for perfect fill after using n
const needFill = (n: number) =>
opts.numCols - col - 2 + (n / 2 - 1) * (opts.numCols - 2);
let canUse4 =
// We have enough space
row + 1 < matrix.length &&
col + 1 < opts.numCols &&
// This cannot end up being a widow (conservative)
// Also make sure the next row gets fully filled, otherwise looks weird
(numLeft === needFill(4) || numLeft >= needFill(4) + opts.numCols);
let canUse6 =
// Image is portrait
input[photoId].height > input[photoId].width &&
// We have enough space
row + 2 < matrix.length &&
col + 1 < opts.numCols &&
// This cannot end up being a widow (conservative)
// Also make sure the next row gets fully filled, otherwise looks weird
(numLeft === needFill(6) || numLeft >= needFill(6) + 2 * opts.numCols);
let canBreakout =
// First column only
col === 0 &&
// Image is landscape
input[photoId].width > input[photoId].height &&
// The next row gets filled
(numLeft === 0 || numLeft >= opts.numCols);
// Probably folders or tags or faces
if (input[photoId].forceSquare) {
// We are square already. Everything below is else-if.
}
// Full width breakout
else if (canBreakout && rand() < (input.length > 0 ? 0.25 : 0.1)) {
matrix[row][col] |= FLAG_BREAKOUT;
for (let i = 1; i < opts.numCols; i++) {
matrix[row][i] |= FLAG_USED;
}
}
// Use 6 vertically
else if (canUse6 && rand() < 0.2) {
matrix[row][col] |= FLAG_USE6;
matrix[row + 1][col] |= FLAG_USED;
matrix[row + 2][col] |= FLAG_USED;
matrix[row][col + 1] |= FLAG_USED;
matrix[row + 1][col + 1] |= FLAG_USED;
matrix[row + 2][col + 1] |= FLAG_USED;
}
// Use 4 box
else if (canUse4 && rand() < 0.35) {
matrix[row][col] |= FLAG_USE4;
matrix[row + 1][col] |= FLAG_USED;
matrix[row][col + 1] |= FLAG_USED;
matrix[row + 1][col + 1] |= FLAG_USED;
}
// Go ahead
photoId++;
col++;
}
// Square layout matrix
const absMatrix: {
top: number;
left: number;
width: number;
height: number;
}[] = [];
let currTop = 0;
row = 0;
col = 0;
photoId = 0;
while (photoId < input.length) {
// Check if we reached the end of row
if (col >= opts.numCols) {
row++;
col = 0;
currTop += opts.rowHeight;
continue;
}
// Skip if used
if (!(matrix[row][col] & FLAG_USE)) {
col++;
continue;
}
// Create basic object
const sqsize = opts.rowHeight;
const p = {
top: currTop,
left: col * sqsize,
width: sqsize,
height: sqsize,
rowHeight: opts.rowHeight,
};
// Use twice the space
const v = matrix[row][col];
if (v & FLAG_USE4) {
p.width *= 2;
p.height *= 2;
col += 2;
} else if (v & FLAG_USE6) {
p.width *= 2;
p.height *= 3;
col += 2;
} else if (v & FLAG_BREAKOUT) {
p.width *= opts.numCols;
p.height = (input[photoId].height * p.width) / input[photoId].width;
p.rowHeight = p.height;
col += opts.numCols;
} else {
col++;
}
absMatrix.push(p);
photoId++;
}
return absMatrix;
} }
function flagMatrixStr(matrix: number[][], numFlag: number) { function flagMatrixStr(matrix: number[][], numFlag: number) {
let str = ''; let str = "";
for (let i = 0; i < matrix.length; i++) { for (let i = 0; i < matrix.length; i++) {
const rstr = matrix[i].map(v => v.toString(2).padStart(numFlag, '0')).join(' '); const rstr = matrix[i]
str += i.toString().padStart(2) + ' | ' + rstr + '\n'; .map((v) => v.toString(2).padStart(numFlag, "0"))
} .join(" ");
return str; str += i.toString().padStart(2) + " | " + rstr + "\n";
}
return str;
} }
function mulberry32(a: number) { function mulberry32(a: number) {
return function() { return function () {
var t = a += 0x6D2B79F5; var t = (a += 0x6d2b79f5);
t = Math.imul(t ^ t >>> 15, t | 1); t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61); t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296; return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
} };
} }

View File

@ -20,11 +20,11 @@
* *
*/ */
const isNumber = function(num: any) { const isNumber = function (num: any) {
if (!num) { if (!num) {
return false return false;
} }
return Number(num).toString() === num.toString() return Number(num).toString() === num.toString();
} };
export { isNumber } export { isNumber };

View File

@ -1,8 +1,8 @@
import { getCanonicalLocale } from "@nextcloud/l10n"; import { getCanonicalLocale } from "@nextcloud/l10n";
import { getCurrentUser } from '@nextcloud/auth' import { getCurrentUser } from "@nextcloud/auth";
import { loadState } from '@nextcloud/initial-state' import { loadState } from "@nextcloud/initial-state";
import { IPhoto } from "../types"; import { IPhoto } from "../types";
import moment from 'moment'; import moment from "moment";
// Memoize the result of short date conversions // Memoize the result of short date conversions
// These operations are surprisingly expensive // These operations are surprisingly expensive
@ -10,49 +10,54 @@ import moment from 'moment';
const shortDateStrMemo = new Map<number, string>(); const shortDateStrMemo = new Map<number, string>();
/** Get JS date object from dayId */ /** Get JS date object from dayId */
export function dayIdToDate(dayId: number){ export function dayIdToDate(dayId: number) {
return new Date(dayId*86400*1000); return new Date(dayId * 86400 * 1000);
} }
/** Get Day ID from JS date */ /** Get Day ID from JS date */
export function dateToDayId(date: Date){ export function dateToDayId(date: Date) {
return Math.floor(date.getTime() / (86400*1000)); return Math.floor(date.getTime() / (86400 * 1000));
} }
/** Get month name from number */ /** Get month name from number */
export function getShortDateStr(date: Date) { export function getShortDateStr(date: Date) {
const dayId = dateToDayId(date); const dayId = dateToDayId(date);
if (!shortDateStrMemo.has(dayId)) { if (!shortDateStrMemo.has(dayId)) {
shortDateStrMemo.set(dayId, shortDateStrMemo.set(
date.toLocaleDateString(getCanonicalLocale(), { dayId,
month: 'short', date.toLocaleDateString(getCanonicalLocale(), {
year: 'numeric', month: "short",
timeZone: 'UTC', year: "numeric",
})); timeZone: "UTC",
} })
return shortDateStrMemo.get(dayId); );
}
return shortDateStrMemo.get(dayId);
} }
/** Get long date string with optional year if same as current */ /** Get long date string with optional year if same as current */
export function getLongDateStr(date: Date, skipYear=false, time=false) { export function getLongDateStr(date: Date, skipYear = false, time = false) {
return date.toLocaleDateString(getCanonicalLocale(), { return date.toLocaleDateString(getCanonicalLocale(), {
weekday: 'short', weekday: "short",
month: 'short', month: "short",
day: 'numeric', day: "numeric",
year: (skipYear && date.getUTCFullYear() === new Date().getUTCFullYear()) ? undefined : 'numeric', year:
timeZone: 'UTC', skipYear && date.getUTCFullYear() === new Date().getUTCFullYear()
hour: time ? 'numeric' : undefined, ? undefined
minute: time ? 'numeric' : undefined, : "numeric",
}); timeZone: "UTC",
hour: time ? "numeric" : undefined,
minute: time ? "numeric" : undefined,
});
} }
/** Get text like "5 years ago" from a date */ /** Get text like "5 years ago" from a date */
export function getFromNowStr(date: Date) { export function getFromNowStr(date: Date) {
// Get fromNow in correct locale // Get fromNow in correct locale
const text = moment(date).locale(getCanonicalLocale()).fromNow(); const text = moment(date).locale(getCanonicalLocale()).fromNow();
// Title case // Title case
return text.charAt(0).toUpperCase() + text.slice(1); return text.charAt(0).toUpperCase() + text.slice(1);
} }
/** /**
@ -62,13 +67,13 @@ export function getFromNowStr(date: Date) {
* @see http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/ * @see http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/
*/ */
export function hashCode(str: string): number { export function hashCode(str: string): number {
let hash = 0; let hash = 0;
for (let i = 0, len = str.length; i < len; i++) { for (let i = 0, len = str.length; i < len; i++) {
let chr = str.charCodeAt(i); let chr = str.charCodeAt(i);
hash = (hash << 5) - hash + chr; hash = (hash << 5) - hash + chr;
hash |= 0; // Convert to 32bit integer hash |= 0; // Convert to 32bit integer
} }
return hash; return hash;
} }
/** /**
@ -80,27 +85,25 @@ export function hashCode(str: string): number {
* @param key Key to use for comparison * @param key Key to use for comparison
*/ */
export function binarySearch(arr: any, elem: any, key?: string) { export function binarySearch(arr: any, elem: any, key?: string) {
let minIndex = 0; let minIndex = 0;
let maxIndex = arr.length - 1; let maxIndex = arr.length - 1;
let currentIndex: number; let currentIndex: number;
let currentElement: any; let currentElement: any;
while (minIndex <= maxIndex) { while (minIndex <= maxIndex) {
currentIndex = (minIndex + maxIndex) / 2 | 0; currentIndex = ((minIndex + maxIndex) / 2) | 0;
currentElement = key ? arr[currentIndex][key] : arr[currentIndex]; currentElement = key ? arr[currentIndex][key] : arr[currentIndex];
if (currentElement < elem) { if (currentElement < elem) {
minIndex = currentIndex + 1; minIndex = currentIndex + 1;
} } else if (currentElement > elem) {
else if (currentElement > elem) { maxIndex = currentIndex - 1;
maxIndex = currentIndex - 1; } else {
} return currentIndex;
else {
return currentIndex;
}
} }
}
return minIndex; return minIndex;
} }
/** /**
@ -109,10 +112,10 @@ export function binarySearch(arr: any, elem: any, key?: string) {
* @param places Number of decimal places * @param places Number of decimal places
* @param floor If true, round down instead of to nearest * @param floor If true, round down instead of to nearest
*/ */
export function round(num: number, places: number, floor=false) { export function round(num: number, places: number, floor = false) {
const pow = Math.pow(10, places); const pow = Math.pow(10, places);
const int = num * pow; const int = num * pow;
return (floor ? Math.floor : Math.round)(int) / pow; return (floor ? Math.floor : Math.round)(int) / pow;
} }
/** /**
@ -120,12 +123,12 @@ export function round(num: number, places: number, floor=false) {
* @param num Number to round * @param num Number to round
*/ */
export function roundHalf(num: number) { export function roundHalf(num: number) {
return Math.round(num * 2) / 2; return Math.round(num * 2) / 2;
} }
/** Choose a random element from an array */ /** Choose a random element from an array */
export function randomChoice(arr: any[]) { export function randomChoice(arr: any[]) {
return arr[Math.floor(Math.random() * arr.length)]; return arr[Math.floor(Math.random() * arr.length)];
} }
/** /**
@ -133,15 +136,19 @@ export function randomChoice(arr: any[]) {
* https://stackoverflow.com/a/11935263/4745239 * https://stackoverflow.com/a/11935263/4745239
*/ */
export function randomSubarray(arr: any[], size: number) { export function randomSubarray(arr: any[], size: number) {
if (arr.length <= size) return arr; if (arr.length <= size) return arr;
var shuffled = arr.slice(0), i = arr.length, min = i - size, temp, index; var shuffled = arr.slice(0),
while (i-- > min) { i = arr.length,
index = Math.floor((i + 1) * Math.random()); min = i - size,
temp = shuffled[index]; temp,
shuffled[index] = shuffled[i]; index;
shuffled[i] = temp; while (i-- > min) {
} index = Math.floor((i + 1) * Math.random());
return shuffled.slice(min); temp = shuffled[index];
shuffled[index] = shuffled[i];
shuffled[i] = temp;
}
return shuffled.slice(min);
} }
/** /**
@ -149,108 +156,114 @@ export function randomSubarray(arr: any[], size: number) {
* @param photo Photo to process * @param photo Photo to process
*/ */
export function convertFlags(photo: IPhoto) { export function convertFlags(photo: IPhoto) {
if (typeof photo.flag === "undefined") { if (typeof photo.flag === "undefined") {
photo.flag = 0; // flags photo.flag = 0; // flags
} }
if (photo.isvideo) { if (photo.isvideo) {
photo.flag |= constants.c.FLAG_IS_VIDEO; photo.flag |= constants.c.FLAG_IS_VIDEO;
delete photo.isvideo; delete photo.isvideo;
} }
if (photo.isfavorite) { if (photo.isfavorite) {
photo.flag |= constants.c.FLAG_IS_FAVORITE; photo.flag |= constants.c.FLAG_IS_FAVORITE;
delete photo.isfavorite; delete photo.isfavorite;
} }
if (photo.isfolder) { if (photo.isfolder) {
photo.flag |= constants.c.FLAG_IS_FOLDER; photo.flag |= constants.c.FLAG_IS_FOLDER;
delete photo.isfolder; delete photo.isfolder;
} }
if (photo.isface) { if (photo.isface) {
photo.flag |= constants.c.FLAG_IS_FACE; photo.flag |= constants.c.FLAG_IS_FACE;
delete photo.isface; delete photo.isface;
} }
if (photo.istag) { if (photo.istag) {
photo.flag |= constants.c.FLAG_IS_TAG; photo.flag |= constants.c.FLAG_IS_TAG;
delete photo.istag; delete photo.istag;
} }
if (photo.isalbum) { if (photo.isalbum) {
photo.flag |= constants.c.FLAG_IS_ALBUM; photo.flag |= constants.c.FLAG_IS_ALBUM;
delete photo.isalbum; delete photo.isalbum;
} }
} }
// Outside for set // Outside for set
const TagDayID = { const TagDayID = {
START: -(1 << 30), START: -(1 << 30),
FOLDERS: -(1 << 30) + 1, FOLDERS: -(1 << 30) + 1,
TAGS: -(1 << 30) + 2, TAGS: -(1 << 30) + 2,
FACES: -(1 << 30) + 3, FACES: -(1 << 30) + 3,
ALBUMS: -(1 << 30) + 4, ALBUMS: -(1 << 30) + 4,
} };
/** Global constants */ /** Global constants */
export const constants = { export const constants = {
c: { c: {
FLAG_PLACEHOLDER: 1 << 0, FLAG_PLACEHOLDER: 1 << 0,
FLAG_LOAD_FAIL: 1 << 1, FLAG_LOAD_FAIL: 1 << 1,
FLAG_IS_VIDEO: 1 << 2, FLAG_IS_VIDEO: 1 << 2,
FLAG_IS_FAVORITE: 1 << 3, FLAG_IS_FAVORITE: 1 << 3,
FLAG_IS_FOLDER: 1 << 4, FLAG_IS_FOLDER: 1 << 4,
FLAG_IS_TAG: 1 << 5, FLAG_IS_TAG: 1 << 5,
FLAG_IS_FACE: 1 << 6, FLAG_IS_FACE: 1 << 6,
FLAG_IS_ALBUM: 1 << 7, FLAG_IS_ALBUM: 1 << 7,
FLAG_SELECTED: 1 << 8, FLAG_SELECTED: 1 << 8,
FLAG_LEAVING: 1 << 9, FLAG_LEAVING: 1 << 9,
}, },
TagDayID: TagDayID, TagDayID: TagDayID,
TagDayIDValueSet: new Set(Object.values(TagDayID)), TagDayIDValueSet: new Set(Object.values(TagDayID)),
} };
/** Cache store */ /** Cache store */
let staticCache: Cache | null = null; let staticCache: Cache | null = null;
const cacheName = `memories-${loadState('memories', 'version')}-${getCurrentUser()!.uid}`; const cacheName = `memories-${loadState("memories", "version")}-${
openCache().then((cache) => { staticCache = cache }); getCurrentUser()!.uid
}`;
openCache().then((cache) => {
staticCache = cache;
});
// Clear all caches except the current one // Clear all caches except the current one
window.caches?.keys().then((keys) => { window.caches?.keys().then((keys) => {
keys.filter((key) => key.startsWith('memories-') && key !== cacheName).forEach((key) => { keys
window.caches.delete(key); .filter((key) => key.startsWith("memories-") && key !== cacheName)
.forEach((key) => {
window.caches.delete(key);
}); });
}); });
/** Open the cache */ /** Open the cache */
export async function openCache() { export async function openCache() {
try { try {
return await window.caches?.open(cacheName); return await window.caches?.open(cacheName);
} catch { } catch {
console.warn('Failed to get cache', cacheName); console.warn("Failed to get cache", cacheName);
return null; return null;
} }
} }
/** Get data from the cache */ /** Get data from the cache */
export async function getCachedData<T>(url: string): Promise<T> { export async function getCachedData<T>(url: string): Promise<T> {
if (!window.caches) return null; if (!window.caches) return null;
const cache = staticCache || await openCache(); const cache = staticCache || (await openCache());
if (!cache) return null; if (!cache) return null;
const cachedResponse = await cache.match(url); const cachedResponse = await cache.match(url);
if (!cachedResponse || !cachedResponse.ok) return undefined; if (!cachedResponse || !cachedResponse.ok) return undefined;
return await cachedResponse.json(); return await cachedResponse.json();
} }
/** Store data in the cache */ /** Store data in the cache */
export function cacheData(url: string, data: Object) { export function cacheData(url: string, data: Object) {
if (!window.caches) return; if (!window.caches) return;
const str = JSON.stringify(data); const str = JSON.stringify(data);
(async () => { (async () => {
const cache = staticCache || await openCache(); const cache = staticCache || (await openCache());
if (!cache) return; if (!cache) return;
const response = new Response(str); const response = new Response(str);
response.headers.set('Content-Type', 'application/json'); response.headers.set("Content-Type", "application/json");
await cache.put(url, response); await cache.put(url, response);
})(); })();
} }

View File

@ -1,87 +1,88 @@
import { IFileInfo, IPhoto } from "../types"; import { IFileInfo, IPhoto } from "../types";
import { showError } from '@nextcloud/dialogs' import { showError } from "@nextcloud/dialogs";
import { subscribe } from '@nextcloud/event-bus'; import { subscribe } from "@nextcloud/event-bus";
import { translate as t, translatePlural as n } from '@nextcloud/l10n' import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import * as dav from "./DavRequests"; import * as dav from "./DavRequests";
// Key to store sidebar state // Key to store sidebar state
const SIDEBAR_KEY = 'memories:sidebar-open'; const SIDEBAR_KEY = "memories:sidebar-open";
export class ViewerManager { export class ViewerManager {
/** Map from fileid to Photo */ /** Map from fileid to Photo */
private photoMap = new Map<number, IPhoto>(); private photoMap = new Map<number, IPhoto>();
constructor( constructor(
ondelete: (photos: IPhoto[]) => void, ondelete: (photos: IPhoto[]) => void,
private updateLoading: (delta: number) => void, private updateLoading: (delta: number) => void
) { ) {
subscribe('files:file:deleted', ({ fileid }: { fileid: number }) => { subscribe("files:file:deleted", ({ fileid }: { fileid: number }) => {
const photo = this.photoMap.get(fileid); const photo = this.photoMap.get(fileid);
ondelete([photo]); ondelete([photo]);
}); });
}
public async open(photo: IPhoto, list?: IPhoto[]) {
list = list || photo.d?.detail;
if (!list) return;
// Repopulate map
this.photoMap.clear();
for (const p of list) {
this.photoMap.set(p.fileid, p);
} }
public async open(photo: IPhoto, list?: IPhoto[]) { // Get file infos
list = list || photo.d?.detail; let fileInfos: IFileInfo[];
if (!list) return; const ids = list.map((p) => p.fileid);
try {
// Repopulate map this.updateLoading(1);
this.photoMap.clear(); fileInfos = await dav.getFiles(ids);
for (const p of list) { } catch (e) {
this.photoMap.set(p.fileid, p); console.error("Failed to load fileInfos", e);
} showError("Failed to load fileInfos");
return;
// Get file infos } finally {
let fileInfos: IFileInfo[]; this.updateLoading(-1);
const ids = list.map(p => p.fileid);
try {
this.updateLoading(1);
fileInfos = await dav.getFiles(ids);
} catch (e) {
console.error('Failed to load fileInfos', e);
showError('Failed to load fileInfos');
return;
} finally {
this.updateLoading(-1);
}
if (fileInfos.length === 0) {
return;
}
// Fix sorting of the fileInfos
const itemPositions = {};
for (const [index, id] of ids.entries()) {
itemPositions[id] = index;
}
fileInfos.sort(function (a, b) {
return itemPositions[a.fileid] - itemPositions[b.fileid];
});
// Get this photo in the fileInfos
const fInfo = fileInfos.find(d => Number(d.fileid) === photo.fileid);
if (!fInfo) {
showError(t('memories', 'Cannot find this photo anymore!'));
return;
}
// Open Nextcloud viewer
globalThis.OCA.Viewer.open({
path: fInfo.filename, // path
list: fileInfos, // file list
canLoop: false, // don't loop
onClose: () => { // on viewer close
if (globalThis.OCA.Files.Sidebar.file) {
localStorage.setItem(SIDEBAR_KEY, '1');
} else {
localStorage.removeItem(SIDEBAR_KEY);
}
globalThis.OCA.Files.Sidebar.close();
},
});
// Restore sidebar state
if (localStorage.getItem(SIDEBAR_KEY) === '1') {
globalThis.OCA.Files.Sidebar.open(fInfo.filename);
}
} }
} if (fileInfos.length === 0) {
return;
}
// Fix sorting of the fileInfos
const itemPositions = {};
for (const [index, id] of ids.entries()) {
itemPositions[id] = index;
}
fileInfos.sort(function (a, b) {
return itemPositions[a.fileid] - itemPositions[b.fileid];
});
// Get this photo in the fileInfos
const fInfo = fileInfos.find((d) => Number(d.fileid) === photo.fileid);
if (!fInfo) {
showError(t("memories", "Cannot find this photo anymore!"));
return;
}
// Open Nextcloud viewer
globalThis.OCA.Viewer.open({
path: fInfo.filename, // path
list: fileInfos, // file list
canLoop: false, // don't loop
onClose: () => {
// on viewer close
if (globalThis.OCA.Files.Sidebar.file) {
localStorage.setItem(SIDEBAR_KEY, "1");
} else {
localStorage.removeItem(SIDEBAR_KEY);
}
globalThis.OCA.Files.Sidebar.close();
},
});
// Restore sidebar state
if (localStorage.getItem(SIDEBAR_KEY) === "1") {
globalThis.OCA.Files.Sidebar.open(fInfo.filename);
}
}
}

View File

@ -1,51 +1,58 @@
import * as base from "./base"; import * as base from "./base";
import { getCurrentUser } from '@nextcloud/auth' import { getCurrentUser } from "@nextcloud/auth";
import { generateUrl } from '@nextcloud/router' import { generateUrl } from "@nextcloud/router";
import { showError } from '@nextcloud/dialogs' import { showError } from "@nextcloud/dialogs";
import { translate as t, translatePlural as n } from '@nextcloud/l10n' import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import { IAlbum, IDay, ITag } from '../../types'; import { IAlbum, IDay, ITag } from "../../types";
import { constants } from '../Utils'; import { constants } from "../Utils";
import axios from '@nextcloud/axios' import axios from "@nextcloud/axios";
import client from '../DavClient'; import client from "../DavClient";
/** /**
* Get DAV path for album * Get DAV path for album
*/ */
export function getAlbumPath(user: string, name: string) { export function getAlbumPath(user: string, name: string) {
// Folder in the dav collection for user // Folder in the dav collection for user
const cuid = getCurrentUser().uid; const cuid = getCurrentUser().uid;
if (user === cuid) { if (user === cuid) {
return `/photos/${cuid}/albums/${name}`; return `/photos/${cuid}/albums/${name}`;
} else { } else {
return `/photos/${cuid}/sharedalbums/${name} (${user})`; return `/photos/${cuid}/sharedalbums/${name} (${user})`;
} }
} }
/** /**
* Get list of albums and convert to Days response * Get list of albums and convert to Days response
* @param type Type of albums to get; 1 = personal, 2 = shared, 3 = all * @param type Type of albums to get; 1 = personal, 2 = shared, 3 = all
*/ */
export async function getAlbumsData(type: '1' | '2' | '3'): Promise<IDay[]> { export async function getAlbumsData(type: "1" | "2" | "3"): Promise<IDay[]> {
let data: IAlbum[] = []; let data: IAlbum[] = [];
try { try {
const res = await axios.get<typeof data>(generateUrl(`/apps/memories/api/albums?t=${type}`)); const res = await axios.get<typeof data>(
data = res.data; generateUrl(`/apps/memories/api/albums?t=${type}`)
} catch (e) { );
throw e; data = res.data;
} } catch (e) {
throw e;
}
// Convert to days response // Convert to days response
return [{ return [
dayid: constants.TagDayID.ALBUMS, {
count: data.length, dayid: constants.TagDayID.ALBUMS,
detail: data.map((album) => ({ count: data.length,
detail: data.map(
(album) =>
({
...album, ...album,
fileid: album.album_id, fileid: album.album_id,
flag: constants.c.FLAG_IS_TAG & constants.c.FLAG_IS_ALBUM, flag: constants.c.FLAG_IS_TAG & constants.c.FLAG_IS_ALBUM,
istag: true, istag: true,
isalbum: true, isalbum: true,
} as ITag)), } as ITag)
}] ),
},
];
} }
/** /**
@ -56,34 +63,37 @@ export async function getAlbumsData(type: '1' | '2' | '3'): Promise<IDay[]> {
* @param fileIds List of file IDs to add * @param fileIds List of file IDs to add
* @returns Generator * @returns Generator
*/ */
export async function* addToAlbum(user: string, name: string, fileIds: number[]) { export async function* addToAlbum(
// Get files data user: string,
let fileInfos = await base.getFiles(fileIds.filter(f => f)); name: string,
fileIds: number[]
) {
// Get files data
let fileInfos = await base.getFiles(fileIds.filter((f) => f));
const albumPath = getAlbumPath(user, name); const albumPath = getAlbumPath(user, name);
// Add each file // Add each file
const calls = fileInfos.map((f) => async () => { const calls = fileInfos.map((f) => async () => {
try { try {
await client.copyFile( await client.copyFile(f.originalFilename, `${albumPath}/${f.basename}`);
f.originalFilename, return f.fileid;
`${albumPath}/${f.basename}`, } catch (e) {
) if (e.response?.status === 409) {
return f.fileid; // File already exists, all good
} catch (e) { return f.fileid;
if (e.response?.status === 409) { }
// File already exists, all good
return f.fileid;
}
showError(t('memories', 'Failed to add {filename} to album.', { showError(
filename: f.filename, t("memories", "Failed to add {filename} to album.", {
})); filename: f.filename,
return 0; })
} );
}); return 0;
}
});
yield* base.runInParallel(calls, 10); yield* base.runInParallel(calls, 10);
} }
/** /**
@ -94,38 +104,46 @@ export async function* addToAlbum(user: string, name: string, fileIds: number[])
* @param fileIds List of file IDs to remove * @param fileIds List of file IDs to remove
* @returns Generator * @returns Generator
*/ */
export async function* removeFromAlbum(user: string, name: string, fileIds: number[]) { export async function* removeFromAlbum(
// Get files data user: string,
let fileInfos = await base.getFiles(fileIds.filter(f => f)); name: string,
fileIds: number[]
) {
// Get files data
let fileInfos = await base.getFiles(fileIds.filter((f) => f));
// Add each file // Add each file
const calls = fileInfos.map((f) => async () => { const calls = fileInfos.map((f) => async () => {
try { try {
await client.deleteFile( await client.deleteFile(
`/photos/${user}/albums/${name}/${f.fileid}-${f.basename}`, `/photos/${user}/albums/${name}/${f.fileid}-${f.basename}`
) );
return f.fileid; return f.fileid;
} catch (e) { } catch (e) {
showError(t('memories', 'Failed to remove {filename}.', { showError(
filename: f.filename, t("memories", "Failed to remove {filename}.", {
})); filename: f.filename,
return 0; })
} );
}); return 0;
}
});
yield* base.runInParallel(calls, 10); yield* base.runInParallel(calls, 10);
} }
/** /**
* Create an album. * Create an album.
*/ */
export async function createAlbum(albumName: string) { export async function createAlbum(albumName: string) {
try { try {
await client.createDirectory(`/photos/${getCurrentUser()?.uid}/albums/${albumName}`) await client.createDirectory(
} catch (error) { `/photos/${getCurrentUser()?.uid}/albums/${albumName}`
console.error(error); );
showError(t('photos', 'Failed to create {albumName}.', { albumName })) } catch (error) {
} console.error(error);
showError(t("photos", "Failed to create {albumName}.", { albumName }));
}
} }
/** /**
@ -137,26 +155,23 @@ export async function createAlbum(albumName: string) {
* @param {object} data.properties - The properties to update. * @param {object} data.properties - The properties to update.
*/ */
export async function updateAlbum(album: any, { albumName, properties }: any) { export async function updateAlbum(album: any, { albumName, properties }: any) {
const stringifiedProperties = Object const stringifiedProperties = Object.entries(properties)
.entries(properties) .map(([name, value]) => {
.map(([name, value]) => { switch (typeof value) {
switch (typeof value) { case "string":
case 'string': return `<nc:${name}>${value}</nc:${name}>`;
return `<nc:${name}>${value}</nc:${name}>` case "object":
case 'object': return `<nc:${name}>${JSON.stringify(value)}</nc:${name}>`;
return `<nc:${name}>${JSON.stringify(value)}</nc:${name}>` default:
default: return "";
return '' }
} })
}) .join();
.join()
try { try {
await client.customRequest( await client.customRequest(album.filename, {
album.filename, method: "PROPPATCH",
{ data: `<?xml version="1.0"?>
method: 'PROPPATCH',
data: `<?xml version="1.0"?>
<d:propertyupdate xmlns:d="DAV:" <d:propertyupdate xmlns:d="DAV:"
xmlns:oc="http://owncloud.org/ns" xmlns:oc="http://owncloud.org/ns"
xmlns:nc="http://nextcloud.org/ns" xmlns:nc="http://nextcloud.org/ns"
@ -167,15 +182,20 @@ export async function updateAlbum(album: any, { albumName, properties }: any) {
</d:prop> </d:prop>
</d:set> </d:set>
</d:propertyupdate>`, </d:propertyupdate>`,
} });
);
return album; return album;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
showError(t('photos', 'Failed to update properties of {albumName} with {properties}.', { albumName, properties: JSON.stringify(properties) })) showError(
return album t(
} "photos",
"Failed to update properties of {albumName} with {properties}.",
{ albumName, properties: JSON.stringify(properties) }
)
);
return album;
}
} }
/** /**
@ -184,7 +204,7 @@ export async function updateAlbum(album: any, { albumName, properties }: any) {
* @param name Name of album (or ID) * @param name Name of album (or ID)
*/ */
export async function getAlbum(user: string, name: string, extraProps = {}) { export async function getAlbum(user: string, name: string, extraProps = {}) {
const req = `<?xml version="1.0"?> const req = `<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:" <d:propfind xmlns:d="DAV:"
xmlns:oc="http://owncloud.org/ns" xmlns:oc="http://owncloud.org/ns"
xmlns:nc="http://nextcloud.org/ns" xmlns:nc="http://nextcloud.org/ns"
@ -198,33 +218,41 @@ export async function getAlbum(user: string, name: string, extraProps = {}) {
${extraProps} ${extraProps}
</d:prop> </d:prop>
</d:propfind>`; </d:propfind>`;
let album = await client.stat(`/photos/${user}/albums/${name}`, { let album = (await client.stat(`/photos/${user}/albums/${name}`, {
data: req, data: req,
details: true, details: true,
}) as any; })) as any;
// Post processing // Post processing
album = { album = {
...album.data, ...album.data,
...album.data.props, ...album.data.props,
}; };
const c = album?.collaborators?.collaborator; const c = album?.collaborators?.collaborator;
album.collaborators = c ? (Array.isArray(c) ? c : [c]) : []; album.collaborators = c ? (Array.isArray(c) ? c : [c]) : [];
return album; return album;
} }
/** Rename an album */ /** Rename an album */
export async function renameAlbum(album: any, { currentAlbumName, newAlbumName }) { export async function renameAlbum(
const newAlbum = { ...album, basename: newAlbumName } album: any,
try { { currentAlbumName, newAlbumName }
await client.moveFile( ) {
`/photos/${getCurrentUser()?.uid}/albums/${currentAlbumName}`, const newAlbum = { ...album, basename: newAlbumName };
`/photos/${getCurrentUser()?.uid}/albums/${newAlbumName}`, try {
) await client.moveFile(
return newAlbum `/photos/${getCurrentUser()?.uid}/albums/${currentAlbumName}`,
} catch (error) { `/photos/${getCurrentUser()?.uid}/albums/${newAlbumName}`
console.error(error); );
showError(t('photos', 'Failed to rename {currentAlbumName} to {newAlbumName}.', { currentAlbumName, newAlbumName })) return newAlbum;
return album } catch (error) {
} console.error(error);
showError(
t("photos", "Failed to rename {currentAlbumName} to {newAlbumName}.", {
currentAlbumName,
newAlbumName,
})
);
return album;
}
} }

View File

@ -1,8 +1,8 @@
import * as base from './base'; import * as base from "./base";
import { generateUrl } from '@nextcloud/router' import { generateUrl } from "@nextcloud/router";
import { showError } from '@nextcloud/dialogs' import { showError } from "@nextcloud/dialogs";
import { translate as t, translatePlural as n } from '@nextcloud/l10n' import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import axios from '@nextcloud/axios' import axios from "@nextcloud/axios";
/** /**
* Archive or unarchive a single file * Archive or unarchive a single file
@ -10,8 +10,11 @@ import axios from '@nextcloud/axios'
* @param fileid File id * @param fileid File id
* @param archive Archive or unarchive * @param archive Archive or unarchive
*/ */
export async function archiveFile(fileid: number, archive: boolean) { export async function archiveFile(fileid: number, archive: boolean) {
return await axios.patch(generateUrl('/apps/memories/api/archive/{fileid}', { fileid }), { archive }); return await axios.patch(
generateUrl("/apps/memories/api/archive/{fileid}", { fileid }),
{ archive }
);
} }
/** /**
@ -21,23 +24,24 @@ import axios from '@nextcloud/axios'
* @param archive Archive or unarchive * @param archive Archive or unarchive
* @returns list of file ids that were deleted * @returns list of file ids that were deleted
*/ */
export async function* archiveFilesByIds(fileIds: number[], archive: boolean) { export async function* archiveFilesByIds(fileIds: number[], archive: boolean) {
if (fileIds.length === 0) { if (fileIds.length === 0) {
return; return;
}
// Archive each file
const calls = fileIds.map((id) => async () => {
try {
await archiveFile(id, archive);
return id as number;
} catch (error) {
console.error("Failed to (un)archive", id, error);
const msg =
error?.response?.data?.message || t("memories", "General Failure");
showError(t("memories", "Error: {msg}", { msg }));
return 0;
} }
});
// Archive each file yield* base.runInParallel(calls, 10);
const calls = fileIds.map((id) => async () => { }
try {
await archiveFile(id, archive);
return id as number;
} catch (error) {
console.error('Failed to (un)archive', id, error);
const msg = error?.response?.data?.message || t('memories', 'General Failure');
showError(t('memories', 'Error: {msg}', { msg }));
return 0;
}
});
yield* base.runInParallel(calls, 10);
}

View File

@ -1,9 +1,9 @@
import { getCurrentUser } from '@nextcloud/auth'; import { getCurrentUser } from "@nextcloud/auth";
import { showError } from '@nextcloud/dialogs'; import { showError } from "@nextcloud/dialogs";
import { translate as t } from '@nextcloud/l10n'; import { translate as t } from "@nextcloud/l10n";
import { IFileInfo } from '../../types'; import { IFileInfo } from "../../types";
import client from '../DavClient'; import client from "../DavClient";
import { genFileInfo } from '../FileUtils'; import { genFileInfo } from "../FileUtils";
export const props = ` export const props = `
<oc:fileid /> <oc:fileid />
@ -17,18 +17,18 @@ export const props = `
<d:resourcetype />`; <d:resourcetype />`;
export const IMAGE_MIME_TYPES = [ export const IMAGE_MIME_TYPES = [
'image/png', "image/png",
'image/jpeg', "image/jpeg",
'image/heic', "image/heic",
'image/png', "image/png",
'image/tiff', "image/tiff",
'image/gif', "image/gif",
'image/bmp', "image/bmp",
'video/mpeg', "video/mpeg",
'video/webm', "video/webm",
'video/mp4', "video/mp4",
'video/quicktime', "video/quicktime",
'video/x-matroska', "video/x-matroska",
]; ];
const GET_FILE_CHUNK_SIZE = 50; const GET_FILE_CHUNK_SIZE = 50;
@ -38,17 +38,17 @@ const GET_FILE_CHUNK_SIZE = 50;
* @param fileIds list of file ids * @param fileIds list of file ids
* @returns list of file infos * @returns list of file infos
*/ */
export async function getFiles(fileIds: number[]): Promise<IFileInfo[]> { export async function getFiles(fileIds: number[]): Promise<IFileInfo[]> {
// Divide fileIds into chunks of GET_FILE_CHUNK_SIZE // Divide fileIds into chunks of GET_FILE_CHUNK_SIZE
const chunks = []; const chunks = [];
for (let i = 0; i < fileIds.length; i += GET_FILE_CHUNK_SIZE) { for (let i = 0; i < fileIds.length; i += GET_FILE_CHUNK_SIZE) {
chunks.push(fileIds.slice(i, i + GET_FILE_CHUNK_SIZE)); chunks.push(fileIds.slice(i, i + GET_FILE_CHUNK_SIZE));
} }
// Get file infos for each chunk // Get file infos for each chunk
const fileInfos = await Promise.all(chunks.map(getFilesInternal)); const fileInfos = await Promise.all(chunks.map(getFilesInternal));
return fileInfos.flat(); return fileInfos.flat();
} }
/** /**
* Get file infos for list of files given Ids * Get file infos for list of files given Ids
@ -56,29 +56,33 @@ const GET_FILE_CHUNK_SIZE = 50;
* @returns list of file infos * @returns list of file infos
*/ */
async function getFilesInternal(fileIds: number[]): Promise<IFileInfo[]> { async function getFilesInternal(fileIds: number[]): Promise<IFileInfo[]> {
const prefixPath = `/files/${getCurrentUser()!.uid}`; const prefixPath = `/files/${getCurrentUser()!.uid}`;
// IMPORTANT: if this isn't there, then a blank // IMPORTANT: if this isn't there, then a blank
// returns EVERYTHING on the server! // returns EVERYTHING on the server!
if (fileIds.length === 0) { if (fileIds.length === 0) {
return []; return [];
} }
const filter = fileIds.map(fileId => ` const filter = fileIds
.map(
(fileId) => `
<d:eq> <d:eq>
<d:prop> <d:prop>
<oc:fileid/> <oc:fileid/>
</d:prop> </d:prop>
<d:literal>${fileId}</d:literal> <d:literal>${fileId}</d:literal>
</d:eq> </d:eq>
`).join(''); `
)
.join("");
const options = { const options = {
method: 'SEARCH', method: "SEARCH",
headers: { headers: {
'content-Type': 'text/xml', "content-Type": "text/xml",
}, },
data: `<?xml version="1.0" encoding="UTF-8"?> data: `<?xml version="1.0" encoding="UTF-8"?>
<d:searchrequest xmlns:d="DAV:" <d:searchrequest xmlns:d="DAV:"
xmlns:oc="http://owncloud.org/ns" xmlns:oc="http://owncloud.org/ns"
xmlns:nc="http://nextcloud.org/ns" xmlns:nc="http://nextcloud.org/ns"
@ -103,34 +107,39 @@ async function getFilesInternal(fileIds: number[]): Promise<IFileInfo[]> {
</d:where> </d:where>
</d:basicsearch> </d:basicsearch>
</d:searchrequest>`, </d:searchrequest>`,
deep: true, deep: true,
details: true, details: true,
responseType: 'text', responseType: "text",
}; };
let response: any = await client.getDirectoryContents('', options); let response: any = await client.getDirectoryContents("", options);
return response.data return response.data
.map((data: any) => genFileInfo(data)) .map((data: any) => genFileInfo(data))
.map((data: any) => Object.assign({}, data, { .map((data: any) =>
originalFilename: data.filename, Object.assign({}, data, {
filename: data.filename.replace(prefixPath, '') originalFilename: data.filename,
})); filename: data.filename.replace(prefixPath, ""),
})
);
} }
/** /**
* Run promises in parallel, but only n at a time * Run promises in parallel, but only n at a time
* @param promises Array of promise generator funnction (async functions) * @param promises Array of promise generator funnction (async functions)
* @param n Number of promises to run in parallel * @param n Number of promises to run in parallel
*/ */
export async function* runInParallel<T>(promises: (() => Promise<T>)[], n: number) { export async function* runInParallel<T>(
while (promises.length > 0) { promises: (() => Promise<T>)[],
const promisesToRun = promises.splice(0, n); n: number
const resultsForThisBatch = await Promise.all(promisesToRun.map(p => p())); ) {
yield resultsForThisBatch; while (promises.length > 0) {
} const promisesToRun = promises.splice(0, n);
return; const resultsForThisBatch = await Promise.all(
promisesToRun.map((p) => p())
);
yield resultsForThisBatch;
}
return;
} }
/** /**
@ -139,8 +148,8 @@ export async function* runInParallel<T>(promises: (() => Promise<T>)[], n: numbe
* @param path path to the file * @param path path to the file
*/ */
export async function deleteFile(path: string) { export async function deleteFile(path: string) {
const prefixPath = `/files/${getCurrentUser()!.uid}`; const prefixPath = `/files/${getCurrentUser()!.uid}`;
return await client.deleteFile(`${prefixPath}${path}`); return await client.deleteFile(`${prefixPath}${path}`);
} }
/** /**
@ -150,46 +159,34 @@ export async function deleteFile(path: string) {
* @returns list of file ids that were deleted * @returns list of file ids that were deleted
*/ */
export async function* deleteFilesByIds(fileIds: number[]) { export async function* deleteFilesByIds(fileIds: number[]) {
const fileIdsSet = new Set(fileIds); const fileIdsSet = new Set(fileIds);
if (fileIds.length === 0) { if (fileIds.length === 0) {
return; return;
} }
// Get files data // Get files data
let fileInfos: any[] = []; let fileInfos: any[] = [];
try {
fileInfos = await getFiles(fileIds.filter((f) => f));
} catch (e) {
console.error("Failed to get file info for files to delete", fileIds, e);
showError(t("memories", "Failed to delete files."));
return;
}
// Delete each file
fileInfos = fileInfos.filter((f) => fileIdsSet.has(f.fileid));
const calls = fileInfos.map((fileInfo) => async () => {
try { try {
fileInfos = await getFiles(fileIds.filter(f => f)); await deleteFile(fileInfo.filename);
} catch (e) { return fileInfo.fileid as number;
console.error('Failed to get file info for files to delete', fileIds, e); } catch (error) {
showError(t('memories', 'Failed to delete files.')); console.error("Failed to delete", fileInfo, error);
return; showError(t("memories", "Failed to delete {fileName}.", fileInfo));
return 0;
} }
});
// Delete each file yield* runInParallel(calls, 10);
fileInfos = fileInfos.filter((f) => fileIdsSet.has(f.fileid));
const calls = fileInfos.map((fileInfo) => async () => {
try {
await deleteFile(fileInfo.filename);
return fileInfo.fileid as number;
} catch (error) {
console.error('Failed to delete', fileInfo, error);
showError(t('memories', 'Failed to delete {fileName}.', fileInfo));
return 0;
}
});
yield* runInParallel(calls, 10);
} }

View File

@ -1,5 +1,5 @@
import * as base from './base'; import * as base from "./base";
import { generateUrl } from '@nextcloud/router' import { generateUrl } from "@nextcloud/router";
/** /**
* Download a file * Download a file
@ -7,32 +7,32 @@ import { generateUrl } from '@nextcloud/router'
* @param fileNames - The file's names * @param fileNames - The file's names
*/ */
export async function downloadFiles(fileNames: string[]): Promise<boolean> { export async function downloadFiles(fileNames: string[]): Promise<boolean> {
const randomToken = Math.random().toString(36).substring(2) const randomToken = Math.random().toString(36).substring(2);
const params = new URLSearchParams() const params = new URLSearchParams();
params.append('files', JSON.stringify(fileNames)) params.append("files", JSON.stringify(fileNames));
params.append('downloadStartSecret', randomToken) params.append("downloadStartSecret", randomToken);
const downloadURL = generateUrl(`/apps/files/ajax/download.php?${params}`) const downloadURL = generateUrl(`/apps/files/ajax/download.php?${params}`);
window.location.href = `${downloadURL}downloadStartSecret=${randomToken}` window.location.href = `${downloadURL}downloadStartSecret=${randomToken}`;
return new Promise((resolve) => { return new Promise((resolve) => {
const waitForCookieInterval = setInterval( const waitForCookieInterval = setInterval(() => {
() => { const cookieIsSet = document.cookie
const cookieIsSet = document.cookie .split(";")
.split(';') .map((cookie) => cookie.split("="))
.map(cookie => cookie.split('=')) .findIndex(
.findIndex(([cookieName, cookieValue]) => cookieName === 'ocDownloadStarted' && cookieValue === randomToken) ([cookieName, cookieValue]) =>
cookieName === "ocDownloadStarted" && cookieValue === randomToken
);
if (cookieIsSet) { if (cookieIsSet) {
clearInterval(waitForCookieInterval) clearInterval(waitForCookieInterval);
resolve(true) resolve(true);
} }
}, }, 50);
50 });
)
})
} }
/** /**
@ -40,11 +40,11 @@ export async function downloadFiles(fileNames: string[]): Promise<boolean> {
* @param fileIds list of file ids * @param fileIds list of file ids
*/ */
export async function downloadFilesByIds(fileIds: number[]) { export async function downloadFilesByIds(fileIds: number[]) {
if (fileIds.length === 0) { if (fileIds.length === 0) {
return; return;
} }
// Get files to download // Get files to download
const fileInfos = await base.getFiles(fileIds); const fileInfos = await base.getFiles(fileIds);
await downloadFiles(fileInfos.map(f => f.filename)); await downloadFiles(fileInfos.map((f) => f.filename));
} }

View File

@ -1,44 +1,51 @@
import axios from '@nextcloud/axios'; import axios from "@nextcloud/axios";
import { showError } from '@nextcloud/dialogs'; import { showError } from "@nextcloud/dialogs";
import { translate as t } from '@nextcloud/l10n'; import { translate as t } from "@nextcloud/l10n";
import { generateUrl } from '@nextcloud/router'; import { generateUrl } from "@nextcloud/router";
import { IDay, IPhoto } from '../../types'; import { IDay, IPhoto } from "../../types";
import client from '../DavClient'; import client from "../DavClient";
import { constants } from '../Utils'; import { constants } from "../Utils";
import * as base from './base'; import * as base from "./base";
/** /**
* Get list of tags and convert to Days response * Get list of tags and convert to Days response
*/ */
export async function getPeopleData(): Promise<IDay[]> { export async function getPeopleData(): Promise<IDay[]> {
// Query for photos // Query for photos
let data: { let data: {
id: number; id: number;
count: number; count: number;
name: string; name: string;
previews: IPhoto[]; previews: IPhoto[];
}[] = []; }[] = [];
try { try {
const res = await axios.get<typeof data>(generateUrl('/apps/memories/api/faces')); const res = await axios.get<typeof data>(
data = res.data; generateUrl("/apps/memories/api/faces")
} catch (e) { );
throw e; data = res.data;
} } catch (e) {
throw e;
}
// Add flag to previews // Add flag to previews
data.forEach(t => t.previews?.forEach((preview) => preview.flag = 0)); data.forEach((t) => t.previews?.forEach((preview) => (preview.flag = 0)));
// Convert to days response // Convert to days response
return [{ return [
dayid: constants.TagDayID.FACES, {
count: data.length, dayid: constants.TagDayID.FACES,
detail: data.map((face) => ({ count: data.length,
detail: data.map(
(face) =>
({
...face, ...face,
fileid: face.id, fileid: face.id,
istag: true, istag: true,
isface: true, isface: true,
} as any)), } as any)
}] ),
},
];
} }
/** /**
@ -49,23 +56,31 @@ export async function getPeopleData(): Promise<IDay[]> {
* @param fileIds List of file IDs to remove * @param fileIds List of file IDs to remove
* @returns Generator * @returns Generator
*/ */
export async function* removeFaceImages(user: string, name: string, fileIds: number[]) { export async function* removeFaceImages(
// Get files data user: string,
let fileInfos = await base.getFiles(fileIds.filter(f => f)); name: string,
fileIds: number[]
) {
// Get files data
let fileInfos = await base.getFiles(fileIds.filter((f) => f));
// Remove each file // Remove each file
const calls = fileInfos.map((f) => async () => { const calls = fileInfos.map((f) => async () => {
try { try {
await client.deleteFile(`/recognize/${user}/faces/${name}/${f.fileid}-${f.basename}`) await client.deleteFile(
return f.fileid; `/recognize/${user}/faces/${name}/${f.fileid}-${f.basename}`
} catch (e) { );
console.error(e) return f.fileid;
showError(t('memories', 'Failed to remove {filename} from face.', { } catch (e) {
filename: f.filename, console.error(e);
})); showError(
return 0; t("memories", "Failed to remove {filename} from face.", {
} filename: f.filename,
}); })
);
return 0;
}
});
yield* base.runInParallel(calls, 10); yield* base.runInParallel(calls, 10);
} }

View File

@ -1,9 +1,9 @@
import * as base from './base'; import * as base from "./base";
import { generateUrl } from '@nextcloud/router' import { generateUrl } from "@nextcloud/router";
import { encodePath } from '@nextcloud/paths' import { encodePath } from "@nextcloud/paths";
import { showError } from '@nextcloud/dialogs' import { showError } from "@nextcloud/dialogs";
import { translate as t, translatePlural as n } from '@nextcloud/l10n' import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import axios from '@nextcloud/axios' import axios from "@nextcloud/axios";
/** /**
* Favorite a file * Favorite a file
@ -13,22 +13,22 @@ import axios from '@nextcloud/axios'
* @param favoriteState - The new favorite state * @param favoriteState - The new favorite state
*/ */
export async function favoriteFile(fileName: string, favoriteState: boolean) { export async function favoriteFile(fileName: string, favoriteState: boolean) {
let encodedPath = encodePath(fileName) let encodedPath = encodePath(fileName);
while (encodedPath[0] === '/') { while (encodedPath[0] === "/") {
encodedPath = encodedPath.substring(1) encodedPath = encodedPath.substring(1);
} }
try { try {
return axios.post( return axios.post(
`${generateUrl('/apps/files/api/v1/files/')}${encodedPath}`, `${generateUrl("/apps/files/api/v1/files/")}${encodedPath}`,
{ {
tags: favoriteState ? ['_$!<Favorite>!$_'] : [], tags: favoriteState ? ["_$!<Favorite>!$_"] : [],
}, }
) );
} catch (error) { } catch (error) {
console.error('Failed to favorite', fileName, error) console.error("Failed to favorite", fileName, error);
showError(t('memories', 'Failed to favorite {fileName}.', { fileName })) showError(t("memories", "Failed to favorite {fileName}.", { fileName }));
} }
} }
/** /**
@ -38,35 +38,38 @@ export async function favoriteFile(fileName: string, favoriteState: boolean) {
* @param favoriteState the new favorite state * @param favoriteState the new favorite state
* @returns generator of lists of file ids that were state-changed * @returns generator of lists of file ids that were state-changed
*/ */
export async function* favoriteFilesByIds(fileIds: number[], favoriteState: boolean) { export async function* favoriteFilesByIds(
const fileIdsSet = new Set(fileIds); fileIds: number[],
favoriteState: boolean
) {
const fileIdsSet = new Set(fileIds);
if (fileIds.length === 0) { if (fileIds.length === 0) {
return; return;
} }
// Get files data // Get files data
let fileInfos: any[] = []; let fileInfos: any[] = [];
try {
fileInfos = await base.getFiles(fileIds.filter((f) => f));
} catch (e) {
console.error("Failed to get file info", fileIds, e);
showError(t("memories", "Failed to favorite files."));
return;
}
// Favorite each file
fileInfos = fileInfos.filter((f) => fileIdsSet.has(f.fileid));
const calls = fileInfos.map((fileInfo) => async () => {
try { try {
fileInfos = await base.getFiles(fileIds.filter(f => f)); await favoriteFile(fileInfo.filename, favoriteState);
} catch (e) { return fileInfo.fileid as number;
console.error('Failed to get file info', fileIds, e); } catch (error) {
showError(t('memories', 'Failed to favorite files.')); console.error("Failed to favorite", fileInfo, error);
return; showError(t("memories", "Failed to favorite {fileName}.", fileInfo));
return 0;
} }
});
// Favorite each file yield* base.runInParallel(calls, 10);
fileInfos = fileInfos.filter((f) => fileIdsSet.has(f.fileid)); }
const calls = fileInfos.map((fileInfo) => async () => {
try {
await favoriteFile(fileInfo.filename, favoriteState);
return fileInfo.fileid as number;
} catch (error) {
console.error('Failed to favorite', fileInfo, error);
showError(t('memories', 'Failed to favorite {fileName}.', fileInfo));
return 0;
}
});
yield* base.runInParallel(calls, 10);
}

View File

@ -1,32 +1,37 @@
import * as base from './base'; import * as base from "./base";
import { getCurrentUser } from '@nextcloud/auth' import { getCurrentUser } from "@nextcloud/auth";
import { genFileInfo } from '../FileUtils' import { genFileInfo } from "../FileUtils";
import { IFileInfo } from '../../types'; import { IFileInfo } from "../../types";
import client from '../DavClient'; import client from "../DavClient";
/** /**
* Get file infos for files in folder path * Get file infos for files in folder path
* @param folderPath Path to folder * @param folderPath Path to folder
* @param limit Max number of files to return * @param limit Max number of files to return
*/ */
export async function getFolderPreviewFileIds(folderPath: string, limit: number): Promise<IFileInfo[]> { export async function getFolderPreviewFileIds(
const prefixPath = `/files/${getCurrentUser()!.uid}`; folderPath: string,
limit: number
): Promise<IFileInfo[]> {
const prefixPath = `/files/${getCurrentUser()!.uid}`;
const filter = base.IMAGE_MIME_TYPES.map(mime => ` const filter = base.IMAGE_MIME_TYPES.map(
(mime) => `
<d:like> <d:like>
<d:prop> <d:prop>
<d:getcontenttype/> <d:getcontenttype/>
</d:prop> </d:prop>
<d:literal>${mime}</d:literal> <d:literal>${mime}</d:literal>
</d:like> </d:like>
`).join(''); `
).join("");
const options = { const options = {
method: 'SEARCH', method: "SEARCH",
headers: { headers: {
'content-Type': 'text/xml', "content-Type": "text/xml",
}, },
data: `<?xml version="1.0" encoding="UTF-8"?> data: `<?xml version="1.0" encoding="UTF-8"?>
<d:searchrequest xmlns:d="DAV:" <d:searchrequest xmlns:d="DAV:"
xmlns:oc="http://owncloud.org/ns" xmlns:oc="http://owncloud.org/ns"
xmlns:nc="http://nextcloud.org/ns" xmlns:nc="http://nextcloud.org/ns"
@ -54,16 +59,18 @@ export async function getFolderPreviewFileIds(folderPath: string, limit: number)
</d:limit> </d:limit>
</d:basicsearch> </d:basicsearch>
</d:searchrequest>`, </d:searchrequest>`,
deep: true, deep: true,
details: true, details: true,
responseType: 'text', responseType: "text",
}; };
let response:any = await client.getDirectoryContents('', options); let response: any = await client.getDirectoryContents("", options);
return response.data return response.data
.map((data: any) => genFileInfo(data)) .map((data: any) => genFileInfo(data))
.map((data: any) => Object.assign({}, data, { .map((data: any) =>
filename: data.filename.replace(prefixPath, ''), Object.assign({}, data, {
etag: data.etag.replace(/&quot;/g, ''), // remove quotes filename: data.filename.replace(prefixPath, ""),
})); etag: data.etag.replace(/&quot;/g, ""), // remove quotes
} })
);
}

View File

@ -1,30 +1,32 @@
import { generateUrl } from '@nextcloud/router' import { generateUrl } from "@nextcloud/router";
import { IDay, IPhoto } from '../../types'; import { IDay, IPhoto } from "../../types";
import axios from '@nextcloud/axios' import axios from "@nextcloud/axios";
/** /**
* Get original onThisDay response. * Get original onThisDay response.
*/ */
export async function getOnThisDayRaw() { export async function getOnThisDayRaw() {
const dayIds: number[] = []; const dayIds: number[] = [];
const now = new Date(); const now = new Date();
const nowUTC = new Date(now.getTime() - now.getTimezoneOffset() * 60000); const nowUTC = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
// Populate dayIds // Populate dayIds
for (let i = 1; i <= 120; i++) { for (let i = 1; i <= 120; i++) {
// +- 3 days from this day // +- 3 days from this day
for (let j = -3; j <= 3; j++) { for (let j = -3; j <= 3; j++) {
const d = new Date(nowUTC); const d = new Date(nowUTC);
d.setFullYear(d.getFullYear() - i); d.setFullYear(d.getFullYear() - i);
d.setDate(d.getDate() + j); d.setDate(d.getDate() + j);
const dayId = Math.floor(d.getTime() / 1000 / 86400) const dayId = Math.floor(d.getTime() / 1000 / 86400);
dayIds.push(dayId); dayIds.push(dayId);
}
} }
}
return (await axios.post<IPhoto[]>(generateUrl('/apps/memories/api/days'), { return (
body_ids: dayIds.join(','), await axios.post<IPhoto[]>(generateUrl("/apps/memories/api/days"), {
})).data; body_ids: dayIds.join(","),
})
).data;
} }
/** /**
@ -32,30 +34,30 @@ export async function getOnThisDayRaw() {
* Query for last 120 years; should be enough * Query for last 120 years; should be enough
*/ */
export async function getOnThisDayData(): Promise<IDay[]> { export async function getOnThisDayData(): Promise<IDay[]> {
// Query for photos // Query for photos
let data = await getOnThisDayRaw(); let data = await getOnThisDayRaw();
// Group photos by day // Group photos by day
const ans: IDay[] = []; const ans: IDay[] = [];
let prevDayId = Number.MIN_SAFE_INTEGER; let prevDayId = Number.MIN_SAFE_INTEGER;
for (const photo of data) { for (const photo of data) {
if (!photo.dayid) continue; if (!photo.dayid) continue;
// This works because the response is sorted by date taken // This works because the response is sorted by date taken
if (photo.dayid !== prevDayId) { if (photo.dayid !== prevDayId) {
ans.push({ ans.push({
dayid: photo.dayid, dayid: photo.dayid,
count: 0, count: 0,
detail: [], detail: [],
}); });
prevDayId = photo.dayid; prevDayId = photo.dayid;
}
// Add to last day
const day = ans[ans.length - 1];
day.detail.push(photo);
day.count++;
} }
return ans; // Add to last day
} const day = ans[ans.length - 1];
day.detail.push(photo);
day.count++;
}
return ans;
}

View File

@ -1,38 +1,45 @@
import { generateUrl } from '@nextcloud/router' import { generateUrl } from "@nextcloud/router";
import { IDay, IPhoto, ITag } from '../../types'; import { IDay, IPhoto, ITag } from "../../types";
import { constants, hashCode } from '../Utils'; import { constants, hashCode } from "../Utils";
import axios from '@nextcloud/axios' import axios from "@nextcloud/axios";
/** /**
* Get list of tags and convert to Days response * Get list of tags and convert to Days response
*/ */
export async function getTagsData(): Promise<IDay[]> { export async function getTagsData(): Promise<IDay[]> {
// Query for photos // Query for photos
let data: { let data: {
id: number; id: number;
count: number; count: number;
name: string; name: string;
previews: IPhoto[]; previews: IPhoto[];
}[] = []; }[] = [];
try { try {
const res = await axios.get<typeof data>(generateUrl('/apps/memories/api/tags')); const res = await axios.get<typeof data>(
data = res.data; generateUrl("/apps/memories/api/tags")
} catch (e) { );
throw e; data = res.data;
} } catch (e) {
throw e;
}
// Add flag to previews // Add flag to previews
data.forEach(t => t.previews?.forEach((preview) => preview.flag = 0)); data.forEach((t) => t.previews?.forEach((preview) => (preview.flag = 0)));
// Convert to days response // Convert to days response
return [{ return [
dayid: constants.TagDayID.TAGS, {
count: data.length, dayid: constants.TagDayID.TAGS,
detail: data.map((tag) => ({ count: data.length,
detail: data.map(
(tag) =>
({
...tag, ...tag,
fileid: hashCode(tag.name), fileid: hashCode(tag.name),
flag: constants.c.FLAG_IS_TAG, flag: constants.c.FLAG_IS_TAG,
istag: true, istag: true,
} as ITag)), } as ITag)
}] ),
} },
];
}

View File

@ -1,201 +1,201 @@
import { VueConstructor } from "vue"; import { VueConstructor } from "vue";
export type IFileInfo = { export type IFileInfo = {
/** Database file ID */ /** Database file ID */
fileid: number; fileid: number;
/** Full file name, e.g. /pi/test/Qx0dq7dvEXA.jpg */ /** Full file name, e.g. /pi/test/Qx0dq7dvEXA.jpg */
filename: string; filename: string;
/** Original file name, e.g. /files/admin/pi/test/Qx0dq7dvEXA.jpg */ /** Original file name, e.g. /files/admin/pi/test/Qx0dq7dvEXA.jpg */
originalFilename: string; originalFilename: string;
/** Base name of file e.g. Qx0dq7dvEXA.jpg */ /** Base name of file e.g. Qx0dq7dvEXA.jpg */
basename: string; basename: string;
/** Etag identifier */ /** Etag identifier */
etag: string; etag: string;
/** File has preview available */ /** File has preview available */
hasPreview: boolean; hasPreview: boolean;
/** File is marked favorite */ /** File is marked favorite */
favorite: boolean; favorite: boolean;
/** Vue flags */ /** Vue flags */
flag?: number; flag?: number;
} };
export type IDay = { export type IDay = {
/** Day ID */ /** Day ID */
dayid: number; dayid: number;
/** Number of photos in this day */ /** Number of photos in this day */
count: number; count: number;
/** Rows in the day */ /** Rows in the day */
rows?: IRow[]; rows?: IRow[];
/** List of photos for this day */ /** List of photos for this day */
detail?: IPhoto[]; detail?: IPhoto[];
} };
export type IPhoto = { export type IPhoto = {
/** Nextcloud ID of file */ /** Nextcloud ID of file */
fileid: number; fileid: number;
/** Etag from server */ /** Etag from server */
etag?: string; etag?: string;
/** Bit flags */ /** Bit flags */
flag: number; flag: number;
/** DayID from server */ /** DayID from server */
dayid?: number; dayid?: number;
/** Width of full image */ /** Width of full image */
w?: number; w?: number;
/** Height of full image */ /** Height of full image */
h?: number; h?: number;
/** Grid display width px */ /** Grid display width px */
dispW?: number; dispW?: number;
/** Grid display height px */ /** Grid display height px */
dispH?: number; dispH?: number;
/** Grid display X px */ /** Grid display X px */
dispX?: number; dispX?: number;
/** Grid display Y px */ /** Grid display Y px */
dispY?: number; dispY?: number;
/** Grid display row id (relative to head) */ /** Grid display row id (relative to head) */
dispRowNum?: number; dispRowNum?: number;
/** Reference to day object */ /** Reference to day object */
d?: IDay; d?: IDay;
/** Face dimensions */ /** Face dimensions */
facerect?: IFaceRect; facerect?: IFaceRect;
/** Video flag from server */ /** Video flag from server */
isvideo?: boolean; isvideo?: boolean;
/** Favorite flag from server */ /** Favorite flag from server */
isfavorite?: boolean; isfavorite?: boolean;
/** Is this a folder */ /** Is this a folder */
isfolder?: boolean; isfolder?: boolean;
/** Is this a tag */ /** Is this a tag */
istag?: boolean; istag?: boolean;
/** Is this an album */ /** Is this an album */
isalbum?: boolean; isalbum?: boolean;
/** Is this a face */ /** Is this a face */
isface?: boolean; isface?: boolean;
/** Optional datetaken epoch */ /** Optional datetaken epoch */
datetaken?: number; datetaken?: number;
} };
export interface IFolder extends IPhoto { export interface IFolder extends IPhoto {
/** Path to folder */ /** Path to folder */
path: string; path: string;
/** FileInfos for preview images */ /** FileInfos for preview images */
previewFileInfos?: IFileInfo[]; previewFileInfos?: IFileInfo[];
/** Name of folder */ /** Name of folder */
name: string; name: string;
} }
export interface ITag extends IPhoto { export interface ITag extends IPhoto {
/** Name of tag */ /** Name of tag */
name: string; name: string;
/** Number of images in this tag */ /** Number of images in this tag */
count: number; count: number;
/** User for face if face */ /** User for face if face */
user_id?: string; user_id?: string;
/** Cache of previews */ /** Cache of previews */
previews?: IPhoto[]; previews?: IPhoto[];
} }
export interface IAlbum extends ITag { export interface IAlbum extends ITag {
/** ID of album */ /** ID of album */
album_id: number; album_id: number;
/** Owner of album */ /** Owner of album */
user: string; user: string;
/** Created timestamp */ /** Created timestamp */
created: number; created: number;
/** Location string */ /** Location string */
location: string; location: string;
/** File ID of last added photo */ /** File ID of last added photo */
last_added_photo: number; last_added_photo: number;
} }
export interface IFaceRect { export interface IFaceRect {
w: number; w: number;
h: number; h: number;
x: number; x: number;
y: number; y: number;
} }
export type IRow = { export type IRow = {
/** Vue Recycler identifier */ /** Vue Recycler identifier */
id?: string; id?: string;
/** Row ID from head */ /** Row ID from head */
num: number; num: number;
/** Day ID */ /** Day ID */
dayId: number; dayId: number;
/** Refrence to day object */ /** Refrence to day object */
day: IDay; day: IDay;
/** Whether this is a head row */ /** Whether this is a head row */
type: IRowType; type: IRowType;
/** [Head only] Title of the header */ /** [Head only] Title of the header */
name?: string; name?: string;
/** [Head only] Boolean if the entire day is selected */ /** [Head only] Boolean if the entire day is selected */
selected?: boolean; selected?: boolean;
/** Main list of photo items */ /** Main list of photo items */
photos?: IPhoto[]; photos?: IPhoto[];
/** Height in px of the row */ /** Height in px of the row */
size?: number; size?: number;
/** Count of placeholders to create */ /** Count of placeholders to create */
pct?: number; pct?: number;
} };
export type IHeadRow = IRow & { export type IHeadRow = IRow & {
type: IRowType.HEAD; type: IRowType.HEAD;
selected: boolean; selected: boolean;
super?: string; super?: string;
} };
export enum IRowType { export enum IRowType {
HEAD = 0, HEAD = 0,
PHOTOS = 1, PHOTOS = 1,
FOLDERS = 2, FOLDERS = 2,
} }
export type ITick = { export type ITick = {
/** Day ID */ /** Day ID */
dayId: number; dayId: number;
/** Display top position */ /** Display top position */
topF: number; topF: number;
/** Display top position (truncated to 1 decimal pt) */ /** Display top position (truncated to 1 decimal pt) */
top: number; top: number;
/** Y coordinate on recycler */ /** Y coordinate on recycler */
y: number; y: number;
/** Cumulative number of photos before this tick */ /** Cumulative number of photos before this tick */
count: number; count: number;
/** Is a new month */ /** Is a new month */
isMonth: boolean; isMonth: boolean;
/** Text if any (e.g. year) */ /** Text if any (e.g. year) */
text?: string | number; text?: string | number;
/** Whether this tick should be shown */ /** Whether this tick should be shown */
s?: boolean; s?: boolean;
/** Key for vue component */ /** Key for vue component */
key?: number key?: number;
} };
export type TopMatter = { export type TopMatter = {
type: TopMatterType; type: TopMatterType;
} };
export enum TopMatterType { export enum TopMatterType {
NONE = 0, NONE = 0,
FOLDER = 1, FOLDER = 1,
TAG = 2, TAG = 2,
FACE = 3, FACE = 3,
ALBUM = 4, ALBUM = 4,
} }
export type TopMatterFolder = TopMatter & { export type TopMatterFolder = TopMatter & {
type: TopMatterType.FOLDER; type: TopMatterType.FOLDER;
list: { list: {
text: string; text: string;
path: string; path: string;
}[]; }[];
} };
export type ISelectionAction = { export type ISelectionAction = {
/** Display text */ /** Display text */
name: string; name: string;
/** Icon component */ /** Icon component */
icon: VueConstructor; icon: VueConstructor;
/** Action to perform */ /** Action to perform */
callback: (selection: Map<number, IPhoto>) => Promise<void>; callback: (selection: Map<number, IPhoto>) => Promise<void>;
/** Condition to check for including */ /** Condition to check for including */
if?: (self?: any) => boolean; if?: (self?: any) => boolean;
} };

14
src/vue-shims.d.ts vendored
View File

@ -1,10 +1,10 @@
declare module "*.vue" { declare module "*.vue" {
import Vue from "vue" import Vue from "vue";
export default Vue export default Vue;
} }
declare module '*.svg' { declare module "*.svg" {
import Vue, {VueConstructor} from 'vue'; import Vue, { VueConstructor } from "vue";
const content: VueConstructor<Vue>; const content: VueConstructor<Vue>;
export default content; export default content;
} }