From 1c2d6d60a66a0044d4fba7443310c49ebb56cc52 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 3 May 2025 23:40:30 +0200 Subject: [PATCH] wip add button and feed stuff --- src/App.tsx | 4 +- src/api/api.ts | 28 +++++++++-- src/api/schema.ts | 45 ++++++++++++++++- src/components/PostsFeed.tsx | 22 ++++----- src/hooks/useAsyncData.ts | 6 ++- src/index.css | 7 --- src/pages/AuthorPage.tsx | 27 +++++------ src/pages/FeedView.css | 6 +++ src/pages/FeedView.tsx | 94 ++++++++++++++++++++++++++++++++++++ src/pages/HomePage.tsx | 14 ++++++ tsconfig.app.json | 4 +- 11 files changed, 211 insertions(+), 46 deletions(-) create mode 100644 src/pages/FeedView.css create mode 100644 src/pages/FeedView.tsx create mode 100644 src/pages/HomePage.tsx diff --git a/src/App.tsx b/src/App.tsx index 4af04cc..41a8827 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,12 @@ -import { AuthorPage } from './pages/AuthorPage.tsx' import { BrowserRouter, Route, Routes } from 'react-router' +import HomePage from './pages/HomePage.tsx' +import AuthorPage from './pages/AuthorPage.tsx' function App() { return ( + } /> } /> diff --git a/src/api/api.ts b/src/api/api.ts index dee1b96..2f253de 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -8,17 +8,35 @@ console.debug('API HOST IS', ApiHost) export async function loadPostsForAuthor( authorId: string, - count?: number, - cursor?: string, + cursor: string | null, + amount: number | null, ): Promise { const url = new URL(`authors/${authorId}/posts`, ApiHost) - if (count != null) url.searchParams.set('count', count.toString()) + if (amount != null) url.searchParams.set('amount', amount.toString()) + + if (cursor != null) url.searchParams.set('cursor', cursor) + + const res = await doGetRequest(url) + + return res as components['schemas']['GetAuthorPostsResponse'] +} + +export async function loadPublicFeed( + cursor: string | null, + amount: number | null, +): Promise { + const url = new URL(`posts`, ApiHost) + if (amount != null) url.searchParams.set('amount', amount.toString()) if (cursor) url.searchParams.set('cursor', cursor) - const request = new Request(url) + const res = await doGetRequest(url) - const response = await fetch(request) + return res as components['schemas']['GetAllPublicPostsResponse'] +} + +async function doGetRequest(url: URL): Promise { + const response = await fetch(new Request(url)) if (!response.ok) throw new Error(await response.text()) diff --git a/src/api/schema.ts b/src/api/schema.ts index e210c68..349268a 100644 --- a/src/api/schema.ts +++ b/src/api/schema.ts @@ -6,7 +6,31 @@ export interface paths { path?: never cookie?: never } - get?: never + get: { + parameters: { + query?: { + Cursor?: string + Amount?: number + } + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'text/plain': components['schemas']['GetAllPublicPostsResponse'] + 'application/json': components['schemas']['GetAllPublicPostsResponse'] + 'text/json': components['schemas']['GetAllPublicPostsResponse'] + } + } + } + } put?: never post: { parameters: { @@ -53,7 +77,7 @@ export interface paths { parameters: { query?: { Cursor?: string - Count?: number + Amount?: number } header?: never path: { @@ -106,9 +130,26 @@ export interface components { /** Format: uuid */ postId: string } + GetAllPublicPostsResponse: { + posts: components['schemas']['PublicPostDto'][] + } GetAuthorPostsResponse: { posts: components['schemas']['AuthorPostDto'][] } + PublicPostAuthorDto: { + /** Format: uuid */ + authorId: string + username: string + } + PublicPostDto: { + authorDto: components['schemas']['PublicPostAuthorDto'] + /** Format: uuid */ + postId: string + content: string + media: string[] + /** Format: date-time */ + createdAt: string + } } responses: never parameters: never diff --git a/src/components/PostsFeed.tsx b/src/components/PostsFeed.tsx index b4abb26..9de45a0 100644 --- a/src/components/PostsFeed.tsx +++ b/src/components/PostsFeed.tsx @@ -5,17 +5,7 @@ interface PostsFeedProps { posts: Post[] } -export function PostsFeed({ posts }: PostsFeedProps) { - const formatDate = (date: Temporal.PlainDateTime) => { - return date.toLocaleString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }) - } - +export default function PostsFeed({ posts }: PostsFeedProps) { return (
{posts.map((post) => ( @@ -36,3 +26,13 @@ export function PostsFeed({ posts }: PostsFeedProps) {
) } + +function formatDate(date: Temporal.PlainDateTime) { + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} diff --git a/src/hooks/useAsyncData.ts b/src/hooks/useAsyncData.ts index 5b58244..ceb9add 100644 --- a/src/hooks/useAsyncData.ts +++ b/src/hooks/useAsyncData.ts @@ -1,7 +1,9 @@ import { useEffect, useState } from 'react' -export function useAsyncState(loader: () => Promise): T | undefined { - const [state, setState] = useState() +export function useAsyncState(loader: () => Promise): T | undefined +export function useAsyncState(loader: () => Promise, defaultValue: T): T +export function useAsyncState(loader: () => Promise, defaultValue?: T): T | undefined { + const [state, setState] = useState(defaultValue as T) useEffect(() => { setTimeout(async () => { diff --git a/src/index.css b/src/index.css index 41fdd9a..ea6ee36 100644 --- a/src/index.css +++ b/src/index.css @@ -6,11 +6,4 @@ text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; -} - - -body { - margin: 0; - width: 100vw; - height: 100vh; } \ No newline at end of file diff --git a/src/pages/AuthorPage.tsx b/src/pages/AuthorPage.tsx index 7f61fa4..067c4e3 100644 --- a/src/pages/AuthorPage.tsx +++ b/src/pages/AuthorPage.tsx @@ -1,25 +1,20 @@ -import { PostsFeed } from '../components/PostsFeed.tsx' import { useCallback } from 'react' import { useParams } from 'react-router' import { loadPostsForAuthor } from '../api/api.ts' -import { useAsyncState } from '../hooks/useAsyncData.ts' import { Post } from '../model/posts/posts.ts' +import './FeedView.css' +import FeedView from './FeedView.tsx' -export function AuthorPage() { +export default function AuthorPage() { const { username } = useParams() - const fetchPosts = useCallback(async () => { - const result = await loadPostsForAuthor(username!) - return result.posts.map((post) => Post.fromDto(post)) - }, [username]) - - const posts = useAsyncState(fetchPosts) - - return ( -
-
- -
-
+ const fetchPosts = useCallback( + async (cursor: string | null, amount: number | null) => { + const result = await loadPostsForAuthor(username!, cursor, amount) + return result.posts.map((post) => Post.fromDto(post)) + }, + [username], ) + + return } diff --git a/src/pages/FeedView.css b/src/pages/FeedView.css new file mode 100644 index 0000000..3ef31cc --- /dev/null +++ b/src/pages/FeedView.css @@ -0,0 +1,6 @@ +@media (width >= 48rem) { + main { + display: grid; + grid-template-columns: 1.618fr 1fr; + } +} diff --git a/src/pages/FeedView.tsx b/src/pages/FeedView.tsx new file mode 100644 index 0000000..0ebad8a --- /dev/null +++ b/src/pages/FeedView.tsx @@ -0,0 +1,94 @@ +import { Post } from '../model/posts/posts.ts' +import PostsFeed from '../components/PostsFeed.tsx' +import { useCallback, useEffect, useRef, useState } from 'react' +import './FeedView.css' + +// Stub for spinner component +const Spinner = () => ( +
+) + +const PageSize = 2 + +interface FeedViewProps { + loadPosts: (cursor: string | null, amount: number) => Promise +} +export default function FeedView({ loadPosts }: FeedViewProps) { + const [pages, setPages] = useState([]) + + const posts = pages.flat() + + const [hasMore, setHasMore] = useState(true) + const cursor = useRef(null) + const [isLoading, setIsLoading] = useState(false) + const loading = useRef(false) + + const loadNextPage = useCallback(async () => { + if (loading.current) return + + loading.current = true + setIsLoading(true) + + try { + const page = await loadPosts(cursor.current, PageSize) + + if (page.length < PageSize) { + setHasMore(false) + } + + cursor.current = page.at(-1)?.postId ?? null + + setPages((prev) => [...prev, page]) + } finally { + setIsLoading(false) + loading.current = false + } + }, [loadPosts]) + + useEffect(() => { + const timeoutId = setTimeout(async () => { + await loadNextPage() + }) + + return () => { + clearTimeout(timeoutId) + } + }, [loadNextPage]) + + const loadButtonState = isLoading ? 'loading' : hasMore ? 'ready' : 'done' + + return ( +
+
+ + +
+
+ ) +} + +interface LoadMoreButtonProps { + state: 'ready' | 'loading' | 'done' + onClick: () => void +} + +function LoadMoreButton({ state, onClick }: LoadMoreButtonProps) { + const buttonClasses = + 'w-full py-3 px-4 bg-gray-100 hover:bg-gray-200 text-gray-800 rounded-md flex items-center justify-center disabled:opacity-70' + switch (state) { + case 'done': + return
that's all...
+ case 'loading': + return ( + + ) + case 'ready': + return ( + + ) + } +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx new file mode 100644 index 0000000..56a7252 --- /dev/null +++ b/src/pages/HomePage.tsx @@ -0,0 +1,14 @@ +import { useCallback } from 'react' +import { Post } from '../model/posts/posts.ts' +import './FeedView.css' +import { loadPublicFeed } from '../api/api.ts' +import FeedView from './FeedView.tsx' + +export default function HomePage() { + const fetchPosts = useCallback(async (cursor: string | null, amount: number | null) => { + const result = await loadPublicFeed(cursor, amount) + return result.posts.map((post) => Post.fromDto(post)) + }, []) + + return +} diff --git a/tsconfig.app.json b/tsconfig.app.json index 358ca9b..850fd66 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -1,9 +1,9 @@ { "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2020", + "target": "ESNext", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true,