some change

This commit is contained in:
john 2025-05-16 16:09:35 +02:00
parent d4a1492d56
commit 313f1def49
38 changed files with 475 additions and 401 deletions

View file

@ -1,34 +1,38 @@
import { BrowserRouter, Route, Routes } from 'react-router'
import HomePage from './feed/HomePage.tsx'
import { PostsService } from './feed/models/posts/postsService.ts'
import AuthorPage from './feed/AuthorPage.tsx'
import { MediaService } from './model/media/mediaService.ts'
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'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import HomePage from './app/feed/pages/HomePage.tsx'
import { PostsService } from './app/feed/posts/postsService.ts'
import AuthorPage from './app/feed/pages/AuthorPage.tsx'
import { MediaService } from './app/media/mediaService.ts'
import SignupPage from './app/auth/pages/SignupPage.tsx'
import LoginPage from './app/auth/pages/LoginPage.tsx'
import { AuthService } from './app/auth/authService.ts'
import { useUser } from './app/user/userStore.ts'
import LogoutPage from './app/auth/pages/LogoutPage.tsx'
import UnauthorizedHandler from './app/auth/components/UnauthorizedHandler.tsx'
import Protected from './app/auth/components/Protected.tsx'
function App() {
const [user] = useUser()
const api = new ApiImpl()
const postService = new PostsService(api)
const mediaService = new MediaService(api)
const authService = new AuthService(api, user)
const postService = new PostsService()
const mediaService = new MediaService()
const authService = new AuthService(user)
return (
<BrowserRouter>
<Routes>
<Route
path={'/'}
element={<HomePage postsService={postService} mediaService={mediaService} />}
/>
<Route path="/u/:username" element={<AuthorPage postsService={postService} />} />
<Route path="/login" element={<LoginPage authService={authService} />} />
<Route path="/logout" element={<LogoutPage authService={authService} />} />
<Route path="/signup/:code?" element={<SignupPage authService={authService} />} />
</Routes>
<UnauthorizedHandler>
<Routes>
<Route element={<Protected />}>
<Route
path={'/'}
element={<HomePage postsService={postService} mediaService={mediaService} />}
/>
<Route path="/u/:username" element={<AuthorPage postsService={postService} />} />
</Route>
<Route path="/login" element={<LoginPage authService={authService} />} />
<Route path="/logout" element={<LogoutPage authService={authService} />} />
<Route path="/signup/:code?" element={<SignupPage authService={authService} />} />
</Routes>
</UnauthorizedHandler>
</BrowserRouter>
)
}

View file

@ -1,154 +0,0 @@
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 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<components['schemas']['GetAllPublicPostsResponse']> {
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<components['schemas']['UploadMediaResponse']> {
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<components['schemas']['CreatePostResponse']> {
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<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))
if (!response.ok) throw new Error(await response.text())
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()
}

18
src/app/api/client.ts Normal file
View file

@ -0,0 +1,18 @@
import { paths } from './schema.ts'
import createClient, { Middleware } from 'openapi-fetch'
import { dispatchMessage } from '../messageBus/messageBus.ts'
const client = createClient<paths>({ baseUrl: `${location.protocol}//${location.hostname}:5181` })
const UnauthorizedHandlerMiddleware: Middleware = {
async onResponse({ response }) {
if (response.status === 401) {
dispatchMessage('auth:unauthorized', null)
}
},
}
client.use(UnauthorizedHandlerMiddleware)
// todo inject this if necessary
export default client

View file

@ -112,7 +112,7 @@ export interface paths {
patch?: never
trace?: never
}
'/media/{id}': {
[path: `/media/${string}`]: {
parameters: {
query?: never
header?: never
@ -190,7 +190,7 @@ export interface paths {
patch?: never
trace?: never
}
'/auth/signup': {
'/auth/register': {
parameters: {
query?: never
header?: never
@ -208,9 +208,9 @@ export interface paths {
}
requestBody: {
content: {
'application/json': components['schemas']['SignupRequest']
'text/json': components['schemas']['SignupRequest']
'application/*+json': components['schemas']['SignupRequest']
'application/json': components['schemas']['RegisterRequest']
'text/json': components['schemas']['RegisterRequest']
'application/*+json': components['schemas']['RegisterRequest']
}
}
responses: {
@ -220,9 +220,9 @@ export interface paths {
[name: string]: unknown
}
content: {
'text/plain': components['schemas']['SignupResponse']
'application/json': components['schemas']['SignupResponse']
'text/json': components['schemas']['SignupResponse']
'text/plain': components['schemas']['RegisterResponse']
'application/json': components['schemas']['RegisterResponse']
'text/json': components['schemas']['RegisterResponse']
}
}
}
@ -233,7 +233,7 @@ export interface paths {
patch?: never
trace?: never
}
'/auth/delete-session': {
'/auth/session': {
parameters: {
query?: never
header?: never
@ -242,20 +242,15 @@ export interface paths {
}
get?: never
put?: never
post: {
post?: never
delete: {
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']
}
}
requestBody?: never
responses: {
/** @description OK */
200: {
@ -266,7 +261,6 @@ export interface paths {
}
}
}
delete?: never
options?: never
head?: never
patch?: never
@ -297,9 +291,6 @@ export interface components {
/** Format: uuid */
postId: string
}
DeleteSessionRequest: {
sessionToken: string
}
GetAllPublicPostsResponse: {
posts: components['schemas']['PublicPostDto'][]
/** Format: uuid */
@ -313,7 +304,6 @@ export interface components {
/** Format: uuid */
userId: string
username: string
sessionToken: string
}
PublicPostAuthorDto: {
/** Format: uuid */
@ -337,17 +327,16 @@ export interface components {
/** Format: int32 */
height: number | null
}
SignupRequest: {
RegisterRequest: {
username: string
password: string
signupCode: string
email: string | null
}
SignupResponse: {
RegisterResponse: {
/** Format: uuid */
userId: string
username: string
sessionToken: string
}
UploadMediaResponse: {
/** Format: uuid */

View file

@ -0,0 +1,51 @@
import { User } from '../user/userStore.ts'
import { dispatchMessage } from '../messageBus/messageBus.ts'
import client from '../api/client.ts'
export class AuthService {
constructor(private readonly user: User | null) {}
async login(username: string, password: string) {
if (this.user != null) {
throw new Error('already logged in')
}
const res = await client.POST('/auth/login', {
body: { username, password },
credentials: 'include',
})
if (!res.data) {
throw new Error('invalid credentials')
}
dispatchMessage('auth:logged-in', { ...res.data })
}
async signup(username: string, password: string, signupCode: string) {
if (this.user != null) {
throw new Error('already logged in')
}
const res = await client.POST('/auth/register', {
body: { username, password, signupCode, email: null },
credentials: 'include',
})
if (!res.data) {
throw new Error('invalid credentials')
}
dispatchMessage('auth:registered', { ...res.data })
}
async logout() {
if (this.user == null) {
return
}
await client.DELETE('/auth/session', { credentials: 'include' })
dispatchMessage('auth:logged-out', null)
}
}

View file

@ -0,0 +1,13 @@
import { useUser } from '../../user/userStore.ts'
import { useNavigate, Outlet } from 'react-router-dom'
export default function Protected() {
const [user] = useUser()
const navigate = useNavigate()
if (!user) {
navigate('/login')
}
return <Outlet />
}

View file

@ -0,0 +1,17 @@
import { useNavigate } from 'react-router-dom'
import { useMessageListener } from '../../../hooks/useMessageListener.ts'
import { PropsWithChildren } from 'react'
type UnauthorizedHandlerProps = unknown
export default function UnauthorizedHandler({
children,
}: PropsWithChildren<UnauthorizedHandlerProps>) {
const navigate = useNavigate()
useMessageListener('auth:unauthorized', async () => {
console.debug('unauth triggered')
navigate('/logout')
})
return <>{children}</>
}

View file

@ -1,9 +1,10 @@
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 './authService.ts'
import { useNavigate } from 'react-router'
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
import TextInput from '../../../components/TextInput.tsx'
import PrimaryButton from '../../../components/PrimaryButton.tsx'
import { AuthService } from '../authService.ts'
import { useNavigate } from 'react-router-dom'
import SecondaryNavButton from '../../../components/SecondaryNavButton.tsx'
interface LoginPageProps {
authService: AuthService
@ -79,6 +80,8 @@ export default function LoginPage({ authService }: LoginPageProps) {
{isSubmitting ? 'wait...' : 'make login pls'}
</PrimaryButton>
<SecondaryNavButton to={'/signup'}>register instead?</SecondaryNavButton>
<span className="text-xs h-3 text-red-500">{error}</span>
</form>
</div>

View file

@ -1,5 +1,5 @@
import { useNavigate } from 'react-router'
import { AuthService } from './authService.ts'
import { useNavigate } from 'react-router-dom'
import { AuthService } from '../authService.ts'
import { useEffect } from 'react'
interface LogoutPageProps {
@ -11,8 +11,8 @@ export default function LogoutPage({ authService }: LogoutPageProps) {
useEffect(() => {
const timeout = setTimeout(async () => {
navigate('/login')
await authService.logout()
navigate('/')
})
return () => clearTimeout(timeout)

View file

@ -1,11 +1,12 @@
import { useNavigate, useParams } from 'react-router'
import { useNavigate, useParams } from 'react-router-dom'
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'
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'
import SecondaryNavButton from '../../../components/SecondaryNavButton.tsx'
const SignupCodeKey = 'signupCode'
@ -21,13 +22,10 @@ export default function SignupPage({ authService }: SignupPageProps) {
const [username, setUsername, usernameError, validateUsername] =
useValidatedInput(isValidUsername)
const [email, setEmail, emailError, validateEmail] = useValidatedInput(isValidEmail)
const [password, setPassword, passwordError, validatePassword] =
useValidatedInput(isValidPassword)
const userNameInputRef = useRef<HTMLInputElement | null>(null)
const emailInputRef = useRef<HTMLInputElement | null>(null)
const passwordInputRef = useRef<HTMLInputElement | null>(null)
const dialogRef = useRef<HTMLDialogElement | null>(null)
@ -59,29 +57,24 @@ export default function SignupPage({ authService }: SignupPageProps) {
}
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) {
if (!isUsernameValid || !isPasswordValid) {
return
}
setIsSubmitting(true)
try {
await authService.signup(username, email, password, signupCode)
await authService.signup(username, password, signupCode)
navigate('/')
} finally {
setIsSubmitting(false)
@ -100,13 +93,7 @@ export default function SignupPage({ authService }: SignupPageProps) {
error={usernameError}
ref={userNameInputRef}
/>
<FormInput
id="email"
value={email}
onInput={setEmail}
error={emailError}
ref={emailInputRef}
/>
<FormInput
id="password"
value={password}
@ -122,6 +109,7 @@ export default function SignupPage({ authService }: SignupPageProps) {
>
{isSubmitting ? 'wait...' : 'give me an account pls'}
</PrimaryButton>
<SecondaryNavButton to={'/login'}>login instead?</SecondaryNavButton>
</form>
</div>
</main>
@ -217,18 +205,8 @@ function isValidUsername(username: string): Validation {
}
}
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) {
if (password.length >= 6) {
return valid()
} else {
return invalid("that isn't a good password :/")

View file

@ -1,5 +1,5 @@
import { useCallback, useRef, useState } from 'react'
import { Post } from './models/posts/posts.ts'
import { Post } from '../posts/posts.ts'
const PageSize = 20
@ -8,12 +8,13 @@ export function useFeedViewModel(
) {
const [pages, setPages] = useState<Post[][]>([])
const [hasMore, setHasMore] = useState(true)
const [error, setError] = useState<string | null>(null)
const cursor = useRef<string | null>(null)
const loading = useRef(false)
const loadNextPage = useCallback(async () => {
if (loading.current || !hasMore) return
if (loading.current || !hasMore || error) return
loading.current = true
try {
@ -23,10 +24,13 @@ export function useFeedViewModel(
setHasMore(page.length >= PageSize)
cursor.current = page.at(-1)?.postId ?? null
setPages((prev) => [...prev, page])
} catch (e: unknown) {
const err = e as Error
setError(err.message)
} finally {
loading.current = false
}
}, [loadMore, hasMore])
}, [loadMore, hasMore, error])
return { pages, setPages, loadNextPage } as const
return { pages, setPages, loadNextPage, error } as const
}

View file

@ -1,6 +1,6 @@
import { useRef } from 'react'
import { useIntersectionLoad } from '../hooks/useIntersectionLoad.ts'
import { Post } from './models/posts/posts.ts'
import { useIntersectionLoad } from '../../../hooks/useIntersectionLoad.ts'
import { Post } from '../posts/posts.ts'
import PostItem from './PostItem.tsx'
interface FeedViewProps {

View file

@ -1,5 +1,5 @@
import { Post, PostMedia } from './models/posts/posts.ts'
import { Link } from 'react-router'
import { Post, PostMedia } from '../posts/posts.ts'
import { Link } from 'react-router-dom'
import { useEffect, useState } from 'react'
interface PostItemProps {

View file

@ -1,10 +1,10 @@
import { useCallback } from 'react'
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 './FeedView.ts'
import FeedView from '../components/FeedView.tsx'
import { PostsService } from '../posts/postsService.ts'
import { useParams } from 'react-router-dom'
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
import NavBar from '../../../components/NavBar.tsx'
import { useFeedViewModel } from '../components/FeedView.ts'
interface AuthorPageParams {
postsService: PostsService

View file

@ -1,14 +1,14 @@
import { useCallback, useState } from 'react'
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 './FeedView.ts'
import { Post } from './models/posts/posts.ts'
import FeedView from '../components/FeedView.tsx'
import { PostsService } from '../posts/postsService.ts'
import { useUser } from '../../user/userStore.ts'
import { MediaService } from '../../media/mediaService.ts'
import NewPostWidget from '../../../components/NewPostWidget.tsx'
import { useFeedViewModel } from '../components/FeedView.ts'
import { Post } from '../posts/posts.ts'
import { Temporal } from '@js-temporal/polyfill'
import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
import NavBar from '../components/NavBar.tsx'
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
import NavBar from '../../../components/NavBar.tsx'
interface HomePageProps {
postsService: PostsService

View file

@ -1,5 +1,5 @@
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

View file

@ -0,0 +1,62 @@
import { Post } from './posts.ts'
import client from '../../api/client.ts'
export class PostsService {
constructor() {}
async createNew(authorId: string, content: string, media: CreatePostMedia[]): Promise<string> {
const response = await client.POST('/posts', {
body: {
authorId,
content,
media: media.map((m) => {
return { ...m, type: null, url: m.url.toString() }
}),
},
credentials: 'include',
})
if (!response.data) {
throw new Error('Failed to create post')
}
return response.data.postId
}
async loadPublicFeed(cursor: string | null, amount: number | null): Promise<Post[]> {
const response = await client.GET('/posts', {
query: { cursor, amount },
credentials: 'include',
})
if (!response.data) {
return []
}
return response.data?.posts.map((post) => Post.fromDto(post))
}
async loadByAuthor(
username: string,
cursor: string | null,
amount: number | null,
): Promise<Post[]> {
const response = await client.GET('/posts', {
query: { cursor, amount, username },
credentials: 'include',
})
if (!response.data) {
return []
}
return response.data?.posts.map((post) => Post.fromDto(post))
}
}
interface CreatePostMedia {
mediaId: string
url: string | URL
width: number | null
height: number | null
}

View file

@ -0,0 +1,22 @@
import client from '../api/client.ts'
export class MediaService {
constructor() {}
async uploadFile(file: File): Promise<{ mediaId: string; url: URL }> {
const body = new FormData()
body.append('file', file)
const response = await client.POST('/media', {
// @ts-expect-error this endpoint takes multipart/form-data which means passing a FormData as the body
// maybe openapi-fetch only wants to handle JSON? who knows
body,
credentials: 'include',
})
if (!response.data) {
throw new Error('Failed to upload file')
}
return { mediaId: response.data.mediaId, url: new URL(response.data.url) }
}
}

View file

@ -0,0 +1,28 @@
import { MessageTypes } from './messageTypes.ts'
export type Listener<E extends keyof MessageTypes> = (
message: MessageTypes[E],
) => void | Promise<void>
type Unlisten = () => void
export function addMessageListener<E extends keyof MessageTypes>(
e: E,
listener: Listener<E>,
): Unlisten {
const handler = async (event: Event) => {
console.debug('message received', e, event)
await listener((event as CustomEvent).detail)
}
window.addEventListener(e, handler)
return () => {
window.removeEventListener(e, handler)
}
}
export function dispatchMessage<E extends keyof MessageTypes>(e: E, message: MessageTypes[E]) {
console.debug('dispatching message', e, message)
window.dispatchEvent(new CustomEvent(e, { detail: message }))
}

View file

@ -1,13 +1,12 @@
export interface MessageTypes {
'logged-in': {
'auth:logged-in': {
userId: string
username: string
sessionToken: string
}
'signed-up': {
'auth:registered': {
userId: string
username: string
sessionToken: string
}
'logged-out': {}
'auth:logged-out': null
'auth:unauthorized': null
}

View file

@ -1,10 +1,9 @@
import { createStore, Store, useStore } from './store.ts'
import { addMessageListener } from '../messageBus/addMessageListener.ts'
import { createStore, Store, useStore } from '../../utils/store.ts'
import { addMessageListener } from '../messageBus/messageBus.ts'
export interface User {
userId: string
username: string
sessionToken: string
}
export type UserStore = Store<User | null>
@ -17,15 +16,21 @@ userStore.subscribe((user) => {
localStorage.setItem(UserKey, JSON.stringify(user))
})
addMessageListener('logged-in', (e) => {
addMessageListener('auth:logged-in', (e) => {
userStore.setState({
userId: e.userId,
username: e.username,
sessionToken: e.sessionToken,
})
})
addMessageListener('logged-out', () => {
addMessageListener('auth:registered', (e) => {
userStore.setState({
userId: e.userId,
username: e.username,
})
})
addMessageListener('auth:logged-out', () => {
userStore.setState(null)
})

View file

@ -1,40 +0,0 @@
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) {
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) {
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,4 +1,4 @@
import { useUser } from '../store/userStore'
import { useUser } from '../app/user/userStore'
import NavLinkButton from './NavLinkButton'
export default function NavBar() {

View file

@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react'
import { Link } from 'react-router'
import { Link } from 'react-router-dom'
interface NavLinkButtonProps {
to: string
}

View file

@ -18,11 +18,7 @@ export default function SecondaryButton({
type={type}
disabled={disabled}
onClick={onClick}
className={`
px-4 p-2 rounded-md
text-primary-500 hover:text-primary-700
cursor-pointer disabled:cursor-default
${extraClasses}
className={`secondary-button ${extraClasses}
`}
>
{children}

View file

@ -0,0 +1,18 @@
import { PropsWithChildren } from 'react'
interface SecondaryLinkButtonProps {
href: string
className?: string
}
export default function SecondaryLinkButton({
href,
className: extraClasses = '',
children,
}: PropsWithChildren<SecondaryLinkButtonProps>) {
return (
<a href={href} className={`secondary-button text-center ${extraClasses}`}>
{children}
</a>
)
}

View file

@ -0,0 +1,19 @@
import { PropsWithChildren } from 'react'
import { Link } from 'react-router-dom'
interface SecondaryNavButtonProps {
to: string
className?: string
}
export default function SecondaryNavButton({
to,
className: extraClasses = '',
children,
}: PropsWithChildren<SecondaryNavButtonProps>) {
return (
<Link to={to} className={`secondary-button text-center ${extraClasses}`}>
{children}
</Link>
)
}

View file

@ -1,37 +0,0 @@
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 this.api.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<Post[]> {
const result = await this.api.loadPublicFeed(cursor, amount, null)
return result.posts.map((post) => Post.fromDto(post))
}
async loadByAuthor(
username: string,
cursor: string | null,
amount: number | null,
): Promise<Post[]> {
const result = await this.api.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
}

View file

@ -0,0 +1,7 @@
import { MessageTypes } from '../app/messageBus/messageTypes.ts'
import { addMessageListener, Listener } from '../app/messageBus/messageBus.ts'
import { useEffect } from 'react'
export function useMessageListener<E extends keyof MessageTypes>(e: E, listener: Listener<E>) {
useEffect(() => addMessageListener(e, listener), [e, listener])
}

View file

@ -68,4 +68,21 @@
opacity: 50%;
cursor: default;
}
.secondary-button {
padding: var(--spacing-2) var(--spacing-4);
background: var(--color-white);
color: var(--color-primary-500);
cursor: pointer;
border-radius: var(--radius-md);
}
.secondary-button:hover {
color: var(--color-primary-700);
}
.secondary-button:disabled {
opacity: 50%;
cursor: default;
}
}

View file

@ -1,12 +0,0 @@
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

@ -1,9 +0,0 @@
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 this.api.uploadMedia(file)
return { mediaId, url: new URL(url) }
}
}