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