@{post.authorName}
@@ -44,17 +39,31 @@ export default function PostItem({ post, index }: PostItemProps) {
{post.media.length > 0 && (
- {post.media.map((src) => (
-
})
+ {post.media.map((media) => (
+
))}
)}
)
}
+
+interface PostMediaProps {
+ media: PostMedia
+}
+
+function PostMediaItem({ media }: PostMediaProps) {
+ const url = media.url.toString()
+ const width = media.width ?? undefined
+ const height = media.height ?? undefined
+ return (
+

+ )
+}
diff --git a/src/model/posts/posts.ts b/src/feed/models/posts/posts.ts
similarity index 52%
rename from src/model/posts/posts.ts
rename to src/feed/models/posts/posts.ts
index de9b465..c9323ba 100644
--- a/src/model/posts/posts.ts
+++ b/src/feed/models/posts/posts.ts
@@ -1,17 +1,17 @@
import { Temporal } from '@js-temporal/polyfill'
-import { components } from '../../api/schema.ts'
+import { components } from '../../../api/schema.ts'
export class Post {
public readonly postId: string
public readonly content: string
- public readonly media: URL[]
+ public readonly media: PostMedia[]
public readonly createdAt: Temporal.Instant
public readonly authorName: string
constructor(
postId: string,
content: string,
- media: URL[],
+ media: PostMedia[],
createdAt: string | Temporal.Instant,
authorName: string,
) {
@@ -22,15 +22,28 @@ export class Post {
this.authorName = authorName
}
- public static fromDto(
- dto: components['schemas']['AuthorPostDto'] | components['schemas']['PublicPostDto'],
- ): Post {
+ public static fromDto(dto: components['schemas']['PublicPostDto']): Post {
+ console.debug('make post', dto)
return new Post(
dto.postId,
dto.content,
- dto.media.map((url) => new URL(url)),
+ dto.media.map((m) => new PostMediaImpl(new URL(m.url), m.width, m.height)),
Temporal.Instant.from(dto.createdAt),
dto.author.username,
)
}
}
+
+export interface PostMedia {
+ readonly url: URL
+ readonly width: number | null
+ readonly height: number | null
+}
+
+class PostMediaImpl implements PostMedia {
+ constructor(
+ readonly url: URL,
+ readonly width: number | null,
+ readonly height: number | null,
+ ) {}
+}
diff --git a/src/feed/models/posts/postsService.ts b/src/feed/models/posts/postsService.ts
new file mode 100644
index 0000000..350213a
--- /dev/null
+++ b/src/feed/models/posts/postsService.ts
@@ -0,0 +1,36 @@
+import { createPost, loadPublicFeed } from '../../../api/api.ts'
+import { Post } from './posts.ts'
+
+export class PostsService {
+ async createNew(authorId: string, content: string, media: CreatePostMedia[]): Promise
{
+ const { postId } = await createPost(
+ authorId,
+ content,
+ media.map((m) => {
+ return { ...m, type: null, url: m.url.toString() }
+ }),
+ )
+ return postId
+ }
+
+ async loadPublicFeed(cursor: string | null, amount: number | null): Promise {
+ const result = await loadPublicFeed(cursor, amount, null)
+ return result.posts.map((post) => Post.fromDto(post))
+ }
+
+ async loadByAuthor(
+ username: string,
+ cursor: string | null,
+ amount: number | null,
+ ): Promise {
+ const result = await loadPublicFeed(cursor, amount, username)
+ return result.posts.map((post) => Post.fromDto(post))
+ }
+}
+
+interface CreatePostMedia {
+ mediaId: string
+ url: string | URL
+ width: number | null
+ height: number | null
+}
diff --git a/src/hooks/useIntersectionLoad.ts b/src/hooks/useIntersectionLoad.ts
index 9ba4720..f704131 100644
--- a/src/hooks/useIntersectionLoad.ts
+++ b/src/hooks/useIntersectionLoad.ts
@@ -9,7 +9,7 @@ export function useIntersectionLoad(
callback: () => Promise,
elementRef: RefObject,
{
- earlyTriggerPx = 1800,
+ earlyTriggerPx = 1200,
debounceMs = 300,
root = null,
threshold = 0.1,
diff --git a/src/model/media/mediaService.ts b/src/model/media/mediaService.ts
new file mode 100644
index 0000000..52334ff
--- /dev/null
+++ b/src/model/media/mediaService.ts
@@ -0,0 +1,8 @@
+import { uploadMedia } from '../../api/api.ts'
+
+export class MediaService {
+ async uploadFile(file: File): Promise<{ mediaId: string; url: URL }> {
+ const { mediaId, url } = await uploadMedia(file)
+ return { mediaId, url: new URL(url) }
+ }
+}
diff --git a/src/model/mediaService.ts b/src/model/mediaService.ts
deleted file mode 100644
index 48c2cc5..0000000
--- a/src/model/mediaService.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-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/model/posts/postsService.ts b/src/model/posts/postsService.ts
deleted file mode 100644
index f34778d..0000000
--- a/src/model/posts/postsService.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-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 e808b24..8cf0b54 100644
--- a/src/pages/AuthorPage.tsx
+++ b/src/pages/AuthorPage.tsx
@@ -1,6 +1,6 @@
import { useCallback } from 'react'
import FeedView from '../feed/FeedView.tsx'
-import { PostsService } from '../model/posts/postsService.ts'
+import { PostsService } from '../feed/models/posts/postsService.ts'
import { useParams } from 'react-router'
import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
import NavBar from '../components/NavBar.tsx'
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
index 1a246a4..c65cd21 100644
--- a/src/pages/HomePage.tsx
+++ b/src/pages/HomePage.tsx
@@ -1,11 +1,11 @@
import { useCallback, useState } from 'react'
import FeedView from '../feed/FeedView.tsx'
-import { PostsService } from '../model/posts/postsService.ts'
+import { PostsService } from '../feed/models/posts/postsService.ts'
import { useUserStore } from '../store/userStore.ts'
-import { MediaService } from '../model/mediaService.ts'
+import { MediaService } from '../model/media/mediaService.ts'
import NewPostWidget from '../components/NewPostWidget.tsx'
import { useFeedViewModel } from '../feed/FeedView.ts'
-import { Post } from '../model/posts/posts.ts'
+import { Post } from '../feed/models/posts/posts.ts'
import { Temporal } from '@js-temporal/polyfill'
import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
import NavBar from '../components/NavBar.tsx'
@@ -30,15 +30,25 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
const { pages, setPages, loadNextPage } = useFeedViewModel(fetchPosts)
const onCreatePost = useCallback(
- async (content: string, files: File[]) => {
- if (!onCreatePost) return
-
+ async (content: string, files: { file: File; width: number; height: number }[]) => {
setIsSubmitting(true)
+ if (user == null) throw new Error('Not logged in')
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)
+ const media = await Promise.all(
+ files.map(async ({ file, width, height }) => {
+ console.debug('do mediaService.uploadFile', file, 'width', width, 'height', height)
+ const { mediaId, url } = await mediaService.uploadFile(file)
+
+ return {
+ mediaId,
+ url,
+ width,
+ height,
+ }
+ }),
+ )
+ const postId = await postsService.createNew(user.userId, content, media)
+ const post = new Post(postId, content, media, Temporal.Now.instant(), user.username)
setPages((pages) => [[post], ...pages])
} catch (error) {
console.error('Failed to create post:', error)
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..e2ec911
--- /dev/null
+++ b/src/pages/LoginPage.tsx
@@ -0,0 +1,85 @@
+import { useRef, useState, FormEvent } from 'react'
+import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
+import TextInput from '../components/TextInput.tsx'
+import PrimaryButton from '../components/PrimaryButton.tsx'
+import { AuthService } from '../auth/authService.ts'
+
+interface LoginPageProps {
+ authService: AuthService
+}
+
+export default function LoginPage({ authService }: LoginPageProps) {
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState(null)
+ const usernameInputRef = useRef(null)
+ const passwordInputRef = useRef(null)
+
+ const onSubmit = async (e: FormEvent) => {
+ e.preventDefault()
+
+ if (!username) {
+ setError("you didn't username D:<")
+ return
+ }
+
+ if (!password) {
+ setError("you didn't password D:<")
+ return
+ }
+
+ setError(null)
+
+ setIsSubmitting(true)
+
+ try {
+ const loginResult = await authService.login(username, password)
+ } catch (error: unknown) {
+ setError(error instanceof Error ? error.message : 'something went terribly wrong')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/src/pages/SignupPage.tsx b/src/pages/SignupPage.tsx
index c38c68a..dd48ee7 100644
--- a/src/pages/SignupPage.tsx
+++ b/src/pages/SignupPage.tsx
@@ -1,9 +1,10 @@
import { useParams } from 'react-router'
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useRef, useState, FormEvent, useCallback, Ref } from 'react'
import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
import TextInput from '../components/TextInput.tsx'
import PrimaryButton from '../components/PrimaryButton.tsx'
import PrimaryLinkButton from '../components/PrimaryLinkButton.tsx'
+import { invalid, valid, Validation } from '../utils/validation.ts'
const SignupCodeKey = 'signupCode'
@@ -12,9 +13,17 @@ export default function SignupPage() {
const [signupCode, setSignupCode] = useState(null)
const [isSubmitting, setIsSubmitting] = useState(false)
- const [username, setUsername] = useState('')
- const [email, setEmail] = useState('')
- const [password, setPassword] = useState('')
+ const [username, setUsername, usernameError, validateUsername] =
+ useValidatedInput(isValidUsername)
+
+ const [email, setEmail, emailError, validateEmail] = useValidatedInput(isValidEmail)
+
+ const [password, setPassword, passwordError, validatePassword] =
+ useValidatedInput(isValidPassword)
+
+ const userNameInputRef = useRef(null)
+ const emailInputRef = useRef(null)
+ const passwordInputRef = useRef(null)
const dialogRef = useRef(null)
@@ -35,8 +44,29 @@ export default function SignupPage() {
}
}, [code, signupCode])
- const onSubmit = async (e: React.FormEvent) => {
+ const onSubmit = async (e: FormEvent) => {
e.preventDefault()
+
+ const isUsernameValid = validateUsername()
+ const isEmailValid = validateEmail()
+ const isPasswordValid = validatePassword()
+
+ if (!isPasswordValid) {
+ passwordInputRef.current?.focus()
+ }
+
+ if (!isEmailValid) {
+ emailInputRef.current?.focus()
+ }
+
+ if (!isUsernameValid) {
+ userNameInputRef.current?.focus()
+ }
+
+ if (!isUsernameValid || !isEmailValid || !isPasswordValid) {
+ return
+ }
+
setIsSubmitting(true)
try {
@@ -51,34 +81,33 @@ export default function SignupPage() {
@@ -109,3 +138,87 @@ export default function SignupPage() {
)
}
+
+interface FormInputProps {
+ id: string
+ value: string
+ onInput: (value: string) => void
+ error: string | null
+ type?: 'text' | 'password'
+ ref: Ref
+}
+
+function FormInput({ id, value, onInput, error, type = 'text', ref }: FormInputProps) {
+ return (
+
+
+
+ {error}
+
+ )
+}
+
+type UseValidateInputReturn = [string, (value: string) => void, string | null, () => boolean]
+
+function useValidatedInput(validator: (value: string) => Validation): UseValidateInputReturn {
+ const [value, setValue] = useState('')
+ const [error, setError] = useState(null)
+ const [keepValidating, setKeepValidating] = useState(false)
+
+ const validate = useCallback(() => {
+ const { isValid, error } = validator(value)
+ if (isValid) {
+ setError(null)
+ } else {
+ // We only want to validate on input after they have invalidly submitted once.
+ // It's annoying if we set error messages before they've even finished typing.
+ setKeepValidating(true)
+ setError(error)
+ }
+
+ return isValid
+ }, [validator, value])
+
+ useEffect(() => {
+ if (keepValidating) {
+ validate()
+ }
+ }, [keepValidating, validate])
+
+ return [value, setValue, error, validate]
+}
+
+function isValidUsername(username: string): Validation {
+ if (!username) return invalid('you need to enter a username :/')
+
+ if (username.length < 3) {
+ return invalid('not long enough :(')
+ }
+
+ const usernameRegex = /^[a-zA-Z0-9_-]+$/
+ if (usernameRegex.test(username)) {
+ return valid()
+ } else {
+ return invalid("that's not a good username :'(")
+ }
+}
+
+function isValidEmail(email: string): Validation {
+ if (!email) return valid()
+ const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
+ if (emailRegex.test(email)) {
+ return valid()
+ } else {
+ return invalid("um sorry but that doesn't look like an email 🤔")
+ }
+}
+
+function isValidPassword(password: string): Validation {
+ if (password.length >= 10) {
+ return valid()
+ } else {
+ return invalid("that isn't a good password :/")
+ }
+}
diff --git a/src/store/userStore.ts b/src/store/userStore.ts
index 1f79ff8..d4ece6e 100644
--- a/src/store/userStore.ts
+++ b/src/store/userStore.ts
@@ -1,10 +1,17 @@
import { createStore } from './store.ts'
+import { useState } from 'react'
interface User {
userId: string
username: string
}
+let user: User | null = localStorage.getItem('user')
+
+export function useUserStore() {
+ const [user, setUser] = useState(user)
+}
+
// todo not hardcode
export const userStore = createStore({
userId: '0196960c-6296-7532-ba66-8fabb38c6ae0',
diff --git a/src/utils/validation.ts b/src/utils/validation.ts
new file mode 100644
index 0000000..c64e528
--- /dev/null
+++ b/src/utils/validation.ts
@@ -0,0 +1,19 @@
+export type Validation = Valid | Invalid
+
+interface Valid {
+ isValid: true
+ error: null
+}
+
+interface Invalid {
+ isValid: false
+ error: string
+}
+
+export function valid(): Validation {
+ return { isValid: true, error: null }
+}
+
+export function invalid(error: string): Validation {
+ return { isValid: false, error }
+}