autodoload

This commit is contained in:
john 2025-05-04 00:28:51 +02:00
parent 8cd565c647
commit 62afad71d3
6 changed files with 144 additions and 88 deletions

View file

@ -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

View file

@ -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',
})
}

View 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>
)
}

View 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]}`
}
}

View file

@ -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,
)
}
}

View file

@ -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 />