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 + } +}