autodoload
This commit is contained in:
parent
8cd565c647
commit
62afad71d3
6 changed files with 144 additions and 88 deletions
|
@ -112,6 +112,11 @@ export interface paths {
|
|||
export type webhooks = Record<string, never>
|
||||
export interface components {
|
||||
schemas: {
|
||||
AuthoPostAuthorDto: {
|
||||
/** Format: uuid */
|
||||
authorId: string
|
||||
username: string
|
||||
}
|
||||
AuthorPostDto: {
|
||||
/** Format: uuid */
|
||||
postId: string
|
||||
|
@ -119,6 +124,7 @@ export interface components {
|
|||
media: string[]
|
||||
/** Format: date-time */
|
||||
createdAt: string
|
||||
author: components['schemas']['AuthoPostAuthorDto']
|
||||
}
|
||||
CreatePostRequest: {
|
||||
/** Format: uuid */
|
||||
|
@ -142,7 +148,7 @@ export interface components {
|
|||
username: string
|
||||
}
|
||||
PublicPostDto: {
|
||||
authorDto: components['schemas']['PublicPostAuthorDto']
|
||||
author: components['schemas']['PublicPostAuthorDto']
|
||||
/** Format: uuid */
|
||||
postId: string
|
||||
content: string
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
import { Post } from '../model/posts/posts.ts'
|
||||
import { Temporal } from '@js-temporal/polyfill'
|
||||
|
||||
interface PostsFeedProps {
|
||||
posts: Post[]
|
||||
}
|
||||
|
||||
export default function PostsFeed({ posts }: PostsFeedProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
{posts.map((post) => (
|
||||
<article className="w-full p-4 " key={post.postId}>
|
||||
<div className="text-sm text-gray-500 mb-3">{formatDate(post.createdAt)}</div>
|
||||
|
||||
<div className="text-gray-800 mb-4 whitespace-pre-wrap">{post.content}</div>
|
||||
|
||||
{post.media.length > 0 && (
|
||||
<div className="grid gap-4 grid-cols-1">
|
||||
{post.media.map((src) => (
|
||||
<img key={src} src={src} alt="" className="w-full h-auto" loading="lazy" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(date: Temporal.PlainDateTime) {
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
16
src/components/PostsList.tsx
Normal file
16
src/components/PostsList.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { Post } from '../model/posts/posts.ts'
|
||||
import PostItem from './PostItem'
|
||||
|
||||
interface PostsFeedProps {
|
||||
posts: Post[]
|
||||
}
|
||||
|
||||
export default function PostsList({ posts }: PostsFeedProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
{posts.map((post) => (
|
||||
<PostItem key={post.postId} post={post} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
93
src/hooks/useIntersectionLoad.ts
Normal file
93
src/hooks/useIntersectionLoad.ts
Normal file
|
@ -0,0 +1,93 @@
|
|||
import { RefObject, useEffect, useRef } from 'react'
|
||||
|
||||
interface UseIntersectionLoadOptions extends IntersectionObserverInit {
|
||||
earlyTriggerPx?: number
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
export function useIntersectionLoad(
|
||||
callback: () => Promise<void>,
|
||||
elementRef: RefObject<HTMLElement | null>,
|
||||
{
|
||||
earlyTriggerPx = 1800,
|
||||
debounceMs = 300,
|
||||
root = null,
|
||||
threshold = 0.1,
|
||||
rootMargin = '0px',
|
||||
}: UseIntersectionLoadOptions = {},
|
||||
) {
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
const loading = useRef(false)
|
||||
const timeoutRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = elementRef.current
|
||||
if (!el) return
|
||||
|
||||
const margin = computeAdjustedRootMargin(rootMargin, earlyTriggerPx)
|
||||
|
||||
const debouncedCallback = () => {
|
||||
if (timeoutRef.current) return
|
||||
timeoutRef.current = setTimeout(async () => {
|
||||
timeoutRef.current = null
|
||||
if (!loading.current) {
|
||||
loading.current = true
|
||||
try {
|
||||
await callback()
|
||||
} finally {
|
||||
loading.current = false
|
||||
if (elementRef.current && observerRef.current) {
|
||||
observerRef.current.unobserve(elementRef.current)
|
||||
observerRef.current.observe(elementRef.current)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry.isIntersecting) {
|
||||
debouncedCallback()
|
||||
}
|
||||
},
|
||||
{
|
||||
root,
|
||||
threshold,
|
||||
rootMargin: margin,
|
||||
},
|
||||
)
|
||||
|
||||
observerRef.current.observe(el)
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
if (el && observerRef.current) {
|
||||
observerRef.current.unobserve(el)
|
||||
observerRef.current.disconnect()
|
||||
}
|
||||
}
|
||||
}, [callback, elementRef, root, rootMargin, threshold, earlyTriggerPx, debounceMs])
|
||||
}
|
||||
|
||||
// Utility to adjust rootMargin's top value
|
||||
function computeAdjustedRootMargin(baseMargin: string, extraTopPx: number): string {
|
||||
const parts = baseMargin.split(' ')
|
||||
const top = parts[0] || '0px'
|
||||
const adjustedTop = `${parseInt(top, 10) + extraTopPx}px`
|
||||
|
||||
// Maintain format: top [right bottom left]
|
||||
if (parts.length === 1) {
|
||||
return `${adjustedTop}`
|
||||
} else if (parts.length === 2) {
|
||||
return `${adjustedTop} ${parts[1]}`
|
||||
} else if (parts.length === 3) {
|
||||
return `${adjustedTop} ${parts[1]} ${parts[2]}`
|
||||
} else {
|
||||
return `${adjustedTop} ${parts[1]} ${parts[2]} ${parts[3]}`
|
||||
}
|
||||
}
|
|
@ -6,15 +6,31 @@ export class Post {
|
|||
public readonly content: string
|
||||
public readonly media: string[]
|
||||
public readonly createdAt: Temporal.PlainDateTime
|
||||
public readonly authorName: string
|
||||
|
||||
constructor(postId: string, content: string, media: string[], createdAt: Temporal.PlainDateTime) {
|
||||
constructor(
|
||||
postId: string,
|
||||
content: string,
|
||||
media: string[],
|
||||
createdAt: Temporal.PlainDateTime,
|
||||
authorName: string,
|
||||
) {
|
||||
this.postId = postId
|
||||
this.content = content
|
||||
this.media = media
|
||||
this.createdAt = createdAt
|
||||
this.authorName = authorName
|
||||
}
|
||||
|
||||
public static fromDto(dto: components['schemas']['AuthorPostDto']): Post {
|
||||
return new Post(dto.postId, dto.content, dto.media, Temporal.PlainDateTime.from(dto.createdAt))
|
||||
public static fromDto(
|
||||
dto: components['schemas']['AuthorPostDto'] | components['schemas']['PublicPostDto'],
|
||||
): Post {
|
||||
return new Post(
|
||||
dto.postId,
|
||||
dto.content,
|
||||
dto.media,
|
||||
Temporal.PlainDateTime.from(dto.createdAt),
|
||||
dto.author.username,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { Post } from '../model/posts/posts.ts'
|
||||
import PostsFeed from '../components/PostsFeed.tsx'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import PostsList from '../components/PostsList.tsx'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import './FeedView.css'
|
||||
import { useIntersectionLoad } from '../hooks/useIntersectionLoad.ts'
|
||||
|
||||
const PageSize = 10
|
||||
const PageSize = 20
|
||||
|
||||
interface FeedViewProps {
|
||||
loadPosts: (cursor: string | null, amount: number) => Promise<Post[]>
|
||||
|
@ -26,12 +27,10 @@ export default function FeedView({ loadPosts }: FeedViewProps) {
|
|||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const delay = new Promise((resolve) => setTimeout(resolve, 500)) // minimum delay
|
||||
const delay = new Promise((resolve) => setTimeout(resolve, 500))
|
||||
const pagePromise = loadPosts(cursor.current, PageSize)
|
||||
|
||||
const [page] = await Promise.all([pagePromise, delay]) // wait for both
|
||||
|
||||
if (page.length < PageSize) setHasMore(false)
|
||||
const [page] = await Promise.all([pagePromise, delay])
|
||||
setHasMore(page.length >= PageSize)
|
||||
cursor.current = page.at(-1)?.postId ?? null
|
||||
setPages((prev) => [...prev, page])
|
||||
} finally {
|
||||
|
@ -40,47 +39,11 @@ export default function FeedView({ loadPosts }: FeedViewProps) {
|
|||
}
|
||||
}, [loadPosts, hasMore])
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry.isIntersecting) {
|
||||
loadNextPage()
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: '100px',
|
||||
threshold: 0.1,
|
||||
},
|
||||
)
|
||||
|
||||
const sentinel = sentinelRef.current
|
||||
if (sentinel) observer.observe(sentinel)
|
||||
return () => {
|
||||
if (sentinel) observer.unobserve(sentinel)
|
||||
}
|
||||
}, [loadNextPage])
|
||||
|
||||
// Ensure content fills viewport after initial render
|
||||
useEffect(() => {
|
||||
const checkContentHeight = async () => {
|
||||
while (
|
||||
document.documentElement.scrollHeight <= window.innerHeight &&
|
||||
hasMore &&
|
||||
!loading.current
|
||||
) {
|
||||
await loadNextPage()
|
||||
}
|
||||
}
|
||||
|
||||
checkContentHeight()
|
||||
}, [posts.length, loadNextPage, hasMore])
|
||||
|
||||
useIntersectionLoad(loadNextPage, sentinelRef)
|
||||
return (
|
||||
<main className="w-full max-w-full px-12 py-6">
|
||||
<div className="col-start-1">
|
||||
<PostsFeed posts={posts} />
|
||||
<PostsList posts={posts} />
|
||||
{isLoading && (
|
||||
<div className="w-full flex justify-center py-4">
|
||||
<Spinner />
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue