remove class vue dep (1)

vue3
Varun Patil 2022-12-10 01:01:44 -08:00
parent 8520d0dc1e
commit 07379d836c
9 changed files with 2632 additions and 2577 deletions

View File

@ -38,7 +38,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Mixins, Watch } from "vue-property-decorator"; import Vue, { defineComponent } from "vue";
import NcContent from "@nextcloud/vue/dist/Components/NcContent"; import NcContent from "@nextcloud/vue/dist/Components/NcContent";
import NcAppContent from "@nextcloud/vue/dist/Components/NcAppContent"; import NcAppContent from "@nextcloud/vue/dist/Components/NcAppContent";
@ -49,15 +49,12 @@ const NcAppNavigationSettings = () =>
import("@nextcloud/vue/dist/Components/NcAppNavigationSettings"); import("@nextcloud/vue/dist/Components/NcAppNavigationSettings");
import { generateUrl } from "@nextcloud/router"; import { generateUrl } from "@nextcloud/router";
import { getCurrentUser } from "@nextcloud/auth";
import { translate as t } from "@nextcloud/l10n"; import { translate as t } from "@nextcloud/l10n";
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 Metadata from "./components/Metadata.vue"; import Metadata from "./components/Metadata.vue";
import GlobalMixin from "./mixins/GlobalMixin";
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";
@ -70,7 +67,8 @@ 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({ export default defineComponent({
name: "App",
components: { components: {
NcContent, NcContent,
NcAppContent, NcAppContent,
@ -93,132 +91,66 @@ import MapIcon from "vue-material-design-icons/Map.vue";
TagsIcon, TagsIcon,
MapIcon, MapIcon,
}, },
})
export default class App extends Mixins(GlobalMixin, UserConfig) {
// Outer element
private metadataComponent!: Metadata; data() {
return {
navItems: [],
metadataComponent: null as Metadata,
};
},
private readonly navItemsAll = (self: typeof this) => [ computed: {
{ ncVersion() {
name: "timeline", const version = (<any>window.OC).config.version.split(".");
icon: ImageMultiple, return Number(version[0]);
title: t("memories", "Timeline"),
}, },
{ recognize() {
name: "folders", if (!this.config_recognizeEnabled) {
icon: FolderIcon, return false;
title: t("memories", "Folders"), }
if (this.config_facerecognitionInstalled) {
return t("memories", "People (Recognize)");
}
return t("memories", "People");
}, },
{ facerecognition() {
name: "favorites", if (!this.config_facerecognitionInstalled) {
icon: Star, return false;
title: t("memories", "Favorites"), }
if (this.config_recognizeEnabled) {
return t("memories", "People (Face Recognition)");
}
return t("memories", "People");
}, },
{ isFirstStart() {
name: "videos", return this.config_timelinePath === "EMPTY";
icon: Video,
title: t("memories", "Videos"),
}, },
{ showAlbums() {
name: "albums", return this.config_albumsEnabled;
icon: AlbumIcon,
title: t("memories", "Albums"),
if: self.showAlbums,
}, },
{ removeOuterGap() {
name: "recognize", return this.ncVersion >= 25;
icon: PeopleIcon,
title: self.recognize,
if: self.recognize,
}, },
{ showNavigation() {
name: "facerecognition", return this.$route.name !== "folder-share";
icon: PeopleIcon,
title: self.facerecognition,
if: self.facerecognition,
}, },
{ },
name: "archive",
icon: ArchiveIcon, watch: {
title: t("memories", "Archive"), route() {
this.doRouteChecks();
}, },
{ },
name: "thisday",
icon: CalendarIcon,
title: t("memories", "On this day"),
},
{
name: "tags",
icon: TagsIcon,
title: t("memories", "Tags"),
if: self.config_tagsEnabled,
},
{
name: "maps",
icon: MapIcon,
title: t("memories", "Maps"),
if: self.config_mapsEnabled,
},
];
private navItems = [];
get ncVersion() {
const version = (<any>window.OC).config.version.split(".");
return Number(version[0]);
}
get recognize() {
if (!this.config_recognizeEnabled) {
return false;
}
if (this.config_facerecognitionInstalled) {
return t("memories", "People (Recognize)");
}
return t("memories", "People");
}
get facerecognition() {
if (!this.config_facerecognitionInstalled) {
return false;
}
if (this.config_recognizeEnabled) {
return t("memories", "People (Face Recognition)");
}
return t("memories", "People");
}
get isFirstStart() {
return this.config_timelinePath === "EMPTY";
}
get showAlbums() {
return this.config_albumsEnabled;
}
get removeOuterGap() {
return this.ncVersion >= 25;
}
get showNavigation() {
return this.$route.name !== "folder-share";
}
@Watch("$route")
routeChanged() {
this.doRouteChecks();
}
mounted() { mounted() {
this.doRouteChecks(); this.doRouteChecks();
// Populate navigation // Populate navigation
this.navItems = this.navItemsAll(this).filter( this.navItems = this.navItemsAll().filter(
(item) => typeof item.if === "undefined" || Boolean(item.if) (item) => typeof item.if === "undefined" || Boolean(item.if)
); );
@ -242,10 +174,7 @@ export default class App extends Mixins(GlobalMixin, UserConfig) {
if (this.metadataComponent) { if (this.metadataComponent) {
this.metadataComponent.$destroy(); this.metadataComponent.$destroy();
} }
this.metadataComponent = new Metadata({ this.metadataComponent = new Vue(Metadata);
// Better integration with vue parent component
parent: context,
});
// Only mount after we have all the info we need // Only mount after we have all the info we need
await this.metadataComponent.update(fileInfo); await this.metadataComponent.update(fileInfo);
this.metadataComponent.$mount(el); this.metadataComponent.$mount(el);
@ -260,56 +189,123 @@ export default class App extends Mixins(GlobalMixin, UserConfig) {
}) })
); );
} }
} },
async beforeMount() { methods: {
if ("serviceWorker" in navigator) { navItemsAll() {
// Use the window load event to keep the page load performant return [
window.addEventListener("load", async () => { {
try { name: "timeline",
const url = generateUrl("/apps/memories/service-worker.js"); icon: ImageMultiple,
const registration = await navigator.serviceWorker.register(url, { title: t("memories", "Timeline"),
scope: generateUrl("/apps/memories"), },
}); {
console.log("SW registered: ", registration); name: "folders",
} catch (error) { icon: FolderIcon,
console.error("SW registration failed: ", error); title: t("memories", "Folders"),
} },
}); {
} else { name: "favorites",
console.debug("Service Worker is not enabled on this browser."); icon: Star,
} title: t("memories", "Favorites"),
} },
{
name: "videos",
icon: Video,
title: t("memories", "Videos"),
},
{
name: "albums",
icon: AlbumIcon,
title: t("memories", "Albums"),
if: this.showAlbums,
},
{
name: "recognize",
icon: PeopleIcon,
title: this.recognize,
if: this.recognize,
},
{
name: "facerecognition",
icon: PeopleIcon,
title: this.facerecognition,
if: this.facerecognition,
},
{
name: "archive",
icon: ArchiveIcon,
title: t("memories", "Archive"),
},
{
name: "thisday",
icon: CalendarIcon,
title: t("memories", "On this day"),
},
{
name: "tags",
icon: TagsIcon,
title: t("memories", "Tags"),
if: this.config_tagsEnabled,
},
{
name: "maps",
icon: MapIcon,
title: t("memories", "Maps"),
if: this.config_mapsEnabled,
},
];
},
linkClick() { async beforeMount() {
const nav: any = this.$refs.nav; if ("serviceWorker" in navigator) {
if (globalThis.windowInnerWidth <= 1024) nav?.toggleNavigation(false); // 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.");
}
},
doRouteChecks() { linkClick() {
if (this.$route.name === "folder-share") { const nav: any = this.$refs.nav;
this.putFolderShareToken(this.$route.params.token); if (globalThis.windowInnerWidth <= 1024) nav?.toggleNavigation(false);
} },
}
putFolderShareToken(token: string) { doRouteChecks() {
// Viewer looks for an input with ID sharingToken with the value as the token if (this.$route.name === "folder-share") {
// Create this element or update it otherwise files not gonna open this.putFolderShareToken(this.$route.params.token);
// https://github.com/nextcloud/viewer/blob/a8c46050fb687dcbb48a022a15a5d1275bf54a8e/src/utils/davUtils.js#L61 }
let tokenInput = document.getElementById( },
"sharingToken"
) as HTMLInputElement;
if (!tokenInput) {
tokenInput = document.createElement("input");
tokenInput.id = "sharingToken";
tokenInput.type = "hidden";
tokenInput.style.display = "none";
document.body.appendChild(tokenInput);
}
tokenInput.value = token; putFolderShareToken(token: string) {
} // Viewer looks for an input with ID sharingToken with the value as the token
} // Create this element or update it otherwise files not gonna open
// https://github.com/nextcloud/viewer/blob/a8c46050fb687dcbb48a022a15a5d1275bf54a8e/src/utils/davUtils.js#L61
let tokenInput = document.getElementById(
"sharingToken"
) as HTMLInputElement;
if (!tokenInput) {
tokenInput = document.createElement("input");
tokenInput.id = "sharingToken";
tokenInput.type = "hidden";
tokenInput.style.display = "none";
document.body.appendChild(tokenInput);
}
tokenInput.value = token;
},
},
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@ -47,7 +47,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Mixins } from "vue-property-decorator"; import { defineComponent } from "vue";
import NcContent from "@nextcloud/vue/dist/Components/NcContent"; import NcContent from "@nextcloud/vue/dist/Components/NcContent";
import NcAppContent from "@nextcloud/vue/dist/Components/NcAppContent"; import NcAppContent from "@nextcloud/vue/dist/Components/NcAppContent";
@ -57,95 +57,100 @@ import { getFilePickerBuilder } from "@nextcloud/dialogs";
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 UserConfig from "../mixins/UserConfig";
import banner from "../assets/banner.svg"; import banner from "../assets/banner.svg";
import { IDay } from "../types"; import { IDay } from "../types";
import { API } from "../services/API"; import { API } from "../services/API";
@Component({ export default defineComponent({
name: "FirstStart",
components: { components: {
NcContent, NcContent,
NcAppContent, NcAppContent,
NcButton, NcButton,
}, },
})
export default class FirstStart extends Mixins(GlobalMixin, UserConfig) { data() {
banner = banner; return {
error = ""; banner,
info = ""; error: "",
show = false; info: "",
chosenPath = ""; show: false,
chosenPath: "",
};
},
mounted() { mounted() {
window.setTimeout(() => { window.setTimeout(() => {
this.show = true; this.show = true;
}, 300); }, 300);
} },
get isAdmin() { computed: {
return getCurrentUser().isAdmin; isAdmin() {
} return getCurrentUser().isAdmin;
},
},
async begin() { methods: {
const path = await this.chooseFolder( async begin() {
this.t("memories", "Choose the root of your timeline"), 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 = API.Q(API.DAYS(), query);
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;
}
// Count total photos // Get folder days
const n = res.data.reduce((acc, day) => acc + day.count, 0); this.error = "";
this.info = this.n( this.info = "";
"memories", const query = new URLSearchParams();
"Found {n} item in {path}", query.set("timelinePath", path);
"Found {n} items in {path}", let url = API.Q(API.DAYS(), query);
n, const res = await axios.get<IDay[]>(url);
{
n, // Check response
path, if (res.status !== 200) {
this.error = this.t(
"memories",
"The selected folder does not seem to be valid. Try again."
);
return;
} }
);
this.chosenPath = path;
}
async finish() { // Count total photos
this.show = false; const n = res.data.reduce((acc, day) => acc + day.count, 0);
await new Promise((resolve) => setTimeout(resolve, 500)); this.info = this.n(
this.config_timelinePath = this.chosenPath; "memories",
await this.updateSetting("timelinePath"); "Found {n} item in {path}",
} "Found {n} items in {path}",
n,
{
n,
path,
}
);
this.chosenPath = path;
},
async chooseFolder(title: string, initial: string) { async finish() {
const picker = getFilePickerBuilder(title) this.show = false;
.setMultiSelect(false) await new Promise((resolve) => setTimeout(resolve, 500));
.setModal(true) this.config_timelinePath = this.chosenPath;
.setType(1) await this.updateSetting("timelinePath");
.addMimeTypeFilter("httpd/unix-directory") },
.allowDirectories()
.startAt(initial)
.build();
return await picker.pick(); 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>

View File

@ -45,8 +45,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Mixins } from "vue-property-decorator"; import { defineComponent } from "vue";
import GlobalMixin from "../mixins/GlobalMixin";
import NcActions from "@nextcloud/vue/dist/Components/NcActions"; import NcActions from "@nextcloud/vue/dist/Components/NcActions";
import NcActionButton from "@nextcloud/vue/dist/Components/NcActionButton"; import NcActionButton from "@nextcloud/vue/dist/Components/NcActionButton";
@ -68,258 +67,266 @@ import InfoIcon from "vue-material-design-icons/InformationOutline.vue";
import LocationIcon from "vue-material-design-icons/MapMarker.vue"; import LocationIcon from "vue-material-design-icons/MapMarker.vue";
import { API } from "../services/API"; import { API } from "../services/API";
@Component({ export default defineComponent({
name: "Metadata",
components: { components: {
NcActions, NcActions,
NcActionButton, NcActionButton,
EditIcon, EditIcon,
}, },
})
export default class Metadata extends Mixins(GlobalMixin) {
private fileInfo: IFileInfo = null;
private exif: { [prop: string]: any } = {};
private baseInfo: any = {};
private nominatim: any = null;
private state = 0;
public async update(fileInfo: IFileInfo) { data() {
this.state = Math.random(); return {
this.fileInfo = fileInfo; fileInfo: null as IFileInfo,
this.exif = {}; exif: {} as { [prop: string]: any },
this.nominatim = null; baseInfo: {} as any,
nominatim: null as any,
const state = this.state; state: 0,
const url = API.IMAGE_INFO(fileInfo.id); };
const res = await axios.get<any>(url); },
if (state !== this.state) return;
this.baseInfo = res.data;
this.exif = res.data.exif || {};
// Lazy loading
this.getNominatim().catch();
}
mounted() { mounted() {
subscribe("files:file:updated", this.handleFileUpdated); subscribe("files:file:updated", this.handleFileUpdated);
} },
beforeDestroy() { beforeDestroy() {
unsubscribe("files:file:updated", this.handleFileUpdated); unsubscribe("files:file:updated", this.handleFileUpdated);
} },
private handleFileUpdated({ fileid }) { computed: {
if (fileid && this.fileInfo?.id === fileid) { topFields() {
this.update(this.fileInfo); let list: {
} title: string;
} subtitle: string[];
icon: any;
href?: string;
edit?: () => void;
}[] = [];
get topFields() { if (this.dateOriginal) {
let list: { list.push({
title: string; title: this.dateOriginalStr,
subtitle: string[]; subtitle: this.dateOriginalTime,
icon: any; icon: CalendarIcon,
href?: string; edit: () => globalThis.editDate(globalThis.currentViewerPhoto),
edit?: () => void; });
}[] = [];
if (this.dateOriginal) {
list.push({
title: this.dateOriginalStr,
subtitle: this.dateOriginalTime,
icon: CalendarIcon,
edit: () => globalThis.editDate(globalThis.currentViewerPhoto),
});
}
if (this.camera) {
list.push({
title: this.camera,
subtitle: this.cameraSub,
icon: CameraIrisIcon,
});
}
if (this.imageInfo) {
list.push({
title: this.imageInfo,
subtitle: this.imageInfoSub,
icon: ImageIcon,
});
}
const title = this.exif?.["Title"];
const desc = this.exif?.["Description"];
if (title || desc) {
list.push({
title: title || this.t("memories", "No title"),
subtitle: [desc || this.t("memories", "No description")],
icon: InfoIcon,
edit: () => globalThis.editExif(globalThis.currentViewerPhoto),
});
}
if (this.address) {
list.push({
title: this.address,
subtitle: [],
icon: LocationIcon,
href: this.mapFullUrl,
});
}
return list;
}
/** Date taken info */
get dateOriginal() {
const dt = this.exif["DateTimeOriginal"] || this.exif["CreateDate"];
if (!dt) return null;
const m = moment.utc(dt, "YYYY:MM:DD HH:mm:ss");
if (!m.isValid()) return null;
m.locale(getCanonicalLocale());
return m;
}
get dateOriginalStr() {
if (!this.dateOriginal) return null;
return utils.getLongDateStr(this.dateOriginal.toDate(), true);
}
get dateOriginalTime() {
if (!this.dateOriginal) return null;
// Try to get timezone
let tz = this.exif["OffsetTimeOriginal"] || this.exif["OffsetTime"];
tz = tz ? "GMT" + tz : "";
let parts = [];
parts.push(this.dateOriginal.format("h:mm A"));
if (tz) parts.push(tz);
return parts;
}
/** Camera make and model info */
get camera() {
const make = this.exif["Make"];
const model = this.exif["Model"];
if (!make || !model) return null;
if (model.startsWith(make)) return model;
return `${make} ${model}`;
}
get cameraSub() {
const f = this.exif["FNumber"] || this.exif["Aperture"];
const s = this.shutterSpeed;
const len = this.exif["FocalLength"];
const iso = this.exif["ISO"];
const parts = [];
if (f) parts.push(`f/${f}`);
if (s) parts.push(`${s}`);
if (len) parts.push(`${len}mm`);
if (iso) parts.push(`ISO${iso}`);
return parts;
}
/** Convert shutter speed decimal to 1/x format */
get shutterSpeed() {
const speed = Number(
this.exif["ShutterSpeedValue"] ||
this.exif["ShutterSpeed"] ||
this.exif["ExposureTime"]
);
if (!speed) return null;
if (speed < 1) {
return `1/${Math.round(1 / speed)}`;
} else {
return `${Math.round(speed * 10) / 10}s`;
}
}
/** Image info */
get imageInfo() {
return this.fileInfo.basename || (<any>this.fileInfo).name;
}
get imageInfoSub() {
let parts = [];
let mp = Number(this.exif["Megapixels"]);
if (this.baseInfo.w && this.baseInfo.h) {
parts.push(`${this.baseInfo.w}x${this.baseInfo.h}`);
if (!mp) {
mp = (this.baseInfo.w * this.baseInfo.h) / 1000000;
} }
}
if (mp) { if (this.camera) {
parts.unshift(`${mp.toFixed(1)}MP`); list.push({
} title: this.camera,
subtitle: this.cameraSub,
icon: CameraIrisIcon,
});
}
return parts; if (this.imageInfo) {
} list.push({
title: this.imageInfo,
subtitle: this.imageInfoSub,
icon: ImageIcon,
});
}
get address() { const title = this.exif?.["Title"];
if (!this.lat || !this.lon) return null; const desc = this.exif?.["Description"];
if (title || desc) {
list.push({
title: title || this.t("memories", "No title"),
subtitle: [desc || this.t("memories", "No description")],
icon: InfoIcon,
edit: () => globalThis.editExif(globalThis.currentViewerPhoto),
});
}
if (!this.nominatim) return this.t("memories", "Loading …"); if (this.address) {
list.push({
title: this.address,
subtitle: [],
icon: LocationIcon,
href: this.mapFullUrl,
});
}
const n = this.nominatim; return list;
const country = n.address.country_code?.toUpperCase(); },
if (n.address?.city && n.address.state) { /** Date taken info */
return `${n.address.city}, ${n.address.state}, ${country}`; dateOriginal() {
} else if (n.address?.state) { const dt = this.exif["DateTimeOriginal"] || this.exif["CreateDate"];
return `${n.address.state}, ${country}`; if (!dt) return null;
} else if (n.address?.country) {
return n.address.country;
} else {
return n.display_name;
}
}
get lat() { const m = moment.utc(dt, "YYYY:MM:DD HH:mm:ss");
return this.exif["GPSLatitude"]; if (!m.isValid()) return null;
} m.locale(getCanonicalLocale());
return m;
},
get lon() { dateOriginalStr() {
return this.exif["GPSLongitude"]; if (!this.dateOriginal) return null;
} return utils.getLongDateStr(this.dateOriginal.toDate(), true);
},
get mapUrl() { dateOriginalTime() {
const boxSize = 0.0075; if (!this.dateOriginal) return null;
const bbox = [
this.lon - boxSize,
this.lat - boxSize,
this.lon + boxSize,
this.lat + boxSize,
];
const m = `${this.lat},${this.lon}`;
return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox.join()}&marker=${m}`;
}
get mapFullUrl() { // Try to get timezone
return `https://www.openstreetmap.org/?mlat=${this.lat}&mlon=${this.lon}#map=18/${this.lat}/${this.lon}`; let tz = this.exif["OffsetTimeOriginal"] || this.exif["OffsetTime"];
} tz = tz ? "GMT" + tz : "";
async getNominatim() { let parts = [];
const lat = this.lat; parts.push(this.dateOriginal.format("h:mm A"));
const lon = this.lon; if (tz) parts.push(tz);
if (!lat || !lon) return null;
const state = this.state; return parts;
const n = await axios.get( },
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=18`
); /** Camera make and model info */
if (state !== this.state) return; camera() {
this.nominatim = n.data; const make = this.exif["Make"];
} const model = this.exif["Model"];
} if (!make || !model) return null;
if (model.startsWith(make)) return model;
return `${make} ${model}`;
},
cameraSub() {
const f = this.exif["FNumber"] || this.exif["Aperture"];
const s = this.shutterSpeed;
const len = this.exif["FocalLength"];
const iso = this.exif["ISO"];
const parts = [];
if (f) parts.push(`f/${f}`);
if (s) parts.push(`${s}`);
if (len) parts.push(`${len}mm`);
if (iso) parts.push(`ISO${iso}`);
return parts;
},
/** Convert shutter speed decimal to 1/x format */
shutterSpeed() {
const speed = Number(
this.exif["ShutterSpeedValue"] ||
this.exif["ShutterSpeed"] ||
this.exif["ExposureTime"]
);
if (!speed) return null;
if (speed < 1) {
return `1/${Math.round(1 / speed)}`;
} else {
return `${Math.round(speed * 10) / 10}s`;
}
},
/** Image info */
imageInfo() {
return this.fileInfo.basename || (<any>this.fileInfo).name;
},
imageInfoSub() {
let parts = [];
let mp = Number(this.exif["Megapixels"]);
if (this.baseInfo.w && this.baseInfo.h) {
parts.push(`${this.baseInfo.w}x${this.baseInfo.h}`);
if (!mp) {
mp = (this.baseInfo.w * this.baseInfo.h) / 1000000;
}
}
if (mp) {
parts.unshift(`${mp.toFixed(1)}MP`);
}
return parts;
},
address() {
if (!this.lat || !this.lon) return null;
if (!this.nominatim) return this.t("memories", "Loading …");
const n = this.nominatim;
const country = n.address.country_code?.toUpperCase();
if (n.address?.city && n.address.state) {
return `${n.address.city}, ${n.address.state}, ${country}`;
} else if (n.address?.state) {
return `${n.address.state}, ${country}`;
} else if (n.address?.country) {
return n.address.country;
} else {
return n.display_name;
}
},
lat() {
return this.exif["GPSLatitude"];
},
lon() {
return this.exif["GPSLongitude"];
},
mapUrl() {
const boxSize = 0.0075;
const bbox = [
this.lon - boxSize,
this.lat - boxSize,
this.lon + boxSize,
this.lat + boxSize,
];
const m = `${this.lat},${this.lon}`;
return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox.join()}&marker=${m}`;
},
mapFullUrl() {
return `https://www.openstreetmap.org/?mlat=${this.lat}&mlon=${this.lon}#map=18/${this.lat}/${this.lon}`;
},
},
methods: {
async update(fileInfo: IFileInfo) {
this.state = Math.random();
this.fileInfo = fileInfo;
this.exif = {};
this.nominatim = null;
const state = this.state;
const url = API.IMAGE_INFO(fileInfo.id);
const res = await axios.get<any>(url);
if (state !== this.state) return;
this.baseInfo = res.data;
this.exif = res.data.exif || {};
// Lazy loading
this.getNominatim().catch();
},
handleFileUpdated({ fileid }) {
if (fileid && this.fileInfo?.id === fileid) {
this.update(this.fileInfo);
}
},
async getNominatim() {
const lat = this.lat;
const lon = this.lon;
if (!lat || !lon) return null;
const state = this.state;
const n = await axios.get(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=18`
);
if (state !== this.state) return;
this.nominatim = n.data;
},
},
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -49,9 +49,8 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Mixins, Prop } from "vue-property-decorator"; import { defineComponent, PropType } from "vue";
import { IRow, IRowType, ITick } from "../types"; import { IRow, IRowType, ITick } from "../types";
import GlobalMixin from "../mixins/GlobalMixin";
import ScrollIcon from "vue-material-design-icons/UnfoldMoreHorizontal.vue"; import ScrollIcon from "vue-material-design-icons/UnfoldMoreHorizontal.vue";
import * as utils from "../services/Utils"; import * as utils from "../services/Utils";
@ -59,458 +58,468 @@ import * as utils from "../services/Utils";
// Pixels to snap at // Pixels to snap at
const SNAP_OFFSET = -35; const SNAP_OFFSET = -35;
@Component({ export default defineComponent({
name: "ScrollerManager",
components: { components: {
ScrollIcon, ScrollIcon,
}, },
})
export default class ScrollerManager extends Mixins(GlobalMixin) {
/** Rows from Timeline */
@Prop() rows!: IRow[];
/** Total height */
@Prop() height!: number;
/** Actual recycler component */
@Prop() recycler!: any;
/** Recycler before slot component */
@Prop() recyclerBefore!: any;
/** Last known height at adjustment */ props: {
private lastAdjustHeight = 0; /** Rows from Timeline */
/** Height of the entire photo view */ rows: Array as PropType<IRow[]>,
private recyclerHeight: number = 100; /** Total height */
/** Rect of scroller */ height: Number,
private scrollerRect: DOMRect = null; /** Actual recycler component */
/** Computed ticks */ recycler: Object,
private ticks: ITick[] = []; /** Recycler before slot component */
/** Computed cursor top */ recyclerBefore: HTMLDivElement,
private cursorY = 0; },
/** Hover cursor top */
private hoverCursorY = -5;
/** Hover cursor text */
private hoverCursorText = "";
/** Scrolling using the scroller */
private scrollingTimer = 0;
/** Scrolling now using the scroller */
private scrollingNowTimer = 0;
/** Scrolling recycler */
private scrollingRecyclerTimer = 0;
/** Scrolling recycler now */
private scrollingRecyclerNowTimer = 0;
/** Recycler scrolling throttle */
private scrollingRecyclerUpdateTimer = 0;
/** View size reflow timer */
private reflowRequest = false;
/** Tick adjust timer */
private adjustRequest = false;
/** Scroller is being moved with interaction */
private interacting = false;
/** Track the last requested y position when interacting */
private lastRequestedRecyclerY = 0;
/** Get the visible ticks */ data() {
get visibleTicks() { return {
let key = 9999999900; /** Last known height at adjustment */
return this.ticks lastAdjustHeight: 0,
.filter((tick) => tick.s) /** Height of the entire photo view */
.map((tick) => { recyclerHeight: 100,
if (tick.text) { /** Rect of scroller */
tick.key = key = tick.dayId * 100; scrollerRect: null as DOMRect,
} else { /** Computed ticks */
tick.key = ++key; // days are sorted descending ticks: [] as ITick[],
} /** Computed cursor top */
return tick; cursorY: 0,
}); /** Hover cursor top */
} hoverCursorY: -5,
/** Hover cursor text */
/** Reset state */ hoverCursorText: "",
public reset() { /** Scrolling using the scroller */
this.ticks = []; scrollingTimer: 0,
this.cursorY = 0; /** Scrolling now using the scroller */
this.hoverCursorY = -5; scrollingNowTimer: 0,
this.hoverCursorText = ""; /** Scrolling recycler */
this.reflowRequest = false; scrollingRecyclerTimer: 0,
/** Scrolling recycler now */
// Clear all timers scrollingRecyclerNowTimer: 0,
clearTimeout(this.scrollingTimer); /** Recycler scrolling throttle */
clearTimeout(this.scrollingNowTimer); scrollingRecyclerUpdateTimer: 0,
clearTimeout(this.scrollingRecyclerTimer); /** View size reflow timer */
clearTimeout(this.scrollingRecyclerNowTimer); reflowRequest: false,
clearTimeout(this.scrollingRecyclerUpdateTimer); /** Tick adjust timer */
this.scrollingTimer = 0; adjustRequest: false,
this.scrollingNowTimer = 0; /** Scroller is being moved with interaction */
this.scrollingRecyclerTimer = 0; interacting: false,
this.scrollingRecyclerNowTimer = 0; /** Track the last requested y position when interacting */
this.scrollingRecyclerUpdateTimer = 0; lastRequestedRecyclerY: 0,
}
/** Recycler scroll event, must be called by timeline */
public recyclerScrolled() {
// This isn't a renewing timer, it's a scheduled task
if (this.scrollingRecyclerUpdateTimer) return;
this.scrollingRecyclerUpdateTimer = window.setTimeout(() => {
this.scrollingRecyclerUpdateTimer = 0;
this.updateFromRecyclerScroll();
}, 100);
// Update that we're scrolling with the recycler
utils.setRenewingTimeout(this, "scrollingRecyclerNowTimer", null, 200);
utils.setRenewingTimeout(this, "scrollingRecyclerTimer", null, 1500);
}
/** Update cursor position from recycler scroll position */
public updateFromRecyclerScroll() {
// Ignore if not initialized or moving
if (!this.ticks.length || this.interacting) return;
// Get the scroll position
const scroll = this.recycler?.$el?.scrollTop || 0;
// Get cursor px position
const { top1, top2, y1, y2 } = this.getCoords(scroll, "y");
const topfrac = (scroll - y1) / (y2 - y1);
const rtop = top1 + (top2 - top1) * (topfrac || 0);
// Always move static cursor to right position
this.cursorY = rtop;
// Move hover cursor to same position unless hovering
// Regardless, we need this call because the internal mapping might have changed
if ((<HTMLElement>this.$refs.scroller).matches(":hover")) {
this.moveHoverCursor(this.hoverCursorY);
} else {
this.moveHoverCursor(rtop);
}
}
/** Re-create tick data in the next frame */
public async reflow() {
if (this.reflowRequest) return;
this.reflowRequest = true;
await this.$nextTick();
this.reflowNow();
this.reflowRequest = false;
}
/** Re-create tick data */
private reflowNow() {
// Ignore if not initialized
if (!this.recycler?.$refs.wrapper) return;
// Refresh height of recycler
this.recyclerHeight = this.recycler.$refs.wrapper.clientHeight;
// Recreate ticks data
this.recreate();
// Adjust top
this.adjustNow();
}
/** Recreate from scratch */
private recreate() {
// Clear and override any adjust timer
this.ticks = [];
// Ticks
let prevYear = 9999;
let prevMonth = 0;
// Get a new tick
const getTick = (
dayId: number,
isMonth = false,
text?: string | number
): ITick => {
return {
dayId,
isMonth,
text,
y: 0,
count: 0,
topF: 0,
top: 0,
s: false,
};
}; };
},
// Iterate over rows computed: {
for (const row of this.rows) { /** Get the visible ticks */
if (row.type === IRowType.HEAD) { visibleTicks() {
// Create tick let key = 9999999900;
if (this.TagDayIDValueSet.has(row.dayId)) { return this.ticks
// Blank tick .filter((tick) => tick.s)
this.ticks.push(getTick(row.dayId)); .map((tick) => {
} else { if (tick.text) {
// Make date string tick.key = key = tick.dayId * 100;
const dateTaken = utils.dayIdToDate(row.dayId); } else {
tick.key = ++key; // days are sorted descending
}
return tick;
});
},
},
methods: {
/** Reset state */
reset() {
this.ticks = [];
this.cursorY = 0;
this.hoverCursorY = -5;
this.hoverCursorText = "";
this.reflowRequest = false;
// Clear all timers
clearTimeout(this.scrollingTimer);
clearTimeout(this.scrollingNowTimer);
clearTimeout(this.scrollingRecyclerTimer);
clearTimeout(this.scrollingRecyclerNowTimer);
clearTimeout(this.scrollingRecyclerUpdateTimer);
this.scrollingTimer = 0;
this.scrollingNowTimer = 0;
this.scrollingRecyclerTimer = 0;
this.scrollingRecyclerNowTimer = 0;
this.scrollingRecyclerUpdateTimer = 0;
},
/** Recycler scroll event, must be called by timeline */
recyclerScrolled() {
// This isn't a renewing timer, it's a scheduled task
if (this.scrollingRecyclerUpdateTimer) return;
this.scrollingRecyclerUpdateTimer = window.setTimeout(() => {
this.scrollingRecyclerUpdateTimer = 0;
this.updateFromRecyclerScroll();
}, 100);
// Update that we're scrolling with the recycler
utils.setRenewingTimeout(this, "scrollingRecyclerNowTimer", null, 200);
utils.setRenewingTimeout(this, "scrollingRecyclerTimer", null, 1500);
},
/** Update cursor position from recycler scroll position */
updateFromRecyclerScroll() {
// Ignore if not initialized or moving
if (!this.ticks.length || this.interacting) return;
// Get the scroll position
const scroll = this.recycler?.$el?.scrollTop || 0;
// Get cursor px position
const { top1, top2, y1, y2 } = this.getCoords(scroll, "y");
const topfrac = (scroll - y1) / (y2 - y1);
const rtop = top1 + (top2 - top1) * (topfrac || 0);
// Always move static cursor to right position
this.cursorY = rtop;
// Move hover cursor to same position unless hovering
// Regardless, we need this call because the internal mapping might have changed
if ((<HTMLElement>this.$refs.scroller).matches(":hover")) {
this.moveHoverCursor(this.hoverCursorY);
} else {
this.moveHoverCursor(rtop);
}
},
/** Re-create tick data in the next frame */
async reflow() {
if (this.reflowRequest) return;
this.reflowRequest = true;
await this.$nextTick();
this.reflowNow();
this.reflowRequest = false;
},
/** Re-create tick data */
reflowNow() {
// Ignore if not initialized
if (!this.recycler?.$refs.wrapper) return;
// Refresh height of recycler
this.recyclerHeight = this.recycler.$refs.wrapper.clientHeight;
// Recreate ticks data
this.recreate();
// Adjust top
this.adjustNow();
},
/** Recreate from scratch */
recreate() {
// Clear and override any adjust timer
this.ticks = [];
// Ticks
let prevYear = 9999;
let prevMonth = 0;
// Get a new tick
const getTick = (
dayId: number,
isMonth = false,
text?: string | number
): ITick => {
return {
dayId,
isMonth,
text,
y: 0,
count: 0,
topF: 0,
top: 0,
s: false,
};
};
// Iterate over rows
for (const row of this.rows) {
if (row.type === IRowType.HEAD) {
// Create tick // Create tick
const dtYear = dateTaken.getUTCFullYear(); if (this.TagDayIDValueSet.has(row.dayId)) {
const dtMonth = dateTaken.getUTCMonth(); // Blank tick
const isMonth = dtMonth !== prevMonth || dtYear !== prevYear; this.ticks.push(getTick(row.dayId));
const text = dtYear === prevYear ? undefined : dtYear; } else {
this.ticks.push(getTick(row.dayId, isMonth, text)); // Make date string
const dateTaken = utils.dayIdToDate(row.dayId);
prevMonth = dtMonth; // Create tick
prevYear = dtYear; const dtYear = dateTaken.getUTCFullYear();
const dtMonth = dateTaken.getUTCMonth();
const isMonth = dtMonth !== prevMonth || dtYear !== prevYear;
const text = dtYear === prevYear ? undefined : dtYear;
this.ticks.push(getTick(row.dayId, isMonth, text));
prevMonth = dtMonth;
prevYear = dtYear;
}
} }
} }
} },
}
/** /**
* Update tick positions without truncating the list * Update tick positions without truncating the list
* This is much cheaper than reflowing the whole thing * This is much cheaper than reflowing the whole thing
*/ */
public async adjust() { async adjust() {
if (this.adjustRequest) return; if (this.adjustRequest) return;
this.adjustRequest = true; this.adjustRequest = true;
await this.$nextTick(); await this.$nextTick();
this.adjustNow(); this.adjustNow();
this.adjustRequest = false; this.adjustRequest = false;
} },
/** Do adjustment synchronously */ /** Do adjustment synchronously */
private adjustNow() { adjustNow() {
// Refresh height of recycler // Refresh height of recycler
this.recyclerHeight = this.recycler.$refs.wrapper.clientHeight; this.recyclerHeight = this.recycler.$refs.wrapper.clientHeight;
const extraY = this.recyclerBefore?.clientHeight || 0; const extraY = this.recyclerBefore?.clientHeight || 0;
// Start with the first tick. Walk over all rows counting the // Start with the first tick. Walk over all rows counting the
// y position. When you hit a row with the tick, update y and // y position. When you hit a row with the tick, update y and
// top values and move to the next tick. // top values and move to the next tick.
let tickId = 0; let tickId = 0;
let y = extraY; let y = extraY;
let count = 0; let count = 0;
// We only need to recompute top and visible ticks if count // We only need to recompute top and visible ticks if count
// of some tick has changed. // of some tick has changed.
let needRecomputeTop = false; let needRecomputeTop = false;
// Check if height changed // Check if height changed
if (this.lastAdjustHeight !== this.height) { if (this.lastAdjustHeight !== this.height) {
needRecomputeTop = true; needRecomputeTop = true;
this.lastAdjustHeight = this.height; this.lastAdjustHeight = this.height;
}
for (const row of this.rows) {
// Check if tick is valid
if (tickId >= this.ticks.length) break;
// Check if we hit the next tick
const tick = this.ticks[tickId];
if (tick.dayId === row.dayId) {
tick.y = y;
// Check if count has changed
needRecomputeTop ||= tick.count !== count;
tick.count = count;
// Move to next tick
count += row.day.count;
tickId++;
} }
y += row.size; for (const row of this.rows) {
} // Check if tick is valid
if (tickId >= this.ticks.length) break;
// Compute visible ticks // Check if we hit the next tick
if (needRecomputeTop) { const tick = this.ticks[tickId];
this.setTicksTop(count); if (tick.dayId === row.dayId) {
this.computeVisibleTicks(); tick.y = y;
}
}
/** Mark ticks as visible or invisible */ // Check if count has changed
private computeVisibleTicks() { needRecomputeTop ||= tick.count !== count;
// Kind of unrelated here, but refresh rect tick.count = count;
this.scrollerRect = (
this.$refs.scroller as HTMLElement
).getBoundingClientRect();
// Do another pass to figure out which points are visible // Move to next tick
// This is not as bad as it looks, it's actually 12*O(n) count += row.day.count;
// because there are only 12 months in a year tickId++;
const fontSizePx = parseFloat(
getComputedStyle(this.$refs.cursorSt as any).fontSize
);
const minGap = fontSizePx + (globalThis.windowInnerWidth <= 768 ? 5 : 2);
let prevShow = -9999;
for (const [idx, tick] of this.ticks.entries()) {
// Conservative
tick.s = false;
// These aren't for showing
if (!tick.isMonth) continue;
// You can't see these anyway, why bother?
if (tick.top < minGap || tick.top > this.height - minGap) continue;
// Will overlap with the previous tick. Skip anyway.
if (tick.top - prevShow < minGap) continue;
// This is a labelled tick then show it anyway for the sake of best effort
if (tick.text) {
prevShow = tick.top;
tick.s = true;
continue;
}
// Lookahead for next labelled tick
// If showing this tick would overlap the next one, don't show this one
let i = idx + 1;
while (i < this.ticks.length) {
if (this.ticks[i].text) {
break;
} }
i++;
y += row.size;
} }
if (i < this.ticks.length) {
// A labelled tick was found // Compute visible ticks
const nextLabelledTick = this.ticks[i]; if (needRecomputeTop) {
if ( this.setTicksTop(count);
tick.top + minGap > nextLabelledTick.top && this.computeVisibleTicks();
nextLabelledTick.top < this.height - minGap }
) { },
// make sure this will be shown
/** Mark ticks as visible or invisible */
computeVisibleTicks() {
// Kind of unrelated here, but refresh rect
this.scrollerRect = (
this.$refs.scroller as HTMLElement
).getBoundingClientRect();
// Do another pass to figure out which points are visible
// This is not as bad as it looks, it's actually 12*O(n)
// because there are only 12 months in a year
const fontSizePx = parseFloat(
getComputedStyle(this.$refs.cursorSt as any).fontSize
);
const minGap = fontSizePx + (globalThis.windowInnerWidth <= 768 ? 5 : 2);
let prevShow = -9999;
for (const [idx, tick] of this.ticks.entries()) {
// Conservative
tick.s = false;
// These aren't for showing
if (!tick.isMonth) continue;
// You can't see these anyway, why bother?
if (tick.top < minGap || tick.top > this.height - minGap) continue;
// Will overlap with the previous tick. Skip anyway.
if (tick.top - prevShow < minGap) continue;
// This is a labelled tick then show it anyway for the sake of best effort
if (tick.text) {
prevShow = tick.top;
tick.s = true;
continue; continue;
} }
// Lookahead for next labelled tick
// If showing this tick would overlap the next one, don't show this one
let i = idx + 1;
while (i < this.ticks.length) {
if (this.ticks[i].text) {
break;
}
i++;
}
if (i < this.ticks.length) {
// A labelled tick was found
const nextLabelledTick = this.ticks[i];
if (
tick.top + minGap > nextLabelledTick.top &&
nextLabelledTick.top < this.height - minGap
) {
// make sure this will be shown
continue;
}
}
// Show this tick
tick.s = true;
prevShow = tick.top;
}
},
setTicksTop(total: number) {
for (const tick of this.ticks) {
tick.topF = this.height * (tick.count / total);
tick.top = utils.roundHalf(tick.topF);
}
},
/** Change actual position of the hover cursor */
moveHoverCursor(y: number) {
this.hoverCursorY = y;
// Get index of previous tick
let idx = utils.binarySearch(this.ticks, y, "topF");
if (idx === 0) {
// use this tick
} else if (idx >= 1 && idx <= this.ticks.length) {
idx = idx - 1;
} else {
return;
} }
// Show this tick // DayId of current hover
tick.s = true; const dayId = this.ticks[idx]?.dayId;
prevShow = tick.top;
}
}
private setTicksTop(total: number) { // Special days
for (const tick of this.ticks) { if (dayId === undefined || this.TagDayIDValueSet.has(dayId)) {
tick.topF = this.height * (tick.count / total); this.hoverCursorText = "";
tick.top = utils.roundHalf(tick.topF); return;
} }
}
/** Change actual position of the hover cursor */ const date = utils.dayIdToDate(dayId);
private moveHoverCursor(y: number) { this.hoverCursorText = utils.getShortDateStr(date);
this.hoverCursorY = y; },
// Get index of previous tick /** Handle mouse hover */
let idx = utils.binarySearch(this.ticks, y, "topF"); mousemove(event: MouseEvent) {
if (idx === 0) { if (event.buttons) {
// use this tick this.mousedown(event);
} else if (idx >= 1 && idx <= this.ticks.length) { }
idx = idx - 1; this.moveHoverCursor(event.offsetY);
} else { },
return;
}
// DayId of current hover /** Handle mouse leave */
const dayId = this.ticks[idx]?.dayId; mouseleave() {
this.interactend();
this.moveHoverCursor(this.cursorY);
},
// Special days /** Binary search and get coords surrounding position */
if (dayId === undefined || this.TagDayIDValueSet.has(dayId)) { getCoords(y: number, field: "topF" | "y") {
this.hoverCursorText = ""; // Top of first and second ticks
return; let top1 = 0,
} top2 = 0,
y1 = 0,
y2 = 0;
const date = utils.dayIdToDate(dayId); // Get index of previous tick
this.hoverCursorText = utils.getShortDateStr(date); let idx = utils.binarySearch(this.ticks, y, field);
} if (idx <= 0) {
top1 = 0;
top2 = this.ticks[0].topF;
y1 = 0;
y2 = this.ticks[0].y;
} else if (idx >= this.ticks.length) {
const t = this.ticks[this.ticks.length - 1];
top1 = t.topF;
top2 = this.height;
y1 = t.y;
y2 = this.recyclerHeight;
} else {
const t1 = this.ticks[idx - 1];
const t2 = this.ticks[idx];
top1 = t1.topF;
top2 = t2.topF;
y1 = t1.y;
y2 = t2.y;
}
/** Handle mouse hover */ return { top1, top2, y1, y2 };
private mousemove(event: MouseEvent) { },
if (event.buttons) {
this.mousedown(event);
}
this.moveHoverCursor(event.offsetY);
}
/** Handle mouse leave */ /** Move to given scroller Y */
private mouseleave() { moveto(y: number, snap: boolean) {
this.interactend(); // Move cursor immediately to prevent jank
this.moveHoverCursor(this.cursorY); this.cursorY = y;
} this.hoverCursorY = y;
/** Binary search and get coords surrounding position */ const { top1, top2, y1, y2 } = this.getCoords(y, "topF");
private getCoords(y: number, field: "topF" | "y") { const yfrac = (y - top1) / (top2 - top1);
// Top of first and second ticks const ry = y1 + (y2 - y1) * (yfrac || 0);
let top1 = 0, const targetY = snap ? y1 + SNAP_OFFSET : ry;
top2 = 0,
y1 = 0,
y2 = 0;
// Get index of previous tick if (this.lastRequestedRecyclerY !== targetY) {
let idx = utils.binarySearch(this.ticks, y, field); this.lastRequestedRecyclerY = targetY;
if (idx <= 0) { this.recycler.scrollToPosition(targetY);
top1 = 0; }
top2 = this.ticks[0].topF;
y1 = 0;
y2 = this.ticks[0].y;
} else if (idx >= this.ticks.length) {
const t = this.ticks[this.ticks.length - 1];
top1 = t.topF;
top2 = this.height;
y1 = t.y;
y2 = this.recyclerHeight;
} else {
const t1 = this.ticks[idx - 1];
const t2 = this.ticks[idx];
top1 = t1.topF;
top2 = t2.topF;
y1 = t1.y;
y2 = t2.y;
}
return { top1, top2, y1, y2 }; this.handleScroll();
} },
/** Move to given scroller Y */ /** Handle mouse click */
private moveto(y: number, snap: boolean) { mousedown(event: MouseEvent) {
// Move cursor immediately to prevent jank this.interactstart(); // end called on mouseup
this.cursorY = y; this.moveto(event.offsetY, false);
this.hoverCursorY = y; },
const { top1, top2, y1, y2 } = this.getCoords(y, "topF"); /** Handle touch */
const yfrac = (y - top1) / (top2 - top1); touchmove(event: any) {
const ry = y1 + (y2 - y1) * (yfrac || 0); let y = event.targetTouches[0].pageY - this.scrollerRect.top;
const targetY = snap ? y1 + SNAP_OFFSET : ry; y = Math.max(0, y - 20); // middle of touch finger
this.moveto(y, true);
},
if (this.lastRequestedRecyclerY !== targetY) { interactstart() {
this.lastRequestedRecyclerY = targetY; this.interacting = true;
this.recycler.scrollToPosition(targetY); },
}
this.handleScroll(); interactend() {
} this.interacting = false;
this.recyclerScrolled(); // make sure final position is correct
},
/** Handle mouse click */ /** Update scroller is being used to scroll recycler */
private mousedown(event: MouseEvent) { handleScroll() {
this.interactstart(); // end called on mouseup utils.setRenewingTimeout(this, "scrollingNowTimer", null, 200);
this.moveto(event.offsetY, false); utils.setRenewingTimeout(this, "scrollingTimer", null, 1500);
} },
},
/** Handle touch */ });
private touchmove(event: any) {
let y = event.targetTouches[0].pageY - this.scrollerRect.top;
y = Math.max(0, y - 20); // middle of touch finger
this.moveto(y, true);
}
private interactstart() {
this.interacting = true;
}
private interactend() {
this.interacting = false;
this.recyclerScrolled(); // make sure final position is correct
}
/** Update scroller is being used to scroll recycler */
private handleScroll() {
utils.setRenewingTimeout(this, "scrollingNowTimer", null, 200);
utils.setRenewingTimeout(this, "scrollingTimer", null, 1500);
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

File diff suppressed because it is too large Load Diff

View File

@ -69,9 +69,7 @@ input[type="text"] {
</style> </style>
<script lang="ts"> <script lang="ts">
import { Component, Mixins } from "vue-property-decorator"; import { defineComponent } from "vue";
import GlobalMixin from "../mixins/GlobalMixin";
import UserConfig from "../mixins/UserConfig";
import { getFilePickerBuilder } from "@nextcloud/dialogs"; import { getFilePickerBuilder } from "@nextcloud/dialogs";
const NcCheckboxRadioSwitch = () => const NcCheckboxRadioSwitch = () =>
@ -79,62 +77,69 @@ const NcCheckboxRadioSwitch = () =>
import MultiPathSelectionModal from "./modal/MultiPathSelectionModal.vue"; import MultiPathSelectionModal from "./modal/MultiPathSelectionModal.vue";
@Component({ export default defineComponent({
name: "Settings",
components: { components: {
NcCheckboxRadioSwitch, NcCheckboxRadioSwitch,
MultiPathSelectionModal, MultiPathSelectionModal,
}, },
})
export default class Settings extends Mixins(UserConfig, GlobalMixin) {
get pathSelTitle() {
return this.t("memories", "Choose Timeline Paths");
}
async chooseFolder(title: string, initial: string) { computed: {
const picker = getFilePickerBuilder(title) pathSelTitle() {
.setMultiSelect(false) return this.t("memories", "Choose Timeline Paths");
.setModal(true) },
.setType(1) },
.addMimeTypeFilter("httpd/unix-directory")
.allowDirectories()
.startAt(initial)
.build();
return await picker.pick(); methods: {
} async chooseFolder(title: string, initial: string) {
const picker = getFilePickerBuilder(title)
.setMultiSelect(false)
.setModal(true)
.setType(1)
.addMimeTypeFilter("httpd/unix-directory")
.allowDirectories()
.startAt(initial)
.build();
async chooseTimelinePath() { return await picker.pick();
(<any>this.$refs.multiPathModal).open(this.config_timelinePath.split(";")); },
}
async saveTimelinePath(paths: string[]) { async chooseTimelinePath() {
if (!paths || !paths.length) return; (<any>this.$refs.multiPathModal).open(
this.config_timelinePath.split(";")
);
},
const newPath = paths.join(";"); async saveTimelinePath(paths: string[]) {
if (newPath !== this.config_timelinePath) { if (!paths || !paths.length) return;
this.config_timelinePath = newPath;
await this.updateSetting("timelinePath");
}
}
async chooseFoldersPath() { const newPath = paths.join(";");
let newPath = await this.chooseFolder( if (newPath !== this.config_timelinePath) {
this.t("memories", "Choose the root for the folders view"), this.config_timelinePath = newPath;
this.config_foldersPath await this.updateSetting("timelinePath");
); }
if (newPath === "") newPath = "/"; },
if (newPath !== this.config_foldersPath) {
this.config_foldersPath = newPath;
await this.updateSetting("foldersPath");
}
}
async updateSquareThumbs() { async chooseFoldersPath() {
await this.updateSetting("squareThumbs"); let newPath = await this.chooseFolder(
} this.t("memories", "Choose the root for the folders view"),
this.config_foldersPath
);
if (newPath === "") newPath = "/";
if (newPath !== this.config_foldersPath) {
this.config_foldersPath = newPath;
await this.updateSetting("foldersPath");
}
},
async updateShowHidden() { async updateSquareThumbs() {
await this.updateSetting("showHidden"); await this.updateSetting("squareThumbs");
} },
}
async updateShowHidden() {
await this.updateSetting("showHidden");
},
},
});
</script> </script>

File diff suppressed because it is too large Load Diff

View File

@ -29,82 +29,89 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Watch, Mixins } from "vue-property-decorator"; import { defineComponent, PropType } from "vue";
import { IFolder, IPhoto } from "../../types"; import { IFolder, IPhoto } from "../../types";
import GlobalMixin from "../../mixins/GlobalMixin";
import UserConfig from "../../mixins/UserConfig";
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({ export default defineComponent({
name: "Folder",
components: { components: {
FolderIcon, FolderIcon,
}, },
})
export default class Folder extends Mixins(GlobalMixin, UserConfig) {
@Prop() data: IFolder;
// Separate property because the one on data isn't reactive props: {
private previews: IPhoto[] = []; data: Object as PropType<IFolder>,
},
// Error occured fetching thumbs data() {
private error = false; return {
// Separate property because the one on data isn't reactive
previews: [] as IPhoto[],
// Error occured fetching thumbs
error: false,
// Passthrough
getPreviewUrl,
};
},
/** Passthrough */ computed: {
private getPreviewUrl = getPreviewUrl; /** Open folder */
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 } };
},
},
mounted() { mounted() {
this.refreshPreviews(); this.refreshPreviews();
} },
@Watch("data") watch: {
dataChanged() { data() {
this.refreshPreviews(); this.refreshPreviews();
} },
},
/** Refresh previews */ methods: {
refreshPreviews() { /** Refresh previews */
// Reset state refreshPreviews() {
this.error = false; // Reset state
this.error = false;
// Check if valid path present // Check if valid path present
if (!this.data.path) { if (!this.data.path) {
this.error = true; this.error = true;
return; return;
}
// Get preview infos
const previews = this.data.previews;
if (previews) {
if (previews.length > 0 && previews.length < 4) {
this.previews = [previews[0]];
} else {
this.previews = previews.slice(0, 4);
} }
}
}
/** Open folder */ // Get preview infos
get target() { const previews = this.data.previews;
const path = this.data.path if (previews) {
.split("/") if (previews.length > 0 && previews.length < 4) {
.filter((x) => x) this.previews = [previews[0]];
.slice(2) as string[]; } else {
this.previews = previews.slice(0, 4);
// 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>

View File

@ -4,6 +4,8 @@ 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 GlobalMixin from "./mixins/GlobalMixin";
import UserConfig from "./mixins/UserConfig";
import App from "./App.vue"; import App from "./App.vue";
import router from "./router"; import router from "./router";
@ -64,6 +66,8 @@ if (!globalThis.videoClientIdPersistent) {
); );
} }
Vue.mixin(GlobalMixin);
Vue.mixin(UserConfig);
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