refactor(I4): extract useInfiniteScroll composable; migrate 6 consumers

The IntersectionObserver-on-a-sentinel infinite-scroll pattern was copy-
pasted in 7 places. New composables/useInfiniteScroll(sentinelRef, cb)
owns the observer lifecycle (attach on mount, re-attach when the ref
changes, disconnect on unmount). Migrated GalleryGrid, MasonryGrid
(gallery+showcase), ArtistPostsTab, ArtistsView, TagsView, SeriesManageView.
PostsView left manual on purpose — its anchored mode does bidirectional
scroll-position preservation that doesn't fit the simple composable.

I4(a) (timestamps) is effectively already done: the UTC displays were
converted earlier; the remaining toLocaleString uses already render local
time, so they're left as-is rather than churned for format-only consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 14:03:49 -04:00
parent 00e2608ba1
commit 8979e0e377
7 changed files with 57 additions and 79 deletions
@@ -28,10 +28,11 @@
</template>
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { onMounted, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { usePostsStore } from '../../stores/posts.js'
import { useInfiniteScroll } from '../../composables/useInfiniteScroll.js'
import PostCard from '../posts/PostCard.vue'
const props = defineProps({
@@ -42,7 +43,6 @@ defineEmits(['switch-tab'])
const store = usePostsStore()
const sentinel = ref(null)
let observer = null
async function reload () {
await store.loadInitial({ artist_id: props.artistId, platform: null })
@@ -50,19 +50,9 @@ async function reload () {
watch(() => props.artistId, reload)
onMounted(async () => {
await reload()
observer = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) {
store.loadMore()
}
}, { rootMargin: '400px 0px' })
if (sentinel.value) observer.observe(sentinel.value)
})
useInfiniteScroll(sentinel, () => store.loadMore(), { rootMargin: '400px 0px' })
onUnmounted(() => {
if (observer) observer.disconnect()
})
onMounted(reload)
</script>
<style scoped>
@@ -30,8 +30,9 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed } from 'vue'
import { usePolyMasonry } from '../../composables/usePolyMasonry.js'
import { useInfiniteScroll } from '../../composables/useInfiniteScroll.js'
const props = defineProps({
items: { type: Array, default: () => [] },
@@ -78,20 +79,9 @@ function aspectStyle(item) {
return { aspectRatio: `${w} / ${h}` }
}
let observer = null
function attachObserver() {
if (observer) observer.disconnect()
if (!sentinelEl.value) return
observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && props.hasMore && !props.loading) {
emit('load-more')
}
}, { rootMargin: '600px' })
observer.observe(sentinelEl.value)
}
watch(sentinelEl, attachObserver)
onMounted(attachObserver)
onUnmounted(() => observer && observer.disconnect())
useInfiniteScroll(sentinelEl, () => {
if (props.hasMore && !props.loading) emit('load-more')
})
</script>
<style scoped>
@@ -32,8 +32,9 @@
</template>
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { ref } from 'vue'
import { useGalleryStore } from '../../stores/gallery.js'
import { useInfiniteScroll } from '../../composables/useInfiniteScroll.js'
import GalleryItem from './GalleryItem.vue'
defineEmits(['open'])
@@ -41,22 +42,9 @@ defineEmits(['open'])
const store = useGalleryStore()
const sentinelEl = ref(null)
let observer = null
function attachObserver() {
if (observer) observer.disconnect()
if (!sentinelEl.value) return
observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && store.hasMore && !store.loading) {
store.loadMore()
}
}, { rootMargin: '600px' })
observer.observe(sentinelEl.value)
}
watch(sentinelEl, attachObserver)
onMounted(() => attachObserver())
onUnmounted(() => observer && observer.disconnect())
useInfiniteScroll(sentinelEl, () => {
if (store.hasMore && !store.loading) store.loadMore()
})
function dateHeaderId(group) { return `fc-month-${group.year}-${group.month}` }