This commit is contained in:
john 2025-05-04 23:22:45 +02:00
parent 8e365867bd
commit 74c66e74c9
13 changed files with 363 additions and 112 deletions

View file

@ -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>
)

View file

@ -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()
}

View file

@ -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
View 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
View 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
}

View file

@ -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,
)
}

View 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))
}
}

View file

@ -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} />
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View 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
View 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
View 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