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 { BrowserRouter, Route, Routes } from 'react-router'
|
||||||
import HomePage from './pages/HomePage.tsx'
|
import HomePage from './pages/HomePage.tsx'
|
||||||
|
import { PostsService } from './model/posts/postsService.ts'
|
||||||
import AuthorPage from './pages/AuthorPage.tsx'
|
import AuthorPage from './pages/AuthorPage.tsx'
|
||||||
|
import { MediaService } from './pages/mediaService.ts'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const postService = new PostsService()
|
||||||
|
const mediaService = new MediaService()
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path={'/'} element={<HomePage />} />
|
<Route
|
||||||
<Route path="/u/:username" element={<AuthorPage />} />
|
path={'/'}
|
||||||
|
element={<HomePage postsService={postService} mediaService={mediaService} />}
|
||||||
|
/>
|
||||||
|
<Route path="/u/:username" element={<AuthorPage postsService={postService} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
|
|
|
@ -13,11 +13,8 @@ export async function loadPostsForAuthor(
|
||||||
): Promise<components['schemas']['GetAuthorPostsResponse']> {
|
): Promise<components['schemas']['GetAuthorPostsResponse']> {
|
||||||
const url = new URL(`authors/${authorId}/posts`, ApiHost)
|
const url = new URL(`authors/${authorId}/posts`, ApiHost)
|
||||||
if (amount != null) url.searchParams.set('amount', amount.toString())
|
if (amount != null) url.searchParams.set('amount', amount.toString())
|
||||||
|
|
||||||
if (cursor != null) url.searchParams.set('from', cursor)
|
if (cursor != null) url.searchParams.set('from', cursor)
|
||||||
|
|
||||||
const res = await doGetRequest(url)
|
const res = await doGetRequest(url)
|
||||||
|
|
||||||
return res as components['schemas']['GetAuthorPostsResponse']
|
return res as components['schemas']['GetAuthorPostsResponse']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,14 +24,46 @@ export async function loadPublicFeed(
|
||||||
): Promise<components['schemas']['GetAllPublicPostsResponse']> {
|
): Promise<components['schemas']['GetAllPublicPostsResponse']> {
|
||||||
const url = new URL(`posts`, ApiHost)
|
const url = new URL(`posts`, ApiHost)
|
||||||
if (amount != null) url.searchParams.set('amount', amount.toString())
|
if (amount != null) url.searchParams.set('amount', amount.toString())
|
||||||
|
if (cursor != null) url.searchParams.set('from', cursor)
|
||||||
if (cursor) url.searchParams.set('from', cursor)
|
|
||||||
|
|
||||||
const res = await doGetRequest(url)
|
const res = await doGetRequest(url)
|
||||||
|
|
||||||
return res as components['schemas']['GetAllPublicPostsResponse']
|
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> {
|
async function doGetRequest(url: URL): Promise<unknown> {
|
||||||
const response = await fetch(new Request(url))
|
const response = await fetch(new Request(url))
|
||||||
|
|
||||||
|
@ -42,3 +71,19 @@ async function doGetRequest(url: URL): Promise<unknown> {
|
||||||
|
|
||||||
return await response.json()
|
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: {
|
get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
Cursor?: string
|
From?: string
|
||||||
Amount?: number
|
Amount?: number
|
||||||
}
|
}
|
||||||
header?: never
|
header?: never
|
||||||
|
@ -66,6 +66,85 @@ export interface paths {
|
||||||
patch?: never
|
patch?: never
|
||||||
trace?: 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': {
|
'/authors/{username}/posts': {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never
|
query?: never
|
||||||
|
@ -76,7 +155,7 @@ export interface paths {
|
||||||
get: {
|
get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
Cursor?: string
|
From?: string
|
||||||
Amount?: number
|
Amount?: number
|
||||||
}
|
}
|
||||||
header?: never
|
header?: never
|
||||||
|
@ -160,6 +239,11 @@ export interface components {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
UploadMediaResponse: {
|
||||||
|
/** Format: uuid */
|
||||||
|
mediaId: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
responses: never
|
responses: never
|
||||||
parameters: 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 {
|
export class Post {
|
||||||
public readonly postId: string
|
public readonly postId: string
|
||||||
public readonly content: string
|
public readonly content: string
|
||||||
public readonly media: string[]
|
public readonly media: URL[]
|
||||||
public readonly createdAt: Temporal.PlainDateTime
|
public readonly createdAt: Temporal.Instant
|
||||||
public readonly authorName: string
|
public readonly authorName: string
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
postId: string,
|
postId: string,
|
||||||
content: string,
|
content: string,
|
||||||
media: string[],
|
media: URL[],
|
||||||
createdAt: Temporal.PlainDateTime,
|
createdAt: string | Temporal.Instant,
|
||||||
authorName: string,
|
authorName: string,
|
||||||
) {
|
) {
|
||||||
this.postId = postId
|
this.postId = postId
|
||||||
this.content = content
|
this.content = content
|
||||||
this.media = media
|
this.media = media
|
||||||
this.createdAt = createdAt
|
this.createdAt = Temporal.Instant.from(createdAt)
|
||||||
this.authorName = authorName
|
this.authorName = authorName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,8 +28,8 @@ export class Post {
|
||||||
return new Post(
|
return new Post(
|
||||||
dto.postId,
|
dto.postId,
|
||||||
dto.content,
|
dto.content,
|
||||||
dto.media,
|
dto.media.map((url) => new URL(url)),
|
||||||
Temporal.PlainDateTime.from(dto.createdAt),
|
Temporal.Instant.from(dto.createdAt),
|
||||||
dto.author.username,
|
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 { useCallback } from 'react'
|
||||||
|
import FeedView from '../feed/FeedView.tsx'
|
||||||
|
import { PostsService } from '../model/posts/postsService.ts'
|
||||||
import { useParams } from 'react-router'
|
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 { username } = useParams()
|
||||||
|
|
||||||
const fetchPosts = useCallback(
|
const fetchPosts = useCallback(
|
||||||
async (cursor: string | null, amount: number | null) => {
|
async (cursor: string | null, amount: number | null) => {
|
||||||
const result = await loadPostsForAuthor(username!, cursor, amount)
|
return postsService.loadByAuthor(username!, cursor, amount)
|
||||||
return result.posts.map((post) => Post.fromDto(post))
|
|
||||||
},
|
},
|
||||||
[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 { Post } from '../model/posts/posts.ts'
|
||||||
import { loadPublicFeed } from '../api/api.ts'
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
import FeedView from './FeedView.tsx'
|
|
||||||
|
|
||||||
export default function HomePage() {
|
interface HomePageProps {
|
||||||
const fetchPosts = useCallback(async (cursor: string | null, amount: number | null) => {
|
postsService: PostsService
|
||||||
const result = await loadPublicFeed(cursor, amount)
|
mediaService: MediaService
|
||||||
return result.posts.map((post) => Post.fromDto(post))
|
}
|
||||||
}, [])
|
|
||||||
|
export default function HomePage({ postsService, mediaService }: HomePageProps) {
|
||||||
const handleCreatePost = useCallback(async (content: string, media: File[]) => {
|
const [user] = useUserStore()
|
||||||
// This is a placeholder for the actual implementation
|
|
||||||
// In a real app, you would call an API to create a post
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
console.log('Creating post:', { content, media })
|
|
||||||
|
const fetchPosts = useCallback(
|
||||||
// Simulate API call delay
|
async (cursor: string | null, amount: number | null) => {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
return postsService.loadPublicFeed(cursor, amount)
|
||||||
|
},
|
||||||
// You would typically refresh the feed after creating a post
|
[postsService],
|
||||||
// This could be done by calling fetchPosts again or by adding the new post to the state
|
)
|
||||||
}, [])
|
|
||||||
|
const [pages, setPages, loadNextPage] = useFeedViewModel(fetchPosts)
|
||||||
return <FeedView loadPosts={fetchPosts} onCreatePost={handleCreatePost} />
|
|
||||||
|
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