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:
@@ -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}` }
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
// Shared IntersectionObserver-on-a-sentinel infinite-scroll lifecycle.
|
||||
// Pass a ref to the sentinel element and a callback to fire when it scrolls
|
||||
// into view; the callback owns its own guard (e.g. `if (store.hasMore &&
|
||||
// !store.loading) store.loadMore()`). Re-attaches if the sentinel ref
|
||||
// changes (e.g. it's behind a v-if) and disconnects on unmount.
|
||||
export function useInfiniteScroll (sentinelRef, onIntersect, { rootMargin = '600px' } = {}) {
|
||||
let observer = null
|
||||
|
||||
function teardown () {
|
||||
if (observer) { observer.disconnect(); observer = null }
|
||||
}
|
||||
function attach () {
|
||||
teardown()
|
||||
if (!sentinelRef.value) return
|
||||
observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) onIntersect()
|
||||
}, { rootMargin })
|
||||
observer.observe(sentinelRef.value)
|
||||
}
|
||||
|
||||
onMounted(attach)
|
||||
watch(sentinelRef, attach)
|
||||
onUnmounted(teardown)
|
||||
|
||||
return { attach, teardown }
|
||||
}
|
||||
@@ -34,9 +34,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useArtistDirectoryStore } from '../stores/artistDirectory.js'
|
||||
import { usePlatformsStore } from '../stores/platforms.js'
|
||||
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
||||
import ArtistCard from '../components/discovery/ArtistCard.vue'
|
||||
|
||||
const store = useArtistDirectoryStore()
|
||||
@@ -55,16 +56,9 @@ watch(search, (v) => {
|
||||
})
|
||||
watch(platformModel, (v) => store.setPlatform(v || null))
|
||||
|
||||
let observer = null
|
||||
function attachObserver() {
|
||||
if (observer) observer.disconnect()
|
||||
if (!sentinelEl.value) return
|
||||
observer = new IntersectionObserver(([e]) => {
|
||||
if (e.isIntersecting && store.hasMore && !store.loading) store.loadMore()
|
||||
}, { rootMargin: '600px' })
|
||||
observer.observe(sentinelEl.value)
|
||||
}
|
||||
watch(sentinelEl, attachObserver)
|
||||
useInfiniteScroll(sentinelEl, () => {
|
||||
if (store.hasMore && !store.loading) store.loadMore()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await platformsStore.loadAll()
|
||||
@@ -72,9 +66,7 @@ onMounted(async () => {
|
||||
title: p.key, value: p.key,
|
||||
}))
|
||||
store.reset()
|
||||
attachObserver()
|
||||
})
|
||||
onUnmounted(() => observer && observer.disconnect())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -61,15 +61,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js'
|
||||
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useSeriesManageStore()
|
||||
const dragFrom = ref(null)
|
||||
const sentinel = ref(null)
|
||||
let observer = null
|
||||
|
||||
function onDrop(toIdx) {
|
||||
if (dragFrom.value === null || dragFrom.value === toIdx) return
|
||||
@@ -80,15 +80,12 @@ function onDrop(toIdx) {
|
||||
store.reorder(ordered)
|
||||
}
|
||||
|
||||
useInfiniteScroll(sentinel, () => store.loadPicker())
|
||||
|
||||
onMounted(async () => {
|
||||
await store.load(parseInt(route.params.tagId, 10))
|
||||
await store.loadPicker(true)
|
||||
observer = new IntersectionObserver(([e]) => {
|
||||
if (e.isIntersecting) store.loadPicker()
|
||||
}, { rootMargin: '600px' })
|
||||
if (sentinel.value) observer.observe(sentinel.value)
|
||||
})
|
||||
onUnmounted(() => observer && observer.disconnect())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -89,11 +89,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useTagDirectoryStore } from '../stores/tagDirectory.js'
|
||||
import { useAdminStore } from '../stores/admin.js'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
||||
import TagCard from '../components/discovery/TagCard.vue'
|
||||
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
|
||||
import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue'
|
||||
@@ -130,18 +131,10 @@ watch(search, (v) => {
|
||||
})
|
||||
watch(kind, (v) => store.setKind(v || null))
|
||||
|
||||
let observer = null
|
||||
function attachObserver() {
|
||||
if (observer) observer.disconnect()
|
||||
if (!sentinelEl.value) return
|
||||
observer = new IntersectionObserver(([e]) => {
|
||||
if (e.isIntersecting && store.hasMore && !store.loading) store.loadMore()
|
||||
}, { rootMargin: '600px' })
|
||||
observer.observe(sentinelEl.value)
|
||||
}
|
||||
watch(sentinelEl, attachObserver)
|
||||
onMounted(() => { store.reset(); attachObserver() })
|
||||
onUnmounted(() => observer && observer.disconnect())
|
||||
useInfiniteScroll(sentinelEl, () => {
|
||||
if (store.hasMore && !store.loading) store.loadMore()
|
||||
})
|
||||
onMounted(() => store.reset())
|
||||
|
||||
function openTag(tagId) {
|
||||
router.push({ name: 'gallery', query: { tag_id: tagId } })
|
||||
|
||||
Reference in New Issue
Block a user