Add on this day to top of timeline (#41)
parent
d169243ef5
commit
c546c107af
|
@ -32,6 +32,11 @@
|
||||||
v-show="!$refs.topmatter.type && list.length > 0">
|
v-show="!$refs.topmatter.type && list.length > 0">
|
||||||
{{ getViewName() }}
|
{{ getViewName() }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<OnThisDay v-if="$route.name === 'timeline'"
|
||||||
|
:viewerManager="viewerManager"
|
||||||
|
@load="scrollerManager.adjust()">
|
||||||
|
</OnThisDay>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-slot="{ item }">
|
<template v-slot="{ item }">
|
||||||
|
@ -109,6 +114,7 @@ import Folder from "./frame/Folder.vue";
|
||||||
import Tag from "./frame/Tag.vue";
|
import Tag from "./frame/Tag.vue";
|
||||||
import Photo from "./frame/Photo.vue";
|
import Photo from "./frame/Photo.vue";
|
||||||
import TopMatter from "./top-matter/TopMatter.vue";
|
import TopMatter from "./top-matter/TopMatter.vue";
|
||||||
|
import OnThisDay from "./top-matter/OnThisDay.vue";
|
||||||
import SelectionManager from './SelectionManager.vue';
|
import SelectionManager from './SelectionManager.vue';
|
||||||
import ScrollerManager from './ScrollerManager.vue';
|
import ScrollerManager from './ScrollerManager.vue';
|
||||||
import UserConfig from "../mixins/UserConfig";
|
import UserConfig from "../mixins/UserConfig";
|
||||||
|
@ -128,6 +134,7 @@ const MOBILE_NUM_COLS = 3; // Number of columns on phone
|
||||||
Tag,
|
Tag,
|
||||||
Photo,
|
Photo,
|
||||||
TopMatter,
|
TopMatter,
|
||||||
|
OnThisDay,
|
||||||
SelectionManager,
|
SelectionManager,
|
||||||
ScrollerManager,
|
ScrollerManager,
|
||||||
NcEmptyContent,
|
NcEmptyContent,
|
||||||
|
@ -915,9 +922,9 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
|
||||||
* @param delPhotos photos to delete
|
* @param delPhotos photos to delete
|
||||||
*/
|
*/
|
||||||
async deleteFromViewWithAnimation(delPhotos: IPhoto[]) {
|
async deleteFromViewWithAnimation(delPhotos: IPhoto[]) {
|
||||||
if (delPhotos.length === 0) {
|
// Only keep photos with day
|
||||||
return;
|
delPhotos = delPhotos.filter(p => p.d);
|
||||||
}
|
if (delPhotos.length === 0) return;
|
||||||
|
|
||||||
// Get all days that need to be updatd
|
// Get all days that need to be updatd
|
||||||
const updatedDays = new Set<IDay>(delPhotos.map(p => p.d));
|
const updatedDays = new Set<IDay>(delPhotos.map(p => p.d));
|
||||||
|
|
|
@ -0,0 +1,253 @@
|
||||||
|
<template>
|
||||||
|
<div class="outer" v-show="years.length > 0">
|
||||||
|
<div class="inner" ref="inner">
|
||||||
|
<div v-for="year of years" class="group" :key="year.year" @click="click(year)">
|
||||||
|
<img class="fill-block"
|
||||||
|
:src="year.url" />
|
||||||
|
|
||||||
|
<div class="overlay">
|
||||||
|
{{ year.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="left-btn dir-btn memories__onthisday__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 memories__onthisday__btn" v-if="hasRight">
|
||||||
|
<NcActions>
|
||||||
|
<NcActionButton
|
||||||
|
:aria-label="t('memories', 'Move right')"
|
||||||
|
@click="moveRight">
|
||||||
|
{{ t('memories', 'Move right') }}
|
||||||
|
<template #icon> <RightMoveIcon :size="28" /> </template>
|
||||||
|
</NcActionButton>
|
||||||
|
</NcActions>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator';
|
||||||
|
import GlobalMixin from '../../mixins/GlobalMixin';
|
||||||
|
import { NcActions, NcActionButton } from '@nextcloud/vue';
|
||||||
|
|
||||||
|
import * as utils from "../../services/Utils";
|
||||||
|
import * as dav from '../../services/DavRequests';
|
||||||
|
import { ViewerManager } from "../../services/Viewer";
|
||||||
|
import { IPhoto } from '../../types';
|
||||||
|
import { getPreviewUrl } from "../../services/FileUtils";
|
||||||
|
|
||||||
|
import LeftMoveIcon from 'vue-material-design-icons/ChevronLeft.vue';
|
||||||
|
import RightMoveIcon from 'vue-material-design-icons/ChevronRight.vue';
|
||||||
|
|
||||||
|
interface IYear {
|
||||||
|
year: number;
|
||||||
|
url: string;
|
||||||
|
preview: IPhoto;
|
||||||
|
photos: IPhoto[];
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
name: 'OnThisDay',
|
||||||
|
components: {
|
||||||
|
NcActions,
|
||||||
|
NcActionButton,
|
||||||
|
LeftMoveIcon,
|
||||||
|
RightMoveIcon,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export default class OnThisDay extends Mixins(GlobalMixin) {
|
||||||
|
private getPreviewUrl = getPreviewUrl;
|
||||||
|
|
||||||
|
@Emit('load')
|
||||||
|
onload() {}
|
||||||
|
|
||||||
|
private years: IYear[] = []
|
||||||
|
|
||||||
|
private hasRight = false;
|
||||||
|
private hasLeft = false;
|
||||||
|
private scrollStack: number[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nextcloud viewer proxy
|
||||||
|
* Can't use the timeline instance because these photos
|
||||||
|
* might not be in view, so can't delete them
|
||||||
|
*/
|
||||||
|
@Prop()
|
||||||
|
private viewerManager!: ViewerManager;
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
const inner = this.$refs.inner as HTMLElement;
|
||||||
|
inner.addEventListener('scroll', this.onScroll.bind(this));
|
||||||
|
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
const photos = await dav.getOnThisDayRaw();
|
||||||
|
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: photo,
|
||||||
|
photos: [],
|
||||||
|
text: utils.getFromNowStr(dateTaken),
|
||||||
|
});
|
||||||
|
currentYear = year;
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearObj = this.years[this.years.length - 1];
|
||||||
|
yearObj.photos.push(photo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Randomly choose preview photo
|
||||||
|
for (const year of this.years) {
|
||||||
|
const index = Math.floor(Math.random() * year.photos.length);
|
||||||
|
year.preview = year.photos[index];
|
||||||
|
year.url = getPreviewUrl(year.preview.fileid, year.preview.etag, false, 512);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.$nextTick();
|
||||||
|
this.onScroll();
|
||||||
|
this.onload();
|
||||||
|
}
|
||||||
|
|
||||||
|
moveLeft() {
|
||||||
|
const inner = this.$refs.inner as HTMLElement;
|
||||||
|
inner.scrollBy(-(this.scrollStack.pop() || inner.clientWidth), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
moveRight() {
|
||||||
|
const inner = this.$refs.inner as HTMLElement;
|
||||||
|
const innerRect = inner.getBoundingClientRect();
|
||||||
|
const nextChild = Array.from(inner.children).map(c => c.getBoundingClientRect()).find((rect) =>
|
||||||
|
rect.right > innerRect.right
|
||||||
|
);
|
||||||
|
|
||||||
|
let scroll = nextChild ? (nextChild.left - innerRect.left) : inner.clientWidth;
|
||||||
|
scroll = Math.min(inner.scrollWidth - inner.scrollLeft - inner.clientWidth, scroll);
|
||||||
|
this.scrollStack.push(scroll);
|
||||||
|
inner.scrollBy(scroll, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
onScroll() {
|
||||||
|
const inner = this.$refs.inner as HTMLElement;
|
||||||
|
if (!inner) return;
|
||||||
|
this.hasLeft = inner.scrollLeft > 0;
|
||||||
|
this.hasRight = (inner.clientWidth + inner.scrollLeft < inner.scrollWidth - 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
click(year: IYear) {
|
||||||
|
const allPhotos = this.years.flatMap(y => y.photos);
|
||||||
|
this.viewerManager.open(year.preview, allPhotos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
$height: 200px;
|
||||||
|
|
||||||
|
.outer {
|
||||||
|
width: calc(100% - 50px);
|
||||||
|
height: $height;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
padding: 0 calc(28px * 0.6);
|
||||||
|
|
||||||
|
// Sloppy: ideally this should be done in Timeline
|
||||||
|
// to put a gap between the title and this
|
||||||
|
margin-top: 8px;
|
||||||
|
|
||||||
|
.inner {
|
||||||
|
height: calc(100% + 20px);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow-x: scroll;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
height: $height;
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:not(:last-of-type) { margin-right: 6px; }
|
||||||
|
|
||||||
|
img {
|
||||||
|
cursor: inherit;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: var(--color-background-dark);
|
||||||
|
background-clip: padding-box, content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
padding-bottom: 5%;
|
||||||
|
text-shadow: 0 0 2px black;
|
||||||
|
cursor: inherit;
|
||||||
|
transition: background-color 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .overlay {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.memories__onthisday__btn button {
|
||||||
|
transform: scale(0.6);
|
||||||
|
box-shadow: black 0 0 3px 0 !important;
|
||||||
|
background-color: var(--color-main-background) !important;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -391,11 +391,10 @@ export async function downloadFilesByIds(fileIds: number[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the onThisDay data
|
* Get original onThisDay response.
|
||||||
* Query for last 120 years; should be enough
|
|
||||||
*/
|
*/
|
||||||
export async function getOnThisDayData(): Promise<IDay[]> {
|
export async function getOnThisDayRaw() {
|
||||||
const diffs: { [dayId: number]: 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);
|
||||||
|
|
||||||
|
@ -407,20 +406,22 @@ export async function getOnThisDayData(): Promise<IDay[]> {
|
||||||
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)
|
||||||
diffs[dayId] = i;
|
dayIds.push(dayId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (await axios.post<IPhoto[]>(generateUrl('/apps/memories/api/days'), {
|
||||||
|
body_ids: dayIds.join(','),
|
||||||
|
})).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the onThisDay data
|
||||||
|
* Query for last 120 years; should be enough
|
||||||
|
*/
|
||||||
|
export async function getOnThisDayData(): Promise<IDay[]> {
|
||||||
// Query for photos
|
// Query for photos
|
||||||
let data: IPhoto[] = [];
|
let data = await getOnThisDayRaw();
|
||||||
try {
|
|
||||||
const res = await axios.post<IPhoto[]>(generateUrl('/apps/memories/api/days'), {
|
|
||||||
body_ids: Object.keys(diffs).join(','),
|
|
||||||
});
|
|
||||||
data = res.data;
|
|
||||||
} catch (e) {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group photos by day
|
// Group photos by day
|
||||||
const ans: IDay[] = [];
|
const ans: IDay[] = [];
|
||||||
|
|
|
@ -21,19 +21,19 @@ export class ViewerManager {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async open(photo: IPhoto) {
|
public async open(photo: IPhoto, list?: IPhoto[]) {
|
||||||
const day = photo.d;
|
list = list || photo.d?.detail;
|
||||||
if (!day) return;
|
if (!list) return;
|
||||||
|
|
||||||
// Repopulate map
|
// Repopulate map
|
||||||
this.photoMap.clear();
|
this.photoMap.clear();
|
||||||
for (const p of day.detail) {
|
for (const p of list) {
|
||||||
this.photoMap.set(p.fileid, p);
|
this.photoMap.set(p.fileid, p);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file infos
|
// Get file infos
|
||||||
let fileInfos: IFileInfo[];
|
let fileInfos: IFileInfo[];
|
||||||
const ids = day.detail.map(p => p.fileid);
|
const ids = list.map(p => p.fileid);
|
||||||
try {
|
try {
|
||||||
this.updateLoading(1);
|
this.updateLoading(1);
|
||||||
fileInfos = await dav.getFiles(ids);
|
fileInfos = await dav.getFiles(ids);
|
||||||
|
|
Loading…
Reference in New Issue