From 74c66e74c9a6c8d8aa5e397c0551d19a7d8450fd Mon Sep 17 00:00:00 2001 From: john Date: Sun, 4 May 2025 23:22:45 +0200 Subject: [PATCH] recatfor --- src/App.tsx | 11 ++++- src/api/api.ts | 55 +++++++++++++++++++-- src/api/schema.ts | 88 ++++++++++++++++++++++++++++++++- src/feed/FeedView.tsx | 22 +++++++++ src/feed/feedViewModel.ts | 32 ++++++++++++ src/model/posts/posts.ts | 14 +++--- src/model/posts/postsService.ts | 27 ++++++++++ src/pages/AuthorPage.tsx | 18 ++++--- src/pages/FeedView.tsx | 66 ------------------------- src/pages/HomePage.tsx | 75 +++++++++++++++++++--------- src/pages/mediaService.ts | 8 +++ src/store/store.ts | 45 +++++++++++++++++ src/store/userStore.ts | 14 ++++++ 13 files changed, 363 insertions(+), 112 deletions(-) create mode 100644 src/feed/FeedView.tsx create mode 100644 src/feed/feedViewModel.ts create mode 100644 src/model/posts/postsService.ts delete mode 100644 src/pages/FeedView.tsx create mode 100644 src/pages/mediaService.ts create mode 100644 src/store/store.ts create mode 100644 src/store/userStore.ts diff --git a/src/App.tsx b/src/App.tsx index 41a8827..dcd15db 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,20 @@ import { BrowserRouter, Route, Routes } from 'react-router' import HomePage from './pages/HomePage.tsx' +import { PostsService } from './model/posts/postsService.ts' import AuthorPage from './pages/AuthorPage.tsx' +import { MediaService } from './pages/mediaService.ts' function App() { + const postService = new PostsService() + const mediaService = new MediaService() return ( - } /> - } /> + } + /> + } /> ) diff --git a/src/api/api.ts b/src/api/api.ts index a2e533b..08e0912 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -13,11 +13,8 @@ export async function loadPostsForAuthor( ): Promise { const url = new URL(`authors/${authorId}/posts`, ApiHost) if (amount != null) url.searchParams.set('amount', amount.toString()) - if (cursor != null) url.searchParams.set('from', cursor) - const res = await doGetRequest(url) - return res as components['schemas']['GetAuthorPostsResponse'] } @@ -27,14 +24,46 @@ export async function loadPublicFeed( ): Promise { const url = new URL(`posts`, ApiHost) if (amount != null) url.searchParams.set('amount', amount.toString()) - - if (cursor) url.searchParams.set('from', cursor) + if (cursor != null) url.searchParams.set('from', cursor) const res = await doGetRequest(url) return res as components['schemas']['GetAllPublicPostsResponse'] } +export async function uploadMedia( + file: File, +): Promise { + const url = new URL('media', ApiHost) + const body = new FormData() + body.append('file', file) + const response = await fetch( + new Request(url, { + method: 'POST', + body: body, + }), + ) + + if (!response.ok) throw new Error(await response.text()) + return await response.json() +} + +export async function createPost( + authorId: string, + content: string, + media: string[], +): Promise { + const url = new URL('posts', ApiHost) + const body: components['schemas']['CreatePostRequest'] = { + authorId, + content, + media, + } + + const res = await doPostRequest(url, body) + return res as components['schemas']['CreatePostResponse'] +} + async function doGetRequest(url: URL): Promise { const response = await fetch(new Request(url)) @@ -42,3 +71,19 @@ async function doGetRequest(url: URL): Promise { return await response.json() } + +async function doPostRequest(url: URL, body: unknown): Promise { + const response = await fetch( + new Request(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }), + ) + + if (!response.ok) throw new Error(await response.text()) + + return await response.json() +} diff --git a/src/api/schema.ts b/src/api/schema.ts index b58d94e..cc2f120 100644 --- a/src/api/schema.ts +++ b/src/api/schema.ts @@ -9,7 +9,7 @@ export interface paths { get: { parameters: { query?: { - Cursor?: string + From?: string Amount?: number } header?: never @@ -66,6 +66,85 @@ export interface paths { patch?: never trace?: never } + '/media': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + post: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody: { + content: { + 'multipart/form-data': { + /** Format: binary */ + file?: string + } + } + } + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'text/plain': components['schemas']['UploadMediaResponse'] + 'application/json': components['schemas']['UploadMediaResponse'] + 'text/json': components['schemas']['UploadMediaResponse'] + } + } + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/media/{id}': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get: { + parameters: { + query?: never + header?: never + path: { + id: string + } + cookie?: never + } + requestBody?: never + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/authors/{username}/posts': { parameters: { query?: never @@ -76,7 +155,7 @@ export interface paths { get: { parameters: { query?: { - Cursor?: string + From?: string Amount?: number } header?: never @@ -160,6 +239,11 @@ export interface components { /** Format: date-time */ createdAt: string } + UploadMediaResponse: { + /** Format: uuid */ + mediaId: string + url: string + } } responses: never parameters: never diff --git a/src/feed/FeedView.tsx b/src/feed/FeedView.tsx new file mode 100644 index 0000000..7ce5514 --- /dev/null +++ b/src/feed/FeedView.tsx @@ -0,0 +1,22 @@ +import PostsList from '../components/PostsList.tsx' +import { useRef } from 'react' +import { useIntersectionLoad } from '../hooks/useIntersectionLoad.ts' +import { Post } from '../model/posts/posts.ts' + +interface FeedViewProps { + pages: Post[][] + onLoadMore: () => Promise +} + +export default function FeedView({ pages, onLoadMore }: FeedViewProps) { + const sentinelRef = useRef(null) + + useIntersectionLoad(onLoadMore, sentinelRef) + + return ( +
+ +
+
+ ) +} diff --git a/src/feed/feedViewModel.ts b/src/feed/feedViewModel.ts new file mode 100644 index 0000000..11f21c3 --- /dev/null +++ b/src/feed/feedViewModel.ts @@ -0,0 +1,32 @@ +import { useCallback, useRef, useState } from 'react' +import { Post } from '../model/posts/posts.ts' + +const PageSize = 20 + +export function useFeedViewModel( + loadMore: (cursor: string | null, amount: number) => Promise, +) { + const [pages, setPages] = useState([]) + const [hasMore, setHasMore] = useState(true) + + const cursor = useRef(null) + const loading = useRef(false) + + const loadNextPage = useCallback(async () => { + if (loading.current || !hasMore) return + loading.current = true + + try { + const delay = new Promise((resolve) => setTimeout(resolve, 500)) + const pagePromise = loadMore(cursor.current, PageSize) + const [page] = await Promise.all([pagePromise, delay]) + setHasMore(page.length >= PageSize) + cursor.current = page.at(-1)?.postId ?? null + setPages((prev) => [...prev, page]) + } finally { + loading.current = false + } + }, [loadMore, hasMore]) + + return [pages, setPages, loadNextPage] as const +} diff --git a/src/model/posts/posts.ts b/src/model/posts/posts.ts index 69ab531..de9b465 100644 --- a/src/model/posts/posts.ts +++ b/src/model/posts/posts.ts @@ -4,21 +4,21 @@ import { components } from '../../api/schema.ts' export class Post { public readonly postId: string public readonly content: string - public readonly media: string[] - public readonly createdAt: Temporal.PlainDateTime + public readonly media: URL[] + public readonly createdAt: Temporal.Instant public readonly authorName: string constructor( postId: string, content: string, - media: string[], - createdAt: Temporal.PlainDateTime, + media: URL[], + createdAt: string | Temporal.Instant, authorName: string, ) { this.postId = postId this.content = content this.media = media - this.createdAt = createdAt + this.createdAt = Temporal.Instant.from(createdAt) this.authorName = authorName } @@ -28,8 +28,8 @@ export class Post { return new Post( dto.postId, dto.content, - dto.media, - Temporal.PlainDateTime.from(dto.createdAt), + dto.media.map((url) => new URL(url)), + Temporal.Instant.from(dto.createdAt), dto.author.username, ) } diff --git a/src/model/posts/postsService.ts b/src/model/posts/postsService.ts new file mode 100644 index 0000000..f34778d --- /dev/null +++ b/src/model/posts/postsService.ts @@ -0,0 +1,27 @@ +import { createPost, loadPostsForAuthor, loadPublicFeed } from '../../api/api.ts' +import { Post } from './posts.ts' + +export class PostsService { + async createNew(authorId: string, content: string, media: URL[]): Promise { + const { postId } = await createPost( + authorId, + content, + media.map((url) => url.toString()), + ) + return postId + } + + async loadPublicFeed(cursor: string | null, amount: number | null): Promise { + const result = await loadPublicFeed(cursor, amount) + return result.posts.map((post) => Post.fromDto(post)) + } + + async loadByAuthor( + username: string, + cursor: string | null, + amount: number | null, + ): Promise { + const result = await loadPostsForAuthor(username!, cursor, amount) + return result.posts.map((post) => Post.fromDto(post)) + } +} diff --git a/src/pages/AuthorPage.tsx b/src/pages/AuthorPage.tsx index 14f7aed..b02e19c 100644 --- a/src/pages/AuthorPage.tsx +++ b/src/pages/AuthorPage.tsx @@ -1,19 +1,21 @@ import { useCallback } from 'react' +import FeedView from '../feed/FeedView.tsx' +import { PostsService } from '../model/posts/postsService.ts' import { useParams } from 'react-router' -import { loadPostsForAuthor } from '../api/api.ts' -import { Post } from '../model/posts/posts.ts' -import FeedView from './FeedView.tsx' -export default function AuthorPage() { +interface AuthorPageParams { + postsService: PostsService +} + +export default function AuthorPage({ postsService }: AuthorPageParams) { const { username } = useParams() 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)) + return postsService.loadByAuthor(username!, cursor, amount) }, - [username], + [postsService, username], ) - return + return } diff --git a/src/pages/FeedView.tsx b/src/pages/FeedView.tsx deleted file mode 100644 index ac3f52f..0000000 --- a/src/pages/FeedView.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Post } from '../model/posts/posts.ts' -import PostsList from '../components/PostsList.tsx' -import NewPostWidget from '../components/NewPostWidget.tsx' -import { useCallback, useRef, useState } from 'react' -import { useIntersectionLoad } from '../hooks/useIntersectionLoad.ts' - -const PageSize = 20 - -interface FeedViewProps { - loadPosts: (cursor: string | null, amount: number) => Promise - onCreatePost?: (content: string, media: File[]) => Promise -} - -export default function FeedView({ loadPosts, onCreatePost }: FeedViewProps) { - const [pages, setPages] = useState([]) - const [hasMore, setHasMore] = useState(true) - const [isSubmitting, setIsSubmitting] = useState(false) - - const cursor = useRef(null) - const loading = useRef(false) - const sentinelRef = useRef(null) - - const handleCreatePost = useCallback(async (content: string, media: File[]) => { - if (!onCreatePost) return - - setIsSubmitting(true) - try { - await onCreatePost(content, media) - // Optionally refresh the feed after posting - // You could implement this by resetting pages and cursor, then loading the first page again - } catch (error) { - console.error('Failed to create post:', error) - } finally { - setIsSubmitting(false) - } - }, [onCreatePost]) - - const loadNextPage = useCallback(async () => { - if (loading.current || !hasMore) return - loading.current = true - - try { - const delay = new Promise((resolve) => setTimeout(resolve, 500)) - const pagePromise = loadPosts(cursor.current, PageSize) - const [page] = await Promise.all([pagePromise, delay]) - setHasMore(page.length >= PageSize) - cursor.current = page.at(-1)?.postId ?? null - setPages((prev) => [...prev, page]) - } finally { - loading.current = false - } - }, [loadPosts, hasMore]) - - useIntersectionLoad(loadNextPage, sentinelRef) - return ( -
-
- {onCreatePost && ( - - )} - -
-
-
- ) -} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 256fa6a..62bfbe3 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,25 +1,56 @@ -import { useCallback } from 'react' +import { useCallback, useState } from 'react' +import FeedView from '../feed/FeedView.tsx' +import { PostsService } from '../model/posts/postsService.ts' +import { useUserStore } from '../store/userStore.ts' +import { MediaService } from './mediaService.ts' +import NewPostWidget from '../components/NewPostWidget.tsx' +import { useFeedViewModel } from '../feed/feedViewModel.ts' import { Post } from '../model/posts/posts.ts' -import { loadPublicFeed } from '../api/api.ts' -import FeedView from './FeedView.tsx' +import { Temporal } from '@js-temporal/polyfill' -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)) - }, []) - - const handleCreatePost = useCallback(async (content: string, media: File[]) => { - // This is a placeholder for the actual implementation - // In a real app, you would call an API to create a post - console.log('Creating post:', { content, media }) - - // Simulate API call delay - await new Promise(resolve => setTimeout(resolve, 1000)) - - // You would typically refresh the feed after creating a post - // This could be done by calling fetchPosts again or by adding the new post to the state - }, []) - - return +interface HomePageProps { + postsService: PostsService + mediaService: MediaService +} + +export default function HomePage({ postsService, mediaService }: HomePageProps) { + const [user] = useUserStore() + + const [isSubmitting, setIsSubmitting] = useState(false) + + const fetchPosts = useCallback( + async (cursor: string | null, amount: number | null) => { + return postsService.loadPublicFeed(cursor, amount) + }, + [postsService], + ) + + const [pages, setPages, loadNextPage] = useFeedViewModel(fetchPosts) + + const onCreatePost = useCallback( + async (content: string, files: File[]) => { + if (!onCreatePost) return + + setIsSubmitting(true) + try { + if (user == null) throw new Error('Not logged in') + const urls = await Promise.all(files.map((file) => mediaService.uploadFile(file))) + const postId = await postsService.createNew(user.userId, content, urls) + const post = new Post(postId, content, urls, Temporal.Now.instant(), user.username) + setPages((pages) => [[post], ...pages]) + } catch (error) { + console.error('Failed to create post:', error) + } finally { + setIsSubmitting(false) + } + }, + [mediaService, postsService, setPages, user], + ) + + return ( +
+ + +
+ ) } diff --git a/src/pages/mediaService.ts b/src/pages/mediaService.ts new file mode 100644 index 0000000..48c2cc5 --- /dev/null +++ b/src/pages/mediaService.ts @@ -0,0 +1,8 @@ +import { uploadMedia } from '../api/api.ts' + +export class MediaService { + async uploadFile(file: File): Promise { + const { url } = await uploadMedia(file) + return new URL(url) + } +} diff --git a/src/store/store.ts b/src/store/store.ts new file mode 100644 index 0000000..b4987ff --- /dev/null +++ b/src/store/store.ts @@ -0,0 +1,45 @@ +// storeFactory.ts +import { useEffect, useState } from 'react' + +interface Store { + useStore: () => [T, (nextState: T | ((prevState: T) => T)) => void] + getState: () => T + setState: (nextState: T | ((prevState: T) => T)) => void + subscribe: (listener: Listener) => () => void +} + +type Listener = (state: T) => void + +export function createStore(initialState: T): Store { + let state = initialState + const listeners = new Set>() + + const getState = () => Object.freeze(state) + + function setState(nextState: T | ((prevState: T) => T)) { + if (typeof nextState === 'function') { + state = nextState(state) + } else { + state = nextState + } + + listeners.forEach((listener) => listener(state)) + } + + function subscribe(listener: Listener) { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + } + + function useStore(): [typeof state, typeof setState] { + const [selectedState, setSelectedState] = useState(() => getState()) + + useEffect(() => subscribe((newState: T) => setSelectedState(newState)), []) + + return [selectedState, setState] + } + + return { useStore, getState, setState, subscribe } +} diff --git a/src/store/userStore.ts b/src/store/userStore.ts new file mode 100644 index 0000000..1f79ff8 --- /dev/null +++ b/src/store/userStore.ts @@ -0,0 +1,14 @@ +import { createStore } from './store.ts' + +interface User { + userId: string + username: string +} + +// todo not hardcode +export const userStore = createStore({ + userId: '0196960c-6296-7532-ba66-8fabb38c6ae0', + username: 'johnbotris', +}) + +export const useUserStore = userStore.useStore