diff --git a/src/App.tsx b/src/App.tsx
index 374da9b..057f804 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,16 +1,21 @@
import { BrowserRouter, Route, Routes } from 'react-router'
-import HomePage from './pages/HomePage.tsx'
+import HomePage from './feed/HomePage.tsx'
import { PostsService } from './feed/models/posts/postsService.ts'
-import AuthorPage from './pages/AuthorPage.tsx'
+import AuthorPage from './feed/AuthorPage.tsx'
import { MediaService } from './model/media/mediaService.ts'
-import SignupPage from './pages/SignupPage.tsx'
-import LoginPage from './pages/LoginPage.tsx'
+import SignupPage from './auth/SignupPage.tsx'
+import LoginPage from './auth/LoginPage.tsx'
import { AuthService } from './auth/authService.ts'
+import { useUser } from './store/userStore.ts'
+import LogoutPage from './auth/LogoutPage.tsx'
+import { ApiImpl } from './api/api.ts'
function App() {
- const postService = new PostsService()
- const mediaService = new MediaService()
- const authService = new AuthService()
+ const [user] = useUser()
+ const api = new ApiImpl()
+ const postService = new PostsService(api)
+ const mediaService = new MediaService(api)
+ const authService = new AuthService(api, user)
return (
@@ -21,7 +26,8 @@ function App() {
/>
} />
} />
- } />
+ } />
+ } />
)
diff --git a/src/api/api.ts b/src/api/api.ts
index 012dedd..8d89fdf 100644
--- a/src/api/api.ts
+++ b/src/api/api.ts
@@ -1,57 +1,132 @@
import { components } from './schema.ts'
-// TODO for now we just assume that the API and client are running on the same host
-// i think this might change but dependes on what we do with deployment
-const ApiHost = `http://${location.hostname}:5181`
+export interface Api {
+ readonly auth: AuthApi
-console.debug('API HOST IS', ApiHost)
+ loadPublicFeed(
+ cursor: string | null,
+ amount: number | null,
+ author: string | null,
+ ): Promise
-export async function loadPublicFeed(
- cursor: string | null,
- amount: number | null,
- author: string | null,
-): Promise {
- const url = new URL(`posts`, ApiHost)
- if (amount != null) url.searchParams.set('amount', amount.toString())
- if (cursor != null) url.searchParams.set('from', cursor)
- if (author != null) url.searchParams.set('author', author)
+ uploadMedia(file: File): Promise
- const res = await doGetRequest(url)
-
- return res as components['schemas']['GetAllPublicPostsResponse']
+ createPost(
+ authorId: string,
+ content: string,
+ media: components['schemas']['CreatePostRequestMedia'][],
+ ): Promise
}
-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,
- }),
- )
+export interface AuthApi {
+ login(username: string, password: string): Promise
- if (!response.ok) throw new Error(await response.text())
- return await response.json()
+ signup(
+ username: string,
+ password: string,
+ signupCode: string,
+ email: string | null,
+ ): Promise
+
+ deleteSession(sessionToken: string): Promise
}
-export async function createPost(
- authorId: string,
- content: string,
- media: components['schemas']['CreatePostRequestMedia'][],
-): Promise {
- const url = new URL('posts', ApiHost)
- const body: components['schemas']['CreatePostRequest'] = {
- authorId,
- content,
- media,
+export class ApiImpl implements Api {
+ private readonly apiHost: string
+
+ public get auth(): AuthApi {
+ return new AuthApiImpl(this.apiHost)
}
- const res = await doPostRequest(url, body)
- return res as components['schemas']['CreatePostResponse']
+ constructor() {
+ // TODO for now we just assume that the API and client are running on the same host
+ // i think this might change but depends on what we do with deployment
+ this.apiHost = `http://${location.hostname}:5181`
+ console.debug('API HOST IS', this.apiHost)
+ }
+
+ async loadPublicFeed(
+ cursor: string | null,
+ amount: number | null,
+ author: string | null,
+ ): Promise {
+ const url = new URL(`posts`, this.apiHost)
+ if (amount != null) url.searchParams.set('amount', amount.toString())
+ if (cursor != null) url.searchParams.set('from', cursor)
+ if (author != null) url.searchParams.set('author', author)
+
+ const res = await doGetRequest(url)
+ return res as components['schemas']['GetAllPublicPostsResponse']
+ }
+
+ async uploadMedia(file: File): Promise {
+ const url = new URL('media', this.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()
+ }
+
+ async createPost(
+ authorId: string,
+ content: string,
+ media: components['schemas']['CreatePostRequestMedia'][],
+ ): Promise {
+ const url = new URL('posts', this.apiHost)
+ const body: components['schemas']['CreatePostRequest'] = {
+ authorId,
+ content,
+ media,
+ }
+
+ const res = await doPostRequest(url, body)
+ return res as components['schemas']['CreatePostResponse']
+ }
+}
+
+class AuthApiImpl implements AuthApi {
+ constructor(private readonly apiHost: string) {}
+ async login(username: string, password: string): Promise {
+ const url = new URL('/auth/login', this.apiHost)
+ const body: components['schemas']['LoginRequest'] = {
+ username,
+ password,
+ }
+ const res = await doPostRequest(url, body)
+ return res as components['schemas']['LoginResponse']
+ }
+
+ async signup(
+ username: string,
+ password: string,
+ signupCode: string,
+ email: string | null,
+ ): Promise {
+ const url = new URL('/auth/signup', this.apiHost)
+ const body: components['schemas']['SignupRequest'] = {
+ username,
+ password,
+ signupCode,
+ email,
+ }
+ const res = await doPostRequest(url, body)
+ return res as components['schemas']['SignupResponse']
+ }
+
+ async deleteSession(sessionToken: string): Promise {
+ const url = new URL('/auth/delete-session', this.apiHost)
+ const body: components['schemas']['DeleteSessionRequest'] = {
+ sessionToken,
+ }
+ await doPostRequest(url, body)
+ }
}
async function doGetRequest(url: URL): Promise {
diff --git a/src/api/schema.ts b/src/api/schema.ts
index c4f87e2..2e3e150 100644
--- a/src/api/schema.ts
+++ b/src/api/schema.ts
@@ -147,6 +147,131 @@ export interface paths {
patch?: never
trace?: never
}
+ '/auth/login': {
+ 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: {
+ 'application/json': components['schemas']['LoginRequest']
+ 'text/json': components['schemas']['LoginRequest']
+ 'application/*+json': components['schemas']['LoginRequest']
+ }
+ }
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'text/plain': components['schemas']['LoginResponse']
+ 'application/json': components['schemas']['LoginResponse']
+ 'text/json': components['schemas']['LoginResponse']
+ }
+ }
+ }
+ }
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
+ '/auth/signup': {
+ 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: {
+ 'application/json': components['schemas']['SignupRequest']
+ 'text/json': components['schemas']['SignupRequest']
+ 'application/*+json': components['schemas']['SignupRequest']
+ }
+ }
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'text/plain': components['schemas']['SignupResponse']
+ 'application/json': components['schemas']['SignupResponse']
+ 'text/json': components['schemas']['SignupResponse']
+ }
+ }
+ }
+ }
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
+ '/auth/delete-session': {
+ 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: {
+ 'application/json': components['schemas']['DeleteSessionRequest']
+ 'text/json': components['schemas']['DeleteSessionRequest']
+ 'application/*+json': components['schemas']['DeleteSessionRequest']
+ }
+ }
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
+ }
+ }
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
}
export type webhooks = Record
export interface components {
@@ -172,11 +297,24 @@ export interface components {
/** Format: uuid */
postId: string
}
+ DeleteSessionRequest: {
+ sessionToken: string
+ }
GetAllPublicPostsResponse: {
posts: components['schemas']['PublicPostDto'][]
/** Format: uuid */
next: string | null
}
+ LoginRequest: {
+ username: string
+ password: string
+ }
+ LoginResponse: {
+ /** Format: uuid */
+ userId: string
+ username: string
+ sessionToken: string
+ }
PublicPostAuthorDto: {
/** Format: uuid */
authorId: string
@@ -199,6 +337,18 @@ export interface components {
/** Format: int32 */
height: number | null
}
+ SignupRequest: {
+ username: string
+ password: string
+ signupCode: string
+ email: string | null
+ }
+ SignupResponse: {
+ /** Format: uuid */
+ userId: string
+ username: string
+ sessionToken: string
+ }
UploadMediaResponse: {
/** Format: uuid */
mediaId: string
diff --git a/src/pages/LoginPage.tsx b/src/auth/LoginPage.tsx
similarity index 92%
rename from src/pages/LoginPage.tsx
rename to src/auth/LoginPage.tsx
index e2ec911..f84f1a9 100644
--- a/src/pages/LoginPage.tsx
+++ b/src/auth/LoginPage.tsx
@@ -2,7 +2,8 @@ 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'
+import { AuthService } from './authService.ts'
+import { useNavigate } from 'react-router'
interface LoginPageProps {
authService: AuthService
@@ -15,6 +16,7 @@ export default function LoginPage({ authService }: LoginPageProps) {
const [error, setError] = useState(null)
const usernameInputRef = useRef(null)
const passwordInputRef = useRef(null)
+ const navigate = useNavigate()
const onSubmit = async (e: FormEvent) => {
e.preventDefault()
@@ -34,7 +36,8 @@ export default function LoginPage({ authService }: LoginPageProps) {
setIsSubmitting(true)
try {
- const loginResult = await authService.login(username, password)
+ await authService.login(username, password)
+ navigate('/')
} catch (error: unknown) {
setError(error instanceof Error ? error.message : 'something went terribly wrong')
} finally {
diff --git a/src/auth/LogoutPage.tsx b/src/auth/LogoutPage.tsx
new file mode 100644
index 0000000..4a6650c
--- /dev/null
+++ b/src/auth/LogoutPage.tsx
@@ -0,0 +1,22 @@
+import { useNavigate } from 'react-router'
+import { AuthService } from './authService.ts'
+import { useEffect } from 'react'
+
+interface LogoutPageProps {
+ authService: AuthService
+}
+
+export default function LogoutPage({ authService }: LogoutPageProps) {
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ const timeout = setTimeout(async () => {
+ await authService.logout()
+ navigate('/')
+ })
+
+ return () => clearTimeout(timeout)
+ }, [authService, navigate])
+
+ return <>>
+}
diff --git a/src/pages/SignupPage.tsx b/src/auth/SignupPage.tsx
similarity index 93%
rename from src/pages/SignupPage.tsx
rename to src/auth/SignupPage.tsx
index dd48ee7..755c49b 100644
--- a/src/pages/SignupPage.tsx
+++ b/src/auth/SignupPage.tsx
@@ -1,14 +1,19 @@
-import { useParams } from 'react-router'
+import { useNavigate, useParams } from 'react-router'
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'
+import { AuthService } from './authService.ts'
const SignupCodeKey = 'signupCode'
-export default function SignupPage() {
+interface SignupPageProps {
+ authService: AuthService
+}
+
+export default function SignupPage({ authService }: SignupPageProps) {
const { code } = useParams()
const [signupCode, setSignupCode] = useState(null)
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -27,6 +32,8 @@ export default function SignupPage() {
const dialogRef = useRef(null)
+ const navigate = useNavigate()
+
useEffect(() => {
if (signupCode) return
@@ -47,6 +54,10 @@ export default function SignupPage() {
const onSubmit = async (e: FormEvent) => {
e.preventDefault()
+ if (!signupCode) {
+ throw new Error("there's no code")
+ }
+
const isUsernameValid = validateUsername()
const isEmailValid = validateEmail()
const isPasswordValid = validatePassword()
@@ -70,7 +81,8 @@ export default function SignupPage() {
setIsSubmitting(true)
try {
- // todo
+ await authService.signup(username, email, password, signupCode)
+ navigate('/')
} finally {
setIsSubmitting(false)
}
diff --git a/src/auth/authService.ts b/src/auth/authService.ts
index 4716131..ea2f981 100644
--- a/src/auth/authService.ts
+++ b/src/auth/authService.ts
@@ -1,9 +1,40 @@
+import { User } from '../store/userStore.ts'
+import { dispatchMessage } from '../messageBus/addMessageListener.ts'
+import { Api } from '../api/api.ts'
+
export class AuthService {
+ constructor(
+ private readonly api: Api,
+ private readonly user: User | null,
+ ) {}
+
async login(username: string, password: string) {
- throw new Error('not implemented')
+ if (this.user != null) {
+ throw new Error('already logged in')
+ }
+
+ const res = await this.api.auth.login(username, password)
+
+ dispatchMessage('logged-in', { ...res })
}
async signup(username: string, password: string, signupCode: string, email?: string) {
- throw new Error('not implemented')
+ if (this.user != null) {
+ throw new Error('already logged in')
+ }
+
+ const res = await this.api.auth.signup(username, password, signupCode, email ?? null)
+
+ dispatchMessage('signed-up', { ...res })
+ }
+
+ async logout() {
+ if (this.user == null) {
+ return
+ }
+
+ await this.api.auth.deleteSession(this.user.sessionToken)
+
+ dispatchMessage('logged-out', {})
}
}
diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx
index a8d70bc..8928779 100644
--- a/src/components/NavBar.tsx
+++ b/src/components/NavBar.tsx
@@ -1,10 +1,31 @@
+import { useUser } from '../store/userStore'
import NavLinkButton from './NavLinkButton'
export default function NavBar() {
+ const [user] = useUser()
+
+ const loggedIn = user != null
+
return (
)
}
+
+function LoggedInContent() {
+ return (
+ <>
+ logout
+ >
+ )
+}
+
+function LoggedOutContent() {
+ return (
+ <>
+ register
+ login{' '}
+ >
+ )
+}
diff --git a/src/pages/AuthorPage.tsx b/src/feed/AuthorPage.tsx
similarity index 82%
rename from src/pages/AuthorPage.tsx
rename to src/feed/AuthorPage.tsx
index 8cf0b54..d9ec260 100644
--- a/src/pages/AuthorPage.tsx
+++ b/src/feed/AuthorPage.tsx
@@ -1,10 +1,10 @@
import { useCallback } from 'react'
-import FeedView from '../feed/FeedView.tsx'
-import { PostsService } from '../feed/models/posts/postsService.ts'
+import FeedView from './FeedView.tsx'
+import { PostsService } from './models/posts/postsService.ts'
import { useParams } from 'react-router'
import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
import NavBar from '../components/NavBar.tsx'
-import { useFeedViewModel } from '../feed/FeedView.ts'
+import { useFeedViewModel } from './FeedView.ts'
interface AuthorPageParams {
postsService: PostsService
diff --git a/src/pages/HomePage.tsx b/src/feed/HomePage.tsx
similarity index 87%
rename from src/pages/HomePage.tsx
rename to src/feed/HomePage.tsx
index c65cd21..61509a0 100644
--- a/src/pages/HomePage.tsx
+++ b/src/feed/HomePage.tsx
@@ -1,11 +1,11 @@
import { useCallback, useState } from 'react'
-import FeedView from '../feed/FeedView.tsx'
-import { PostsService } from '../feed/models/posts/postsService.ts'
-import { useUserStore } from '../store/userStore.ts'
+import FeedView from './FeedView.tsx'
+import { PostsService } from './models/posts/postsService.ts'
+import { useUser } from '../store/userStore.ts'
import { MediaService } from '../model/media/mediaService.ts'
import NewPostWidget from '../components/NewPostWidget.tsx'
-import { useFeedViewModel } from '../feed/FeedView.ts'
-import { Post } from '../feed/models/posts/posts.ts'
+import { useFeedViewModel } from './FeedView.ts'
+import { Post } from './models/posts/posts.ts'
import { Temporal } from '@js-temporal/polyfill'
import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
import NavBar from '../components/NavBar.tsx'
@@ -16,7 +16,7 @@ interface HomePageProps {
}
export default function HomePage({ postsService, mediaService }: HomePageProps) {
- const [user] = useUserStore()
+ const [user] = useUser()
const [isSubmitting, setIsSubmitting] = useState(false)
diff --git a/src/feed/models/posts/postsService.ts b/src/feed/models/posts/postsService.ts
index 350213a..e24aeca 100644
--- a/src/feed/models/posts/postsService.ts
+++ b/src/feed/models/posts/postsService.ts
@@ -1,9 +1,10 @@
-import { createPost, loadPublicFeed } from '../../../api/api.ts'
+import { Api } from '../../../api/api.ts'
import { Post } from './posts.ts'
export class PostsService {
+ constructor(private readonly api: Api) {}
async createNew(authorId: string, content: string, media: CreatePostMedia[]): Promise {
- const { postId } = await createPost(
+ const { postId } = await this.api.createPost(
authorId,
content,
media.map((m) => {
@@ -14,7 +15,7 @@ export class PostsService {
}
async loadPublicFeed(cursor: string | null, amount: number | null): Promise {
- const result = await loadPublicFeed(cursor, amount, null)
+ const result = await this.api.loadPublicFeed(cursor, amount, null)
return result.posts.map((post) => Post.fromDto(post))
}
@@ -23,7 +24,7 @@ export class PostsService {
cursor: string | null,
amount: number | null,
): Promise {
- const result = await loadPublicFeed(cursor, amount, username)
+ const result = await this.api.loadPublicFeed(cursor, amount, username)
return result.posts.map((post) => Post.fromDto(post))
}
}
diff --git a/src/messageBus/addMessageListener.ts b/src/messageBus/addMessageListener.ts
new file mode 100644
index 0000000..9023bfc
--- /dev/null
+++ b/src/messageBus/addMessageListener.ts
@@ -0,0 +1,12 @@
+import { MessageTypes } from './messageTypes.ts'
+
+export function addMessageListener(
+ e: E,
+ listener: (message: MessageTypes[E]) => void,
+) {
+ window.addEventListener(e, (event) => listener((event as CustomEvent).detail))
+}
+
+export function dispatchMessage(e: E, message: MessageTypes[E]) {
+ window.dispatchEvent(new CustomEvent(e, { detail: message }))
+}
diff --git a/src/messageBus/messageTypes.ts b/src/messageBus/messageTypes.ts
new file mode 100644
index 0000000..0e94833
--- /dev/null
+++ b/src/messageBus/messageTypes.ts
@@ -0,0 +1,13 @@
+export interface MessageTypes {
+ 'logged-in': {
+ userId: string
+ username: string
+ sessionToken: string
+ }
+ 'signed-up': {
+ userId: string
+ username: string
+ sessionToken: string
+ }
+ 'logged-out': {}
+}
diff --git a/src/model/media/mediaService.ts b/src/model/media/mediaService.ts
index 52334ff..a0fd756 100644
--- a/src/model/media/mediaService.ts
+++ b/src/model/media/mediaService.ts
@@ -1,8 +1,9 @@
-import { uploadMedia } from '../../api/api.ts'
+import { Api } from '../../api/api.ts'
export class MediaService {
+ constructor(private readonly api: Api) {}
async uploadFile(file: File): Promise<{ mediaId: string; url: URL }> {
- const { mediaId, url } = await uploadMedia(file)
+ const { mediaId, url } = await this.api.uploadMedia(file)
return { mediaId, url: new URL(url) }
}
}
diff --git a/src/store/store.ts b/src/store/store.ts
index b4987ff..5db61dc 100644
--- a/src/store/store.ts
+++ b/src/store/store.ts
@@ -1,8 +1,6 @@
-// storeFactory.ts
import { useEffect, useState } from 'react'
-interface Store {
- useStore: () => [T, (nextState: T | ((prevState: T) => T)) => void]
+export interface Store {
getState: () => T
setState: (nextState: T | ((prevState: T) => T)) => void
subscribe: (listener: Listener) => () => void
@@ -33,13 +31,13 @@ export function createStore(initialState: T): Store
}
}
- 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 }
+ return { getState, setState, subscribe }
+}
+
+export function useStore(store: Store) {
+ const [selectedState, setSelectedState] = useState(() => store.getState())
+
+ useEffect(() => store.subscribe((newState) => setSelectedState(newState)), [store])
+
+ return [selectedState, setSelectedState] as const
}
diff --git a/src/store/userStore.ts b/src/store/userStore.ts
index d4ece6e..ea56b34 100644
--- a/src/store/userStore.ts
+++ b/src/store/userStore.ts
@@ -1,21 +1,41 @@
-import { createStore } from './store.ts'
-import { useState } from 'react'
+import { createStore, Store, useStore } from './store.ts'
+import { addMessageListener } from '../messageBus/addMessageListener.ts'
-interface User {
+export interface User {
userId: string
username: string
+ sessionToken: string
}
-let user: User | null = localStorage.getItem('user')
+export type UserStore = Store
-export function useUserStore() {
- const [user, setUser] = useState(user)
-}
+const UserKey = 'user'
-// todo not hardcode
-export const userStore = createStore({
- userId: '0196960c-6296-7532-ba66-8fabb38c6ae0',
- username: 'johnbotris',
+export const userStore = createStore(loadStoredUser())
+
+userStore.subscribe((user) => {
+ localStorage.setItem(UserKey, JSON.stringify(user))
})
-export const useUserStore = userStore.useStore
+addMessageListener('logged-in', (e) => {
+ userStore.setState({
+ userId: e.userId,
+ username: e.username,
+ sessionToken: e.sessionToken,
+ })
+})
+
+addMessageListener('logged-out', () => {
+ userStore.setState(null)
+})
+
+export const useUser = () => useStore(userStore)
+
+function loadStoredUser(): User | null {
+ const json = localStorage.getItem(UserKey)
+ if (json) {
+ return JSON.parse(json) as User
+ } else {
+ return null
+ }
+}