Skip to content

Commit 314f48e

Browse files
authored
Fix timeline mode wrong indexing. (#3364)
1 parent 5216781 commit 314f48e

11 files changed

Lines changed: 151 additions & 41 deletions

File tree

resources/js/components/gallery/albumModule/AlbumPanel.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
v-if="layoutConfig !== null && photos !== null && photos.length > 0"
6969
header="gallery.album.header_photos"
7070
:photos="photos"
71+
:photos-timeline="photosTimeline"
7172
:album="album"
7273
:gallery-config="layoutConfig"
7374
:photo-layout="config.photo_layout"
@@ -125,6 +126,7 @@ import AlbumStatistics from "@/components/drawers/AlbumStatistics.vue";
125126
import { useTogglablesStateStore } from "@/stores/ModalsState";
126127
import { usePhotoRoute } from "@/composables/photo/photoRoute";
127128
import { useRouter } from "vue-router";
129+
import { type SplitData } from "@/composables/album/splitter";
128130
129131
const router = useRouter();
130132
@@ -135,6 +137,8 @@ const props = defineProps<{
135137
| App.Http.Resources.Models.TagAlbumResource
136138
| App.Http.Resources.Models.SmartAlbumResource
137139
| undefined;
140+
photos: App.Http.Resources.Models.PhotoResource[];
141+
photosTimeline: SplitData<App.Http.Resources.Models.PhotoResource>[] | undefined;
138142
config: App.Http.Resources.GalleryConfigs.AlbumConfig | undefined;
139143
user: App.Http.Resources.Models.UserResource | undefined;
140144
layoutConfig: App.Http.Resources.GalleryConfigs.PhotoLayoutConfig;
@@ -144,7 +148,8 @@ const props = defineProps<{
144148
const modelAlbum = computed(() => props.modelAlbum);
145149
const album = computed(() => props.album);
146150
const hasHidden = computed(() => modelAlbum.value !== undefined && modelAlbum.value.albums.filter((album) => album.is_nsfw).length > 0);
147-
const photos = computed<App.Http.Resources.Models.PhotoResource[]>(() => album.value?.photos ?? []);
151+
const photos = computed<App.Http.Resources.Models.PhotoResource[]>(() => props.photos);
152+
const photosTimeline = computed(() => props.photosTimeline);
148153
149154
const config = ref(props.config);
150155

resources/js/components/gallery/albumModule/AlbumThumbPanel.vue

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,15 @@
5656
<script setup lang="ts">
5757
import Panel from "primevue/panel";
5858
import { AlbumThumbConfig } from "@/components/gallery/albumModule/thumbs/AlbumThumb.vue";
59-
import { computed } from "vue";
60-
import { SplitData, useSplitter } from "@/composables/album/splitter";
59+
import { computed, onMounted, watch } from "vue";
60+
import { type SplitData, useSplitter } from "@/composables/album/splitter";
6161
import Timeline from "primevue/timeline";
6262
import { useLycheeStateStore } from "@/stores/LycheeState";
6363
import { storeToRefs } from "pinia";
6464
import AlbumThumbPanelList from "./AlbumThumbPanelList.vue";
6565
6666
const lycheeStore = useLycheeStateStore();
67-
const { are_nsfw_visible, is_timeline_left_border_visible } = storeToRefs(lycheeStore);
67+
const { are_nsfw_visible, is_timeline_left_border_visible, is_debug_enabled } = storeToRefs(lycheeStore);
6868
6969
const props = defineProps<{
7070
header: string;
@@ -83,7 +83,7 @@ const emits = defineEmits<{
8383
contexted: [idx: number, event: MouseEvent];
8484
}>();
8585
86-
const { spliter } = useSplitter();
86+
const { spliter, verifyOrder } = useSplitter();
8787
8888
const propagateClicked = (idx: number, e: MouseEvent) => {
8989
emits("clicked", idx, e);
@@ -94,16 +94,40 @@ const propagateMenuOpen = (idx: number, e: MouseEvent) => {
9494
};
9595
9696
const albumsTimeLine = computed<SplitData<App.Http.Resources.Models.ThumbAlbumResource>[]>(() =>
97-
spliter(
98-
props.albums as App.Http.Resources.Models.ThumbAlbumResource[],
99-
(a: App.Http.Resources.Models.ThumbAlbumResource) => a.timeline?.time_date ?? "",
100-
(a: App.Http.Resources.Models.ThumbAlbumResource) => a.timeline?.format ?? "Others",
101-
),
97+
split(props.albums as App.Http.Resources.Models.ThumbAlbumResource[]),
10298
);
10399
104100
const isTimeline = computed(() => props.isTimeline && albumsTimeLine.value.length > 1);
105101
106102
const headerClass = computed(() => {
107103
return props.isAlone ? "hidden" : "";
108104
});
105+
106+
function split(albums: App.Http.Resources.Models.ThumbAlbumResource[]) {
107+
return spliter(
108+
albums,
109+
(a: App.Http.Resources.Models.ThumbAlbumResource) => a.timeline?.time_date ?? "",
110+
(a: App.Http.Resources.Models.ThumbAlbumResource) => a.timeline?.format ?? "Others",
111+
);
112+
}
113+
114+
onMounted(() => {
115+
validate(props.albums as App.Http.Resources.Models.ThumbAlbumResource[]);
116+
});
117+
118+
function validate(albums: App.Http.Resources.Models.ThumbAlbumResource[]) {
119+
if (props.isTimeline) {
120+
const splitted = split(albums);
121+
verifyOrder(is_debug_enabled.value, albums, splitted);
122+
}
123+
}
124+
125+
watch(
126+
() => props.album?.id,
127+
() => {
128+
if (props.isTimeline) {
129+
validate(props.albums as App.Http.Resources.Models.ThumbAlbumResource[]);
130+
}
131+
},
132+
);
109133
</script>

resources/js/components/gallery/albumModule/PhotoThumbPanel.vue

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@
1111
:galleryConfig="props.galleryConfig"
1212
:selectedPhotos="props.selectedPhotos"
1313
:iter="0"
14-
:idx="0"
14+
:group-idx="0"
1515
@clicked="propagateClicked"
1616
@selected="propagateSelected"
1717
@contexted="propagateMenuOpen"
1818
:isTimeline="isTimeline"
1919
/>
2020
<template v-else>
21-
<Timeline v-if="isLeftBorderVisible" :value="photosTimeLine" :pt:eventopposite:class="'hidden'" class="mt-4">
21+
<Timeline v-if="isLeftBorderVisible" :value="props.photosTimeline" :pt:eventopposite:class="'hidden'" class="mt-4">
2222
<template #content="slotProps">
2323
<div
2424
data-type="timelineBlock"
@@ -33,7 +33,7 @@
3333
:galleryConfig="props.galleryConfig"
3434
:selectedPhotos="props.selectedPhotos"
3535
:iter="slotProps.item.iter"
36-
:idx="slotProps.index"
36+
:group-idx="slotProps.index"
3737
:isTimeline="isTimeline"
3838
@contexted="propagateMenuOpen"
3939
@selected="propagateSelected"
@@ -43,7 +43,7 @@
4343
</template>
4444
</Timeline>
4545
<div v-else>
46-
<template v-for="(photoTimeline, idx) in photosTimeLine" :key="'photoTimeline' + idx">
46+
<template v-for="(photoTimeline, idx) in props.photosTimeline" :key="'photoTimeline' + idx">
4747
<div
4848
data-type="timelineBlock"
4949
:data-date="photoTimeline.data[0].timeline?.time_date"
@@ -57,7 +57,7 @@
5757
:galleryConfig="props.galleryConfig"
5858
:selectedPhotos="props.selectedPhotos"
5959
:iter="photoTimeline.iter"
60-
:idx="idx"
60+
:group-idx="idx"
6161
:isTimeline="isTimeline"
6262
@contexted="propagateMenuOpen"
6363
@selected="propagateSelected"
@@ -74,18 +74,20 @@ import { computed, ref } from "vue";
7474
import Panel from "primevue/panel";
7575
import { useLycheeStateStore } from "@/stores/LycheeState";
7676
import { storeToRefs } from "pinia";
77-
import { SplitData, useSplitter } from "@/composables/album/splitter";
77+
import { type SplitData, useSplitter } from "@/composables/album/splitter";
7878
import Timeline from "primevue/timeline";
7979
import PhotoThumbPanelList from "./PhotoThumbPanelList.vue";
8080
import PhotoThumbPanelControl from "./PhotoThumbPanelControl.vue";
8181
import { isTouchDevice } from "@/utils/keybindings-utils";
82+
import { onMounted } from "vue";
8283
8384
const lycheeStore = useLycheeStateStore();
84-
const { is_timeline_left_border_visible } = storeToRefs(lycheeStore);
85+
const { is_timeline_left_border_visible, is_debug_enabled } = storeToRefs(lycheeStore);
8586
8687
const props = defineProps<{
8788
header: string;
88-
photos: { [key: number]: App.Http.Resources.Models.PhotoResource };
89+
photos: App.Http.Resources.Models.PhotoResource[];
90+
photosTimeline: SplitData<App.Http.Resources.Models.PhotoResource>[] | undefined;
8991
photoLayout: App.Enum.PhotoLayoutType;
9092
album:
9193
| App.Http.Resources.Models.AlbumResource
@@ -122,15 +124,13 @@ const propagateMenuOpen = (idx: number, e: MouseEvent) => {
122124
emits("contexted", idx, e);
123125
};
124126
125-
const { spliter } = useSplitter();
127+
const { verifyOrder } = useSplitter();
126128
127-
const photosTimeLine = computed<SplitData<App.Http.Resources.Models.PhotoResource>[]>(() =>
128-
spliter(
129-
props.photos as App.Http.Resources.Models.PhotoResource[],
130-
(p: App.Http.Resources.Models.PhotoResource) => p.timeline?.time_date ?? "",
131-
(p: App.Http.Resources.Models.PhotoResource) => p.timeline?.format ?? "Others",
132-
),
133-
);
129+
const isTimeline = computed(() => props.isTimeline && props.photosTimeline !== undefined && props.photosTimeline.length > 1);
134130
135-
const isTimeline = computed(() => props.isTimeline && photosTimeLine.value.length > 1);
131+
onMounted(() => {
132+
if (isTimeline.value) {
133+
verifyOrder(is_debug_enabled.value, props.photos, props.photosTimeline as SplitData<App.Http.Resources.Models.PhotoResource>[]);
134+
}
135+
});
136136
</script>

resources/js/components/gallery/albumModule/PhotoThumbPanelList.vue

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
<template>
2-
<div class="relative flex flex-wrap flex-row shrink w-full justify-start align-top" :id="'photoListing' + props.idx">
2+
<div class="relative flex flex-wrap flex-row shrink w-full justify-start align-top" :id="'photoListing' + props.groupIdx">
33
<template v-for="(photo, idx) in props.photos" :key="photo.id">
44
<PhotoThumb
5-
@click="maySelect(idx + iter, $event)"
6-
@contextmenu.prevent="menuOpen(idx + iter, $event)"
5+
@click="maySelect(idx + props.iter, $event)"
6+
@contextmenu.prevent="menuOpen(idx + props.iter, $event)"
77
:is-selected="props.selectedPhotos.includes(photo.id)"
88
:photo="photo"
99
:album="props.album"
10-
:is-lazy="idx + iter > 10"
10+
:is-lazy="idx + props.iter > 10"
1111
/>
1212
</template>
1313
</div>
@@ -30,7 +30,7 @@ const props = defineProps<{
3030
galleryConfig: App.Http.Resources.GalleryConfigs.PhotoLayoutConfig;
3131
selectedPhotos: string[];
3232
iter: number;
33-
idx: number;
33+
groupIdx: number;
3434
}>();
3535
3636
const lycheeStore = useLycheeStateStore();
@@ -48,17 +48,19 @@ const emits = defineEmits<{
4848
selected: [idx: number, event: MouseEvent];
4949
contexted: [idx: number, event: MouseEvent];
5050
}>();
51-
const maySelect = (idx: number, e: MouseEvent) => {
51+
function maySelect(idx: number, e: MouseEvent) {
5252
if (ctrlKeyState.value || metaKeyState.value || shiftKeyState.value) {
5353
emits("selected", idx, e);
5454
return;
5555
}
5656
emits("clicked", idx, e);
57-
};
58-
const menuOpen = (idx: number, e: MouseEvent) => emits("contexted", idx, e);
57+
}
58+
function menuOpen(idx: number, e: MouseEvent) {
59+
emits("contexted", idx, e);
60+
}
5961
6062
// Layouts stuff
61-
const { activateLayout } = useLayouts(props.galleryConfig, layout, timelineData, "photoListing" + props.idx);
63+
const { activateLayout } = useLayouts(props.galleryConfig, layout, timelineData, "photoListing" + props.groupIdx);
6264
onMounted(() => activateLayout());
6365
onUpdated(() => activateLayout());
6466
</script>

resources/js/components/gallery/searchModule/ResultPanel.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@
2121
:photo-layout="props.layout"
2222
:header="props.photoHeader"
2323
:photos="props.photos"
24+
:photos-timeline="undefined"
2425
:album="undefined"
2526
:gallery-config="props.layoutConfig"
2627
:selected-photos="selectedPhotosIds"
2728
@clicked="photoClick"
2829
@selected="photoSelect"
2930
@contexted="photoMenuOpen"
30-
:is-timeline="props.isPhotoTimelineEnabled"
31+
:is-timeline="false"
3132
:with-control="true"
3233
/>
3334
<div class="flex justify-center w-full" v-if="photos.length > 0">

resources/js/composables/album/albumRefresher.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { ALL } from "@/config/constants";
22
import AlbumService from "@/services/album-service";
33
import { AuthStore } from "@/stores/Auth";
44
import { computed, Ref, ref } from "vue";
5+
import { type SplitData, useSplitter } from "./splitter";
6+
7+
const { spliter, merge } = useSplitter();
58

69
export function useAlbumRefresher(albumId: Ref<string>, photoId: Ref<string | undefined>, auth: AuthStore, isLoginOpen: Ref<boolean>) {
710
const isPasswordProtected = ref(false);
@@ -15,6 +18,7 @@ export function useAlbumRefresher(albumId: Ref<string>, photoId: Ref<string | un
1518

1619
const photo = ref<App.Http.Resources.Models.PhotoResource | undefined>(undefined);
1720
const photos = ref<App.Http.Resources.Models.PhotoResource[]>([]);
21+
const photosTimeline = ref<SplitData<App.Http.Resources.Models.PhotoResource>[] | undefined>(undefined);
1822

1923
const config = ref<App.Http.Resources.GalleryConfigs.AlbumConfig | undefined>(undefined);
2024
const rights = computed(() => album.value?.rights ?? undefined);
@@ -39,14 +43,43 @@ export function useAlbumRefresher(albumId: Ref<string>, photoId: Ref<string | un
3943
modelAlbum.value = undefined;
4044
tagAlbum.value = undefined;
4145
smartAlbum.value = undefined;
46+
photosTimeline.value = undefined;
4247
if (data.data.config.is_model_album) {
4348
modelAlbum.value = data.data.resource as App.Http.Resources.Models.AlbumResource;
4449
} else if (data.data.config.is_base_album) {
4550
tagAlbum.value = data.data.resource as App.Http.Resources.Models.TagAlbumResource;
4651
} else {
4752
smartAlbum.value = data.data.resource as App.Http.Resources.Models.SmartAlbumResource;
4853
}
49-
photos.value = album.value?.photos ?? [];
54+
55+
// So what is going on here?
56+
// The problem is that the ordering of the photos from the API is not necessarily the same
57+
// as the ordering of the photos in the timeline. The timeline is constructed from the photos
58+
// taken_at data and if not provided, created_at data.
59+
// This is split into different chunks based on the granularity.
60+
// If the ordering is done by created_at, and the order of the photos match, we do not have problems.
61+
// But if one of the photos has a different taken_at date, that does not match the order, then all the following
62+
// photos are "moved" to different place which does not match the original index ordering.
63+
//
64+
// When we click on a photo, the index returned refers to the original ordering of the photos.
65+
// As a result, if the timeline is enabled, we first do the split and then merge the photos so that the
66+
// ordering is updated to reflect the timeline.
67+
//
68+
// Note that this is not something that can be fixed in the backend as we would need to assume that all the dates are
69+
// set properly. Furthermore, this would make the functionality unavailable if sorting by title is done.
70+
// By doing it in the front-end, we are able to display the photos by blocks of time,
71+
// and within the block, the ordering is done as expected.
72+
if (data.data.config.is_photo_timeline_enabled) {
73+
photosTimeline.value = spliter(
74+
data.data.resource?.photos ?? [],
75+
(p: App.Http.Resources.Models.PhotoResource) => p.timeline?.time_date ?? "",
76+
(p: App.Http.Resources.Models.PhotoResource) => p.timeline?.format ?? "Others",
77+
);
78+
photos.value = merge(photosTimeline.value);
79+
} else {
80+
// We are not using the timeline, so we can just use the photos as is.
81+
photos.value = album.value?.photos ?? [];
82+
}
5083
})
5184
.catch((error) => {
5285
if (error.response && error.response.status === 401 && error.response.data.message === "Password required") {
@@ -84,6 +117,7 @@ export function useAlbumRefresher(albumId: Ref<string>, photoId: Ref<string | un
84117
rights,
85118
photo,
86119
photos,
120+
photosTimeline,
87121
config,
88122
loadUser,
89123
loadAlbum,

resources/js/composables/album/albumsRefresher.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import AlbumService from "@/services/album-service";
22
import { AuthStore } from "@/stores/Auth";
33
import { LycheeStateStore } from "@/stores/LycheeState";
44
import { computed, ref, Ref } from "vue";
5-
import { SplitData, useSplitter } from "./splitter";
5+
import { type SplitData, useSplitter } from "./splitter";
66

77
export function useAlbumsRefresher(auth: AuthStore, lycheeStore: LycheeStateStore, isLoginOpen: Ref<boolean>) {
88
const { spliter } = useSplitter();

resources/js/composables/album/splitter.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,46 @@ export function useSplitter() {
2525
return ret;
2626
}
2727

28+
function merge<T>(data: SplitData<T>[]): T[] {
29+
const ret: T[] = [];
30+
data.forEach((d) => ret.push(...d.data));
31+
return ret;
32+
}
33+
34+
function verifyOrder(is_debug: boolean, data: { id: string }[], splitData: SplitData<{ id: string }>[]) {
35+
if (!is_debug) {
36+
return;
37+
}
38+
39+
const dataMap = new Map<string, number>();
40+
data.forEach((d, i) => {
41+
dataMap.set(d.id, i);
42+
});
43+
44+
let check = false;
45+
splitData.forEach((chunk) => {
46+
chunk.data.forEach((d, idx) => {
47+
const expected = dataMap.get(d.id);
48+
if (expected === undefined) {
49+
console.error(`Data not found in original data for id ${d.id} (WTF??)`);
50+
check = true;
51+
}
52+
const candidate = chunk.iter + idx;
53+
if (expected !== candidate) {
54+
console.error(`Data mismatch for id ${d.id} (expected ${expected}, got ${candidate})`);
55+
check = true;
56+
}
57+
});
58+
});
59+
60+
if (check) {
61+
alert("Data mismatch found in splitter, please check the console logs and contact the developer.");
62+
}
63+
}
64+
2865
return {
2966
spliter,
67+
merge,
68+
verifyOrder,
3069
};
3170
}

0 commit comments

Comments
 (0)