recatfor
This commit is contained in:
parent
8e365867bd
commit
74c66e74c9
13 changed files with 363 additions and 112 deletions
11
src/App.tsx
11
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 (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path={'/'} element={<HomePage />} />
|
||||
<Route path="/u/:username" element={<AuthorPage />} />
|
||||
<Route
|
||||
path={'/'}
|
||||
element={<HomePage postsService={postService} mediaService={mediaService} />}
|
||||
/>
|
||||
<Route path="/u/:username" element={<AuthorPage postsService={postService} />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
|
|
@ -13,11 +13,8 @@ export async function loadPostsForAuthor(
|
|||
): Promise<components['schemas']['GetAuthorPostsResponse']> {
|
||||
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<components['schemas']['GetAllPublicPostsResponse']> {
|
||||
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<components['schemas']['UploadMediaResponse']> {
|
||||
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<components['schemas']['CreatePostResponse']> {
|
||||
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<unknown> {
|
||||
const response = await fetch(new Request(url))
|
||||
|
||||
|
@ -42,3 +71,19 @@ async function doGetRequest(url: URL): Promise<unknown> {
|
|||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async function doPostRequest(url: URL, body: unknown): Promise<unknown> {
|
||||
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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
22
src/feed/FeedView.tsx
Normal file
22
src/feed/FeedView.tsx
Normal file
|
@ -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<void>
|
||||
}
|
||||
|
||||
export default function FeedView({ pages, onLoadMore }: FeedViewProps) {
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useIntersectionLoad(onLoadMore, sentinelRef)
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<PostsList pages={pages} />
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
</div>
|
||||
)
|
||||
}
|
32
src/feed/feedViewModel.ts
Normal file
32
src/feed/feedViewModel.ts
Normal file
|
@ -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<Post[]>,
|
||||
) {
|
||||
const [pages, setPages] = useState<Post[][]>([])
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
|
||||
const cursor = useRef<string | null>(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
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
27
src/model/posts/postsService.ts
Normal file
27
src/model/posts/postsService.ts
Normal file
|
@ -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<string> {
|
||||
const { postId } = await createPost(
|
||||
authorId,
|
||||
content,
|
||||
media.map((url) => url.toString()),
|
||||
)
|
||||
return postId
|
||||
}
|
||||
|
||||
async loadPublicFeed(cursor: string | null, amount: number | null): Promise<Post[]> {
|
||||
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<Post[]> {
|
||||
const result = await loadPostsForAuthor(username!, cursor, amount)
|
||||
return result.posts.map((post) => Post.fromDto(post))
|
||||
}
|
||||
}
|
|
@ -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 <FeedView loadPosts={fetchPosts} />
|
||||
return <FeedView loadMore={fetchPosts} />
|
||||
}
|
||||
|
|
|
@ -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<Post[]>
|
||||
onCreatePost?: (content: string, media: File[]) => Promise<void>
|
||||
}
|
||||
|
||||
export default function FeedView({ loadPosts, onCreatePost }: FeedViewProps) {
|
||||
const [pages, setPages] = useState<Post[][]>([])
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const cursor = useRef<string | null>(null)
|
||||
const loading = useRef(false)
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(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 (
|
||||
<main className="w-full flex justify-center">
|
||||
<div className="max-w-3xl w-full">
|
||||
{onCreatePost && (
|
||||
<NewPostWidget onSubmit={handleCreatePost} isSubmitting={isSubmitting} />
|
||||
)}
|
||||
<PostsList pages={pages} />
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
|
@ -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 <FeedView loadPosts={fetchPosts} onCreatePost={handleCreatePost} />
|
||||
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 (
|
||||
<main className={`w-full max-w-3xl mx-auto`}>
|
||||
<NewPostWidget onSubmit={onCreatePost} isSubmitting={isSubmitting} />
|
||||
<FeedView pages={pages} onLoadMore={loadNextPage} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
8
src/pages/mediaService.ts
Normal file
8
src/pages/mediaService.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { uploadMedia } from '../api/api.ts'
|
||||
|
||||
export class MediaService {
|
||||
async uploadFile(file: File): Promise<URL> {
|
||||
const { url } = await uploadMedia(file)
|
||||
return new URL(url)
|
||||
}
|
||||
}
|
45
src/store/store.ts
Normal file
45
src/store/store.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
// storeFactory.ts
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface Store<T> {
|
||||
useStore: () => [T, (nextState: T | ((prevState: T) => T)) => void]
|
||||
getState: () => T
|
||||
setState: (nextState: T | ((prevState: T) => T)) => void
|
||||
subscribe: (listener: Listener<T>) => () => void
|
||||
}
|
||||
|
||||
type Listener<T> = (state: T) => void
|
||||
|
||||
export function createStore<T extends object | null>(initialState: T): Store<T> {
|
||||
let state = initialState
|
||||
const listeners = new Set<Listener<T>>()
|
||||
|
||||
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<T>) {
|
||||
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 }
|
||||
}
|
14
src/store/userStore.ts
Normal file
14
src/store/userStore.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { createStore } from './store.ts'
|
||||
|
||||
interface User {
|
||||
userId: string
|
||||
username: string
|
||||
}
|
||||
|
||||
// todo not hardcode
|
||||
export const userStore = createStore<User | null>({
|
||||
userId: '0196960c-6296-7532-ba66-8fabb38c6ae0',
|
||||
username: 'johnbotris',
|
||||
})
|
||||
|
||||
export const useUserStore = userStore.useStore
|
Loading…
Add table
Add a link
Reference in a new issue