This commit is contained in:
john 2025-05-06 18:13:12 +02:00
parent 4573048a47
commit d4a1492d56
16 changed files with 463 additions and 98 deletions

View file

@ -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 (
<BrowserRouter>
@ -21,7 +26,8 @@ function App() {
/>
<Route path="/u/:username" element={<AuthorPage postsService={postService} />} />
<Route path="/login" element={<LoginPage authService={authService} />} />
<Route path="/signup/:code?" element={<SignupPage />} />
<Route path="/logout" element={<LogoutPage authService={authService} />} />
<Route path="/signup/:code?" element={<SignupPage authService={authService} />} />
</Routes>
</BrowserRouter>
)

View file

@ -1,30 +1,66 @@
import { components } from './schema.ts'
export interface Api {
readonly auth: AuthApi
loadPublicFeed(
cursor: string | null,
amount: number | null,
author: string | null,
): Promise<components['schemas']['GetAllPublicPostsResponse']>
uploadMedia(file: File): Promise<components['schemas']['UploadMediaResponse']>
createPost(
authorId: string,
content: string,
media: components['schemas']['CreatePostRequestMedia'][],
): Promise<components['schemas']['CreatePostResponse']>
}
export interface AuthApi {
login(username: string, password: string): Promise<components['schemas']['LoginResponse']>
signup(
username: string,
password: string,
signupCode: string,
email: string | null,
): Promise<components['schemas']['SignupResponse']>
deleteSession(sessionToken: string): Promise<void>
}
export class ApiImpl implements Api {
private readonly apiHost: string
public get auth(): AuthApi {
return new AuthApiImpl(this.apiHost)
}
constructor() {
// 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`
// 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)
}
console.debug('API HOST IS', ApiHost)
export async function loadPublicFeed(
async loadPublicFeed(
cursor: string | null,
amount: number | null,
author: string | null,
): Promise<components['schemas']['GetAllPublicPostsResponse']> {
const url = new URL(`posts`, ApiHost)
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']
}
export async function uploadMedia(
file: File,
): Promise<components['schemas']['UploadMediaResponse']> {
const url = new URL('media', ApiHost)
async uploadMedia(file: File): Promise<components['schemas']['UploadMediaResponse']> {
const url = new URL('media', this.apiHost)
const body = new FormData()
body.append('file', file)
const response = await fetch(
@ -38,12 +74,12 @@ export async function uploadMedia(
return await response.json()
}
export async function createPost(
async createPost(
authorId: string,
content: string,
media: components['schemas']['CreatePostRequestMedia'][],
): Promise<components['schemas']['CreatePostResponse']> {
const url = new URL('posts', ApiHost)
const url = new URL('posts', this.apiHost)
const body: components['schemas']['CreatePostRequest'] = {
authorId,
content,
@ -53,6 +89,45 @@ export async function createPost(
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<components['schemas']['LoginResponse']> {
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<components['schemas']['SignupResponse']> {
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<void> {
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<unknown> {
const response = await fetch(new Request(url))

View file

@ -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<string, never>
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

View file

@ -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<string | null>(null)
const usernameInputRef = useRef<HTMLInputElement | null>(null)
const passwordInputRef = useRef<HTMLInputElement | null>(null)
const navigate = useNavigate()
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
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 {

22
src/auth/LogoutPage.tsx Normal file
View file

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

View file

@ -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<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
@ -27,6 +32,8 @@ export default function SignupPage() {
const dialogRef = useRef<HTMLDialogElement | null>(null)
const navigate = useNavigate()
useEffect(() => {
if (signupCode) return
@ -47,6 +54,10 @@ export default function SignupPage() {
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
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)
}

View file

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

View file

@ -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 (
<nav className={`w-full flex flex-row-reverse gap-4 px-4 md:px-8 py-0.5`}>
<NavLinkButton to="/signup">register</NavLinkButton>
<NavLinkButton to="/login">login</NavLinkButton>
{loggedIn ? <LoggedInContent /> : <LoggedOutContent />}
</nav>
)
}
function LoggedInContent() {
return (
<>
<NavLinkButton to="/logout">logout</NavLinkButton>
</>
)
}
function LoggedOutContent() {
return (
<>
<NavLinkButton to="/signup">register</NavLinkButton>
<NavLinkButton to="/login">login</NavLinkButton>{' '}
</>
)
}

View file

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

View file

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

View file

@ -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<string> {
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<Post[]> {
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<Post[]> {
const result = await loadPublicFeed(cursor, amount, username)
const result = await this.api.loadPublicFeed(cursor, amount, username)
return result.posts.map((post) => Post.fromDto(post))
}
}

View file

@ -0,0 +1,12 @@
import { MessageTypes } from './messageTypes.ts'
export function addMessageListener<E extends keyof MessageTypes>(
e: E,
listener: (message: MessageTypes[E]) => void,
) {
window.addEventListener(e, (event) => listener((event as CustomEvent).detail))
}
export function dispatchMessage<E extends keyof MessageTypes>(e: E, message: MessageTypes[E]) {
window.dispatchEvent(new CustomEvent(e, { detail: message }))
}

View file

@ -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': {}
}

View file

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

View file

@ -1,8 +1,6 @@
// storeFactory.ts
import { useEffect, useState } from 'react'
interface Store<T> {
useStore: () => [T, (nextState: T | ((prevState: T) => T)) => void]
export interface Store<T> {
getState: () => T
setState: (nextState: T | ((prevState: T) => T)) => void
subscribe: (listener: Listener<T>) => () => void
@ -33,13 +31,13 @@ export function createStore<T extends object | null>(initialState: T): Store<T>
}
}
function useStore(): [typeof state, typeof setState] {
const [selectedState, setSelectedState] = useState(() => getState())
useEffect(() => subscribe((newState: T) => setSelectedState(newState)), [])
return [selectedState, setState]
return { getState, setState, subscribe }
}
return { useStore, getState, setState, subscribe }
export function useStore<T>(store: Store<T>) {
const [selectedState, setSelectedState] = useState(() => store.getState())
useEffect(() => store.subscribe((newState) => setSelectedState(newState)), [store])
return [selectedState, setSelectedState] as const
}

View file

@ -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<User | null>
export function useUserStore() {
const [user, setUser] = useState<User | null>(user)
}
const UserKey = 'user'
// todo not hardcode
export const userStore = createStore<User | null>({
userId: '0196960c-6296-7532-ba66-8fabb38c6ae0',
username: 'johnbotris',
export const userStore = createStore<User | null>(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
}
}