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 type webhooks = Record<string, never>
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
AuthoPostAuthorDto: {
|
||||||
|
/** Format: uuid */
|
||||||
|
authorId: string
|
||||||
|
username: string
|
||||||
|
}
|
||||||
AuthorPostDto: {
|
AuthorPostDto: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
postId: string
|
postId: string
|
||||||
|
@ -119,6 +124,7 @@ export interface components {
|
||||||
media: string[]
|
media: string[]
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
author: components['schemas']['AuthoPostAuthorDto']
|
||||||
}
|
}
|
||||||
CreatePostRequest: {
|
CreatePostRequest: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
|
@ -142,7 +148,7 @@ export interface components {
|
||||||
username: string
|
username: string
|
||||||
}
|
}
|
||||||
PublicPostDto: {
|
PublicPostDto: {
|
||||||
authorDto: components['schemas']['PublicPostAuthorDto']
|
author: components['schemas']['PublicPostAuthorDto']
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
postId: string
|
postId: string
|
||||||
content: 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 content: string
|
||||||
public readonly media: string[]
|
public readonly media: string[]
|
||||||
public readonly createdAt: Temporal.PlainDateTime
|
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.postId = postId
|
||||||
this.content = content
|
this.content = content
|
||||||
this.media = media
|
this.media = media
|
||||||
this.createdAt = createdAt
|
this.createdAt = createdAt
|
||||||
|
this.authorName = authorName
|
||||||
}
|
}
|
||||||
|
|
||||||
public static fromDto(dto: components['schemas']['AuthorPostDto']): Post {
|
public static fromDto(
|
||||||
return new Post(dto.postId, dto.content, dto.media, Temporal.PlainDateTime.from(dto.createdAt))
|
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 { Post } from '../model/posts/posts.ts'
|
||||||
import PostsFeed from '../components/PostsFeed.tsx'
|
import PostsList from '../components/PostsList.tsx'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import './FeedView.css'
|
import './FeedView.css'
|
||||||
|
import { useIntersectionLoad } from '../hooks/useIntersectionLoad.ts'
|
||||||
|
|
||||||
const PageSize = 10
|
const PageSize = 20
|
||||||
|
|
||||||
interface FeedViewProps {
|
interface FeedViewProps {
|
||||||
loadPosts: (cursor: string | null, amount: number) => Promise<Post[]>
|
loadPosts: (cursor: string | null, amount: number) => Promise<Post[]>
|
||||||
|
@ -26,12 +27,10 @@ export default function FeedView({ loadPosts }: FeedViewProps) {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
try {
|
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 pagePromise = loadPosts(cursor.current, PageSize)
|
||||||
|
const [page] = await Promise.all([pagePromise, delay])
|
||||||
const [page] = await Promise.all([pagePromise, delay]) // wait for both
|
setHasMore(page.length >= PageSize)
|
||||||
|
|
||||||
if (page.length < PageSize) setHasMore(false)
|
|
||||||
cursor.current = page.at(-1)?.postId ?? null
|
cursor.current = page.at(-1)?.postId ?? null
|
||||||
setPages((prev) => [...prev, page])
|
setPages((prev) => [...prev, page])
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -40,47 +39,11 @@ export default function FeedView({ loadPosts }: FeedViewProps) {
|
||||||
}
|
}
|
||||||
}, [loadPosts, hasMore])
|
}, [loadPosts, hasMore])
|
||||||
|
|
||||||
useEffect(() => {
|
useIntersectionLoad(loadNextPage, sentinelRef)
|
||||||
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])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="w-full max-w-full px-12 py-6">
|
<main className="w-full max-w-full px-12 py-6">
|
||||||
<div className="col-start-1">
|
<div className="col-start-1">
|
||||||
<PostsFeed posts={posts} />
|
<PostsList posts={posts} />
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="w-full flex justify-center py-4">
|
<div className="w-full flex justify-center py-4">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue