diff --git a/resources/js/composables/selections/selections.ts b/resources/js/composables/selections/selections.ts index 4e96c25264b..8655ac63922 100644 --- a/resources/js/composables/selections/selections.ts +++ b/resources/js/composables/selections/selections.ts @@ -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); @@ -273,6 +286,7 @@ export function useSelection(photosStore: PhotosStore, albumsStore: AlbumsStore, albumSelect, selectEverything, unselect, + pruneSelection, hasSelection, }; } diff --git a/resources/js/services/album-service.ts b/resources/js/services/album-service.ts index 5e096937c74..82b6b903874 100644 --- a/resources/js/services/album-service.ts +++ b/resources/js/services/album-service.ts @@ -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}`); @@ -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> { const requester = axios as unknown as AxiosCacheInstance; @@ -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, }); }, @@ -306,8 +314,17 @@ const AlbumService = { return axios.post(`${Constants.getApiUrl()}Album::watermark`, { album_id: album_id }); }, - getAlbumTags(album_id: string): Promise> { - 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> { + 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, + }); }, }; diff --git a/resources/js/stores/AlbumState.ts b/resources/js/stores/AlbumState.ts index 5eb0e605160..a481412c771 100644 --- a/resources/js/stores/AlbumState.ts +++ b/resources/js/stores/AlbumState.ts @@ -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, @@ -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; @@ -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 { + 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. diff --git a/resources/js/v7/components/forms/photo/PhotoTagDialog.vue b/resources/js/v7/components/forms/photo/PhotoTagDialog.vue index 0f9ba3e53ea..425e7abeee7 100644 --- a/resources/js/v7/components/forms/photo/PhotoTagDialog.vue +++ b/resources/js/v7/components/forms/photo/PhotoTagDialog.vue @@ -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; @@ -54,6 +55,7 @@ const emits = defineEmits<{ }>(); const toast = useToast(); +const albumStore = useAlbumStore(); const question = computed(() => { if (props.photo) { @@ -91,6 +93,7 @@ function execute() { }); AlbumService.clearCache(props.parentId); TagsService.clearCache(); + albumStore.bumpTagsRevision(); close(); emits("tagged"); }); diff --git a/resources/js/v7/components/gallery/albumModule/AlbumTagFilter.vue b/resources/js/v7/components/gallery/albumModule/AlbumTagFilter.vue index f122aac87f8..a9be7261bcf 100644 --- a/resources/js/v7/components/gallery/albumModule/AlbumTagFilter.vue +++ b/resources/js/v7/components/gallery/albumModule/AlbumTagFilter.vue @@ -61,11 +61,14 @@ diff --git a/resources/js/v7/views/gallery-panels/Album.vue b/resources/js/v7/views/gallery-panels/Album.vue index a2cbd12f65a..14866ebf321 100644 --- a/resources/js/v7/views/gallery-panels/Album.vue +++ b/resources/js/v7/views/gallery-panels/Album.vue @@ -56,36 +56,16 @@ :parent-id="albumId" :photo="selectedPhoto" :photo-ids="selectedPhotosIds" - @tagged=" - () => { - unselect(); - refresh(); - } - " + @tagged="refreshInPlace" /> - + pruneSelection()); +} + const { handleRatingClick } = useRating(photoStore, toast, userStore); function goBack() { @@ -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; } diff --git a/resources/js/v7/views/gallery-panels/Tag.vue b/resources/js/v7/views/gallery-panels/Tag.vue index cef3ca520c6..5454af9bbca 100644 --- a/resources/js/v7/views/gallery-panels/Tag.vue +++ b/resources/js/v7/views/gallery-panels/Tag.vue @@ -35,36 +35,16 @@ :parent-id="undefined" :photo="selectedPhoto" :photo-ids="selectedPhotosIds" - @tagged=" - () => { - unselect(); - refresh(); - } - " + @tagged="refreshInPlace" /> - + { return; } + // 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; + } + goBack(); }); @@ -312,6 +304,18 @@ async function refresh() { photoStore.load(); } +/** + * 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. + * + * The selection is kept alive across the reload — only the entries whose photo + * left the album are dropped. Operations which do take photos out of the album + * (move, delete, …) keep clearing the selection entirely. + */ +function refreshInPlace() { + return refresh().then(() => pruneSelection()); +} + onMounted(async () => { photoStore.photoId = props.photoId; tagStore.tagId = props.tagId; diff --git a/resources/js/v8/components/forms/photo/PhotoTagDialog.vue b/resources/js/v8/components/forms/photo/PhotoTagDialog.vue index 42377df104e..4c274cd1f2e 100644 --- a/resources/js/v8/components/forms/photo/PhotoTagDialog.vue +++ b/resources/js/v8/components/forms/photo/PhotoTagDialog.vue @@ -34,6 +34,7 @@ import { useAppToast } from "@/v8/composables/useAppToast"; import { trans } from "laravel-vue-i18n"; import TagsService from "@/services/tags-service"; import TagsInput from "@/v8/components/forms/basic/TagsInput.vue"; +import { useAlbumStore } from "@/stores/AlbumState"; const props = defineProps<{ parentId: string | undefined; @@ -48,6 +49,7 @@ const emits = defineEmits<{ }>(); const toast = useAppToast(); +const albumStore = useAlbumStore(); const question = computed(() => { if (props.photo) { @@ -85,6 +87,7 @@ function execute() { }); AlbumService.clearCache(props.parentId); TagsService.clearCache(); + albumStore.bumpTagsRevision(); close(); emits("tagged"); }); diff --git a/resources/js/v8/components/gallery/albumModule/AlbumTagFilter.vue b/resources/js/v8/components/gallery/albumModule/AlbumTagFilter.vue index faefdc04755..69f874a4b8c 100644 --- a/resources/js/v8/components/gallery/albumModule/AlbumTagFilter.vue +++ b/resources/js/v8/components/gallery/albumModule/AlbumTagFilter.vue @@ -54,9 +54,12 @@ diff --git a/resources/js/v8/views/gallery-panels/Album.vue b/resources/js/v8/views/gallery-panels/Album.vue index aed20b68c5c..2d43d2fb1e2 100644 --- a/resources/js/v8/views/gallery-panels/Album.vue +++ b/resources/js/v8/views/gallery-panels/Album.vue @@ -56,36 +56,16 @@ :parent-id="albumId" :photo="selectedPhoto" :photo-ids="selectedPhotosIds" - @tagged=" - () => { - unselect(); - refresh(); - } - " + @tagged="refreshInPlace" /> - + pruneSelection()); +} + const { handleRatingClick } = useRating(photoStore, toast, userStore); function goBack() { @@ -426,8 +420,11 @@ defineShortcuts({ 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; } diff --git a/resources/js/v8/views/gallery-panels/Tag.vue b/resources/js/v8/views/gallery-panels/Tag.vue index babc10c6d50..ed53bb0f4f1 100644 --- a/resources/js/v8/views/gallery-panels/Tag.vue +++ b/resources/js/v8/views/gallery-panels/Tag.vue @@ -34,36 +34,16 @@ :parent-id="undefined" :photo="selectedPhoto" :photo-ids="selectedPhotosIds" - @tagged=" - () => { - unselect(); - refresh(); - } - " + @tagged="refreshInPlace" /> - + pruneSelection()); +} + onMounted(async () => { photoStore.photoId = props.photoId; tagStore.tagId = props.tagId;