Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions resources/js/composables/selections/selections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ export function useSelection(photosStore: PhotosStore, albumsStore: AlbumsStore,
selectedPhotosIds.value = [];
}

/**
* Drop from the selection the photos and albums which are no longer part of the
* loaded collection.
*
* Called after a reload which keeps the selection alive (e.g. tagging), so that
* an item which disappeared in the meantime cannot be targeted by a later bulk
* action even though it is not displayed any more.
*/
function pruneSelection(): void {
selectedPhotosIds.value = selectedPhotosIds.value.filter((id) => photosStore.photos.some((p) => p.id === id));
selectedAlbumsIds.value = selectedAlbumsIds.value.filter((id) => albumsStore.selectableAlbums.some((a) => a.id === id));
}

function addToPhotoSelection(photoId: string): void {
if (!selectedPhotosIds.value.includes(photoId)) {
selectedPhotosIds.value.push(photoId);
Expand Down Expand Up @@ -273,6 +286,7 @@ export function useSelection(photosStore: PhotosStore, albumsStore: AlbumsStore,
albumSelect,
selectEverything,
unselect,
pruneSelection,
hasSelection,
};
}
21 changes: 19 additions & 2 deletions resources/js/services/album-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ const AlbumService = {
}
// Clear new paginated endpoint caches
axiosWithCache.storage.remove(`album_head_${album_id}`);
axiosWithCache.storage.remove(`album_tags_${album_id}`);
for (let page = 1; page <= 50; page++) {
axiosWithCache.storage.remove(`album_albums_${album_id}_page${page}`);
axiosWithCache.storage.remove(`album_photos_${album_id}_page${page}`);
Expand Down Expand Up @@ -136,12 +137,18 @@ const AlbumService = {
});
},

/**
* @param force - Skip the cached entry and refresh it with a new response.
* Needed after in-place photo edits because the cache key of a
* filtered request is not enumerable by `clearCache()`.
*/
getPhotos(
album_id: string,
page: number = 1,
tag_ids: number[] | null = null,
tag_logic: string = "OR",
person_id: string | null = null,
force: boolean = false,
): Promise<AxiosResponse<App.Http.Resources.Collections.PaginatedPhotosResource>> {
const requester = axios as unknown as AxiosCacheInstance;

Expand All @@ -166,6 +173,7 @@ const AlbumService = {
return requester.get(`${Constants.getApiUrl()}Album::photos?album_id=${album_id}&page=${page}${param}`, {
data: {},
id: cacheKey,
cache: force ? { override: true } : undefined,
});
},

Expand Down Expand Up @@ -306,8 +314,17 @@ const AlbumService = {
return axios.post(`${Constants.getApiUrl()}Album::watermark`, { album_id: album_id });
},

getAlbumTags(album_id: string): Promise<AxiosResponse<{ tags: App.Http.Resources.Tags.TagResource[] }>> {
return axios.get(`${Constants.getApiUrl()}Album::tags`, { params: { album_id: album_id }, data: {} });
/**
* @param force - Skip the cached entry and refresh it with a new response.
*/
getAlbumTags(album_id: string, force: boolean = false): Promise<AxiosResponse<{ tags: App.Http.Resources.Tags.TagResource[] }>> {
const requester = axios as unknown as AxiosCacheInstance;
return requester.get(`${Constants.getApiUrl()}Album::tags`, {
params: { album_id: album_id },
data: {},
id: `album_tags_${album_id}`,
cache: force ? { override: true } : undefined,
});
},
};

Expand Down
78 changes: 78 additions & 0 deletions resources/js/stores/AlbumState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export const useAlbumStore = defineStore("album-store", {
// Person filter state for photos
active_person_filter: null as string | null,

// Bumped whenever the tags of the photos of this album have been edited.
// Components displaying the album tag list watch it to refetch that list.
// Intentionally not cleared by reset(): it is a monotonic change signal.
tags_revision: 0,

// People in this album (loaded lazily)
album_people: [] as App.Http.Resources.Models.PersonResource[],
album_people_total: 0 as number,
Expand All @@ -62,6 +67,9 @@ export const useAlbumStore = defineStore("album-store", {
this.reset();
return this.load();
},
bumpTagsRevision() {
this.tags_revision++;
},
reset() {
this.modelAlbum = undefined;
this.tagAlbum = undefined;
Expand Down Expand Up @@ -295,6 +303,76 @@ export const useAlbumStore = defineStore("album-store", {
});
},

/**
* Re-fetch the photos which are currently displayed, in place.
*
* Contrary to `refresh()`, the store is never `reset()`: the album head, the
* config, the active filters and the loaded page window
* (`photos_min_page` … `photos_current_page`) are all preserved, and the
* thumbnails are swapped in a single mutation once every page has been
* received. The album panel therefore never unmounts, which keeps the
* scroll position — and the photo selection — intact.
*
* Meant for operations which edit photos in place (tags, license, …) and do
* not change which photos belong to the album.
*/
async reloadLoadedPhotos(): Promise<void> {
const photosState = usePhotosStore();

if (this.albumId === ALL || this.albumId === undefined) {
return;
}

// Capture current album ID to detect navigation during loading
const requestedAlbumId = this.albumId;
const firstPage = this.photos_min_page;
const lastPage = Math.max(firstPage, this.photos_current_page);

// Extract active filter params from state
const tag_ids = this.active_tag_filter?.tag_ids ?? null;
const tag_logic = this.active_tag_filter?.tag_logic ?? "OR";
const person_id = this.active_person_filter ?? null;

const pages: number[] = [];
for (let page = firstPage; page <= lastPage; page++) {
pages.push(page);
}

this.photos_loading = true;
try {
const responses = await Promise.all(
pages.map((page) => AlbumService.getPhotos(requestedAlbumId, page, tag_ids, tag_logic, person_id, true)),
);

// Race condition guard: Don't update state if user navigated away
if (this.albumId !== requestedAlbumId) {
return;
}

const isTimeline = this.config?.is_photo_timeline_enabled ?? false;
responses.forEach((response, idx) => {
// The first page replaces the collection, the following ones extend it,
// so the page window stays exactly the same as before the reload.
if (idx === 0) {
photosState.setPhotos(response.data.photos, isTimeline, pages[idx]);
} else {
photosState.appendPhotos(response.data.photos, isTimeline, pages[idx]);
}
});

const last = responses[responses.length - 1].data;
this.photos_current_page = last.current_page;
this.photos_last_page = last.last_page;
this.photos_per_page = last.per_page;
this.photos_total = last.total;
this.photos_min_page = firstPage;
} catch (error) {
console.error(error);
} finally {
this.photos_loading = false;
}
},

/**
* Convenience method to load the next page of photos.
* Used by infinite scroll and "Load More" button components.
Expand Down
3 changes: 3 additions & 0 deletions resources/js/v7/components/forms/photo/PhotoTagDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import Checkbox from "primevue/checkbox";
import { trans } from "laravel-vue-i18n";
import TagsService from "@/services/tags-service";
import TagsInput from "@/v7/components/forms/basic/TagsInput.vue";
import { useAlbumStore } from "@/stores/AlbumState";

const props = defineProps<{
parentId: string | undefined;
Expand All @@ -54,6 +55,7 @@ const emits = defineEmits<{
}>();

const toast = useToast();
const albumStore = useAlbumStore();

const question = computed(() => {
if (props.photo) {
Expand Down Expand Up @@ -91,6 +93,7 @@ function execute() {
});
AlbumService.clearCache(props.parentId);
TagsService.clearCache();
albumStore.bumpTagsRevision();
close();
emits("tagged");
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,14 @@
</template>

<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ref, onMounted, watch } from "vue";
import MultiSelect from "primevue/multiselect";
import RadioButton from "primevue/radiobutton";
import Button from "primevue/button";
import AlbumService from "@/services/album-service";
import { useAlbumStore } from "@/stores/AlbumState";

const albumStore = useAlbumStore();

const props = defineProps<{
albumId: string;
Expand Down Expand Up @@ -102,8 +105,8 @@ function clearFilter() {
emits("clear");
}

async function fetchTags() {
AlbumService.getAlbumTags(props.albumId)
async function fetchTags(force: boolean = false) {
AlbumService.getAlbumTags(props.albumId, force)
.then((response) => {
availableTags.value = response.data.tags;
})
Expand All @@ -117,4 +120,11 @@ async function fetchTags() {
onMounted(() => {
fetchTags();
});

// The photos of the album have been (re)tagged: the component is not remounted
// in that case, so the tag list has to be pulled again from the server.
watch(
() => albumStore.tags_revision,
() => fetchTags(true),
);
</script>
49 changes: 23 additions & 26 deletions resources/js/v7/views/gallery-panels/Album.vue
Original file line number Diff line number Diff line change
Expand Up @@ -56,36 +56,16 @@
:parent-id="albumId"
:photo="selectedPhoto"
:photo-ids="selectedPhotosIds"
@tagged="
() => {
unselect();
refresh();
}
"
@tagged="refreshInPlace"
/>
<PhotoLicenseDialog
v-model:visible="is_license_visible"
:parent-id="albumId"
:photo="selectedPhoto"
:photo-ids="selectedPhotosIds"
@licensed="
() => {
unselect();
refresh();
}
"
/>
<PhotoCopyDialog
v-model:visible="is_copy_visible"
:photo="selectedPhoto"
:photo-ids="selectedPhotosIds"
@copied="
() => {
unselect();
refresh();
}
"
@licensed="refreshInPlace"
/>
<PhotoCopyDialog v-model:visible="is_copy_visible" :photo="selectedPhoto" :photo-ids="selectedPhotosIds" @copied="refreshInPlace" />
<MoveDialog
v-model:visible="is_move_visible"
:photo="selectedPhoto"
Expand Down Expand Up @@ -310,12 +290,26 @@ function toggleSlideShow() {
router.push({ name: albumRoutes().album, params: { albumId: albumStore.album.id, photoId: photosStore.photos[0].id } });
}

const { selectedPhoto, selectedAlbum, selectedPhotosIds, selectedAlbumsIds, selectEverything, unselect, hasSelection } = useSelection(
const { selectedPhoto, selectedAlbum, selectedPhotosIds, selectedAlbumsIds, selectEverything, unselect, pruneSelection, hasSelection } = useSelection(
photosStore,
albumsStore,
togglableStore,
);

/**
* Refresh after an operation which leaves the photos of this album where they
* are: a metadata edit (tags, license) or a copy to another album.
*
* Only the thumbnails currently loaded are re-fetched, in place: the album panel
* is never torn down, so the scroll position, the loaded page window and the
* current selection are all preserved. Operations which do take photos out of
* the album (move, delete, …) keep using the full `refresh()` and drop the
* selection.
*/
function refreshInPlace() {
return albumStore.reloadLoadedPhotos().then(() => pruneSelection());
}

const { handleRatingClick } = useRating(photoStore, toast, userStore);

function goBack() {
Expand Down Expand Up @@ -469,8 +463,11 @@ onKeyStroke("Escape", () => {
return;
}

if (is_move_visible.value) {
is_move_visible.value = false;
// Escape drops the current selection before navigating away, so that a
// selection surviving an in-place edit can be cleared without leaving the
// album.
if (hasSelection()) {
unselect();
return;
}

Expand Down
Loading
Loading