some change
This commit is contained in:
parent
d4a1492d56
commit
313f1def49
38 changed files with 475 additions and 401 deletions
18
src/app/api/client.ts
Normal file
18
src/app/api/client.ts
Normal 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
|
354
src/app/api/schema.ts
Normal file
354
src/app/api/schema.ts
Normal file
|
@ -0,0 +1,354 @@
|
|||
export interface paths {
|
||||
'/posts': {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
From?: string
|
||||
Amount?: number
|
||||
AuthorId?: string
|
||||
Author?: string
|
||||
}
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
requestBody?: never
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'text/plain': components['schemas']['GetAllPublicPostsResponse']
|
||||
'application/json': components['schemas']['GetAllPublicPostsResponse']
|
||||
'text/json': components['schemas']['GetAllPublicPostsResponse']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
put?: never
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['CreatePostRequest']
|
||||
'text/json': components['schemas']['CreatePostRequest']
|
||||
'application/*+json': components['schemas']['CreatePostRequest']
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'text/plain': components['schemas']['CreatePostResponse']
|
||||
'application/json': components['schemas']['CreatePostResponse']
|
||||
'text/json': components['schemas']['CreatePostResponse']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
delete?: never
|
||||
options?: never
|
||||
head?: never
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/media': {
|
||||
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: {
|
||||
'multipart/form-data': {
|
||||
/** Format: binary */
|
||||
file?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'text/plain': components['schemas']['UploadMediaResponse']
|
||||
'application/json': components['schemas']['UploadMediaResponse']
|
||||
'text/json': components['schemas']['UploadMediaResponse']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
delete?: never
|
||||
options?: never
|
||||
head?: never
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
[path: `/media/${string}`]: {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path: {
|
||||
id: string
|
||||
}
|
||||
cookie?: never
|
||||
}
|
||||
requestBody?: never
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
}
|
||||
}
|
||||
put?: never
|
||||
post?: never
|
||||
delete?: never
|
||||
options?: never
|
||||
head?: never
|
||||
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/register': {
|
||||
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']['RegisterRequest']
|
||||
'text/json': components['schemas']['RegisterRequest']
|
||||
'application/*+json': components['schemas']['RegisterRequest']
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'text/plain': components['schemas']['RegisterResponse']
|
||||
'application/json': components['schemas']['RegisterResponse']
|
||||
'text/json': components['schemas']['RegisterResponse']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
delete?: never
|
||||
options?: never
|
||||
head?: never
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/auth/session': {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
get?: never
|
||||
put?: never
|
||||
post?: never
|
||||
delete: {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
requestBody?: never
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
}
|
||||
}
|
||||
options?: never
|
||||
head?: never
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
}
|
||||
export type webhooks = Record<string, never>
|
||||
export interface components {
|
||||
schemas: {
|
||||
CreatePostRequest: {
|
||||
/** Format: uuid */
|
||||
authorId: string
|
||||
content: string
|
||||
media: components['schemas']['CreatePostRequestMedia'][]
|
||||
}
|
||||
CreatePostRequestMedia: {
|
||||
/** Format: uuid */
|
||||
mediaId: string
|
||||
/** Format: uri */
|
||||
url: string
|
||||
type: string | null
|
||||
/** Format: int32 */
|
||||
width: number | null
|
||||
/** Format: int32 */
|
||||
height: number | null
|
||||
}
|
||||
CreatePostResponse: {
|
||||
/** Format: uuid */
|
||||
postId: string
|
||||
}
|
||||
GetAllPublicPostsResponse: {
|
||||
posts: components['schemas']['PublicPostDto'][]
|
||||
/** Format: uuid */
|
||||
next: string | null
|
||||
}
|
||||
LoginRequest: {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
LoginResponse: {
|
||||
/** Format: uuid */
|
||||
userId: string
|
||||
username: string
|
||||
}
|
||||
PublicPostAuthorDto: {
|
||||
/** Format: uuid */
|
||||
authorId: string
|
||||
username: string
|
||||
}
|
||||
PublicPostDto: {
|
||||
author: components['schemas']['PublicPostAuthorDto']
|
||||
/** Format: uuid */
|
||||
postId: string
|
||||
content: string
|
||||
media: components['schemas']['PublicPostMediaDto'][]
|
||||
/** Format: date-time */
|
||||
createdAt: string
|
||||
}
|
||||
PublicPostMediaDto: {
|
||||
/** Format: uri */
|
||||
url: string
|
||||
/** Format: int32 */
|
||||
width: number | null
|
||||
/** Format: int32 */
|
||||
height: number | null
|
||||
}
|
||||
RegisterRequest: {
|
||||
username: string
|
||||
password: string
|
||||
signupCode: string
|
||||
email: string | null
|
||||
}
|
||||
RegisterResponse: {
|
||||
/** Format: uuid */
|
||||
userId: string
|
||||
username: string
|
||||
}
|
||||
UploadMediaResponse: {
|
||||
/** Format: uuid */
|
||||
mediaId: string
|
||||
url: string
|
||||
}
|
||||
}
|
||||
responses: never
|
||||
parameters: never
|
||||
requestBodies: never
|
||||
headers: never
|
||||
pathItems: never
|
||||
}
|
||||
export type $defs = Record<string, never>
|
||||
export type operations = Record<string, never>
|
51
src/app/auth/authService.ts
Normal file
51
src/app/auth/authService.ts
Normal 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)
|
||||
}
|
||||
}
|
13
src/app/auth/components/Protected.tsx
Normal file
13
src/app/auth/components/Protected.tsx
Normal 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 />
|
||||
}
|
17
src/app/auth/components/UnauthorizedHandler.tsx
Normal file
17
src/app/auth/components/UnauthorizedHandler.tsx
Normal 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}</>
|
||||
}
|
91
src/app/auth/pages/LoginPage.tsx
Normal file
91
src/app/auth/pages/LoginPage.tsx
Normal file
|
@ -0,0 +1,91 @@
|
|||
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-dom'
|
||||
import SecondaryNavButton from '../../../components/SecondaryNavButton.tsx'
|
||||
|
||||
interface LoginPageProps {
|
||||
authService: AuthService
|
||||
}
|
||||
|
||||
export default function LoginPage({ authService }: LoginPageProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<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()
|
||||
|
||||
if (!username) {
|
||||
setError("you didn't username D:<")
|
||||
return
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setError("you didn't password D:<")
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
await authService.login(username, password)
|
||||
navigate('/')
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'something went terribly wrong')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SingleColumnLayout>
|
||||
<main className="w-full mx-auto p-4">
|
||||
<div className="mt-12">
|
||||
<form className="flex flex-col gap-4 max-w-md" onSubmit={onSubmit}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="username" className="text-sm text-gray-600">
|
||||
username
|
||||
</label>
|
||||
<TextInput
|
||||
ref={usernameInputRef}
|
||||
id="username"
|
||||
value={username}
|
||||
onInput={setUsername}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="password" className="text-sm text-gray-600">
|
||||
password
|
||||
</label>
|
||||
<TextInput
|
||||
ref={passwordInputRef}
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onInput={setPassword}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PrimaryButton className="mt-4" disabled={isSubmitting} type="submit">
|
||||
{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>
|
||||
</main>
|
||||
</SingleColumnLayout>
|
||||
)
|
||||
}
|
22
src/app/auth/pages/LogoutPage.tsx
Normal file
22
src/app/auth/pages/LogoutPage.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
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 () => {
|
||||
navigate('/login')
|
||||
await authService.logout()
|
||||
})
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}, [authService, navigate])
|
||||
|
||||
return <></>
|
||||
}
|
214
src/app/auth/pages/SignupPage.tsx
Normal file
214
src/app/auth/pages/SignupPage.tsx
Normal file
|
@ -0,0 +1,214 @@
|
|||
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 SecondaryNavButton from '../../../components/SecondaryNavButton.tsx'
|
||||
|
||||
const SignupCodeKey = 'signupCode'
|
||||
|
||||
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)
|
||||
|
||||
const [username, setUsername, usernameError, validateUsername] =
|
||||
useValidatedInput(isValidUsername)
|
||||
|
||||
const [password, setPassword, passwordError, validatePassword] =
|
||||
useValidatedInput(isValidPassword)
|
||||
|
||||
const userNameInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const passwordInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null)
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (signupCode) return
|
||||
|
||||
let theSignupCode: string | null
|
||||
if (code) {
|
||||
theSignupCode = code
|
||||
setSignupCode(theSignupCode)
|
||||
localStorage.setItem(SignupCodeKey, theSignupCode)
|
||||
} else {
|
||||
theSignupCode = localStorage.getItem(SignupCodeKey)
|
||||
}
|
||||
|
||||
if (!theSignupCode) {
|
||||
dialogRef.current?.showModal()
|
||||
}
|
||||
}, [code, signupCode])
|
||||
|
||||
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!signupCode) {
|
||||
throw new Error("there's no code")
|
||||
}
|
||||
|
||||
const isUsernameValid = validateUsername()
|
||||
const isPasswordValid = validatePassword()
|
||||
|
||||
if (!isPasswordValid) {
|
||||
passwordInputRef.current?.focus()
|
||||
}
|
||||
|
||||
if (!isUsernameValid) {
|
||||
userNameInputRef.current?.focus()
|
||||
}
|
||||
|
||||
if (!isUsernameValid || !isPasswordValid) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
await authService.signup(username, password, signupCode)
|
||||
navigate('/')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SingleColumnLayout>
|
||||
<main className="w-full mx-auto p-4">
|
||||
<div className="mt-12">
|
||||
<form className="flex flex-col gap-4 max-w-md" onSubmit={onSubmit}>
|
||||
<FormInput
|
||||
id="username"
|
||||
value={username}
|
||||
onInput={setUsername}
|
||||
error={usernameError}
|
||||
ref={userNameInputRef}
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
id="password"
|
||||
value={password}
|
||||
onInput={setPassword}
|
||||
error={passwordError}
|
||||
type="password"
|
||||
ref={passwordInputRef}
|
||||
/>
|
||||
<PrimaryButton
|
||||
className="mt-4"
|
||||
disabled={isSubmitting || !!usernameError || !!passwordError}
|
||||
type="submit"
|
||||
>
|
||||
{isSubmitting ? 'wait...' : 'give me an account pls'}
|
||||
</PrimaryButton>
|
||||
<SecondaryNavButton to={'/login'}>login instead?</SecondaryNavButton>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<dialog
|
||||
id="go-away-dialog"
|
||||
ref={dialogRef}
|
||||
className="p-6 rounded-lg shadow-lg m-auto outline-none"
|
||||
>
|
||||
<div className="text-gray-600 flex flex-col gap-2">
|
||||
<h1 className={`font-bold text-lg`}>STOP !!!</h1>
|
||||
<p>You need an invitation to sign up</p>
|
||||
<p>
|
||||
I'm surprised you even found your way here without one and honestly I'd prefer it if you
|
||||
would leave
|
||||
</p>
|
||||
<p>
|
||||
If you <span className="italic">do</span> want to create an account, you should know who
|
||||
to contact
|
||||
</p>
|
||||
<PrimaryLinkButton className={`mt-4`} href="https://en.wikipedia.org/wiki/Special:Random">
|
||||
I'm sorry I'll go somewhere else :(
|
||||
</PrimaryLinkButton>
|
||||
</div>
|
||||
</dialog>
|
||||
</SingleColumnLayout>
|
||||
)
|
||||
}
|
||||
|
||||
interface FormInputProps {
|
||||
id: string
|
||||
value: string
|
||||
onInput: (value: string) => void
|
||||
error: string | null
|
||||
type?: 'text' | 'password'
|
||||
ref: Ref<HTMLInputElement>
|
||||
}
|
||||
|
||||
function FormInput({ id, value, onInput, error, type = 'text', ref }: FormInputProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor={id} className="text-sm text-gray-600">
|
||||
{id}
|
||||
</label>
|
||||
<TextInput ref={ref} type={type} id={id} value={value} onInput={onInput} />
|
||||
<span className="text-xs h-3 text-red-500">{error}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type UseValidateInputReturn = [string, (value: string) => void, string | null, () => boolean]
|
||||
|
||||
function useValidatedInput(validator: (value: string) => Validation): UseValidateInputReturn {
|
||||
const [value, setValue] = useState<string>('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [keepValidating, setKeepValidating] = useState(false)
|
||||
|
||||
const validate = useCallback(() => {
|
||||
const { isValid, error } = validator(value)
|
||||
if (isValid) {
|
||||
setError(null)
|
||||
} else {
|
||||
// We only want to validate on input after they have invalidly submitted once.
|
||||
// It's annoying if we set error messages before they've even finished typing.
|
||||
setKeepValidating(true)
|
||||
setError(error)
|
||||
}
|
||||
|
||||
return isValid
|
||||
}, [validator, value])
|
||||
|
||||
useEffect(() => {
|
||||
if (keepValidating) {
|
||||
validate()
|
||||
}
|
||||
}, [keepValidating, validate])
|
||||
|
||||
return [value, setValue, error, validate]
|
||||
}
|
||||
|
||||
function isValidUsername(username: string): Validation {
|
||||
if (!username) return invalid('you need to enter a username :/')
|
||||
|
||||
if (username.length < 3) {
|
||||
return invalid('not long enough :(')
|
||||
}
|
||||
|
||||
const usernameRegex = /^[a-zA-Z0-9_-]+$/
|
||||
if (usernameRegex.test(username)) {
|
||||
return valid()
|
||||
} else {
|
||||
return invalid("that's not a good username :'(")
|
||||
}
|
||||
}
|
||||
|
||||
function isValidPassword(password: string): Validation {
|
||||
if (password.length >= 6) {
|
||||
return valid()
|
||||
} else {
|
||||
return invalid("that isn't a good password :/")
|
||||
}
|
||||
}
|
36
src/app/feed/components/FeedView.ts
Normal file
36
src/app/feed/components/FeedView.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { useCallback, useRef, useState } from 'react'
|
||||
import { Post } from '../posts/posts.ts'
|
||||
|
||||
const PageSize = 20
|
||||
|
||||
export function useFeedViewModel(
|
||||
loadMore: (cursor: string | null, amount: number) => Promise<Post[]>,
|
||||
) {
|
||||
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 || error) return
|
||||
loading.current = true
|
||||
|
||||
try {
|
||||
const delay = new Promise((resolve) => setTimeout(resolve, 500))
|
||||
const pagePromise = loadMore(cursor.current, PageSize)
|
||||
const [page] = await Promise.all([pagePromise, delay])
|
||||
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, error])
|
||||
|
||||
return { pages, setPages, loadNextPage, error } as const
|
||||
}
|
29
src/app/feed/components/FeedView.tsx
Normal file
29
src/app/feed/components/FeedView.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { useRef } from 'react'
|
||||
import { useIntersectionLoad } from '../../../hooks/useIntersectionLoad.ts'
|
||||
import { Post } from '../posts/posts.ts'
|
||||
import PostItem from './PostItem.tsx'
|
||||
|
||||
interface FeedViewProps {
|
||||
pages: Post[][]
|
||||
onLoadMore: () => Promise<void>
|
||||
}
|
||||
|
||||
export default function FeedView({ pages, onLoadMore }: FeedViewProps) {
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||
const posts = pages.flat()
|
||||
|
||||
useIntersectionLoad(onLoadMore, sentinelRef)
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
{posts.map((post) => (
|
||||
<PostItem key={post.postId} post={post} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
</div>
|
||||
)
|
||||
}
|
69
src/app/feed/components/PostItem.tsx
Normal file
69
src/app/feed/components/PostItem.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { Post, PostMedia } from '../posts/posts.ts'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface PostItemProps {
|
||||
post: Post
|
||||
}
|
||||
|
||||
export default function PostItem({ post }: PostItemProps) {
|
||||
const formattedDate = post.createdAt.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setVisible(true))
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const opacity = visible ? 'opacity-100' : 'opacity-0'
|
||||
|
||||
return (
|
||||
<article className={`w-full p-4 ${opacity} transition-opacity duration-500`} key={post.postId}>
|
||||
<div className="text-sm text-gray-500 mb-3">
|
||||
<Link to={`/u/${post.authorName}`} className="text-gray-400 hover:underline mr-2">
|
||||
@{post.authorName}
|
||||
</Link>
|
||||
• {formattedDate}
|
||||
</div>
|
||||
|
||||
<div className="text-gray-800 mb-4 whitespace-pre-wrap">{post.content}</div>
|
||||
|
||||
{post.media.length > 0 && (
|
||||
<div className="grid gap-4 grid-cols-1">
|
||||
{post.media.map((media) => (
|
||||
<PostMediaItem key={media.url.toString()} media={media} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
interface PostMediaProps {
|
||||
media: PostMedia
|
||||
}
|
||||
|
||||
function PostMediaItem({ media }: PostMediaProps) {
|
||||
const url = media.url.toString()
|
||||
const width = media.width ?? undefined
|
||||
const height = media.height ?? undefined
|
||||
return (
|
||||
<img
|
||||
width={width}
|
||||
height={height}
|
||||
src={url}
|
||||
alt="todo sry :("
|
||||
className="w-full h-auto"
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
}
|
30
src/app/feed/pages/AuthorPage.tsx
Normal file
30
src/app/feed/pages/AuthorPage.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { useCallback } from 'react'
|
||||
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
|
||||
}
|
||||
|
||||
export default function AuthorPage({ postsService }: AuthorPageParams) {
|
||||
const { username } = useParams()
|
||||
|
||||
const fetchPosts = useCallback(
|
||||
async (cursor: string | null, amount: number | null) => {
|
||||
return postsService.loadByAuthor(username!, cursor, amount)
|
||||
},
|
||||
[postsService, username],
|
||||
)
|
||||
|
||||
const { pages, loadNextPage } = useFeedViewModel(fetchPosts)
|
||||
|
||||
return (
|
||||
<SingleColumnLayout navbar={<NavBar />}>
|
||||
<FeedView pages={pages} onLoadMore={loadNextPage} />
|
||||
</SingleColumnLayout>
|
||||
)
|
||||
}
|
70
src/app/feed/pages/HomePage.tsx
Normal file
70
src/app/feed/pages/HomePage.tsx
Normal file
|
@ -0,0 +1,70 @@
|
|||
import { useCallback, useState } from 'react'
|
||||
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'
|
||||
|
||||
interface HomePageProps {
|
||||
postsService: PostsService
|
||||
mediaService: MediaService
|
||||
}
|
||||
|
||||
export default function HomePage({ postsService, mediaService }: HomePageProps) {
|
||||
const [user] = useUser()
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const fetchPosts = useCallback(
|
||||
async (cursor: string | null, amount: number | null) => {
|
||||
return postsService.loadPublicFeed(cursor, amount)
|
||||
},
|
||||
[postsService],
|
||||
)
|
||||
|
||||
const { pages, setPages, loadNextPage } = useFeedViewModel(fetchPosts)
|
||||
|
||||
const onCreatePost = useCallback(
|
||||
async (content: string, files: { file: File; width: number; height: number }[]) => {
|
||||
setIsSubmitting(true)
|
||||
if (user == null) throw new Error('Not logged in')
|
||||
try {
|
||||
const media = await Promise.all(
|
||||
files.map(async ({ file, width, height }) => {
|
||||
console.debug('do mediaService.uploadFile', file, 'width', width, 'height', height)
|
||||
const { mediaId, url } = await mediaService.uploadFile(file)
|
||||
|
||||
return {
|
||||
mediaId,
|
||||
url,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const postId = await postsService.createNew(user.userId, content, media)
|
||||
const post = new Post(postId, content, media, Temporal.Now.instant(), user.username)
|
||||
setPages((pages) => [[post], ...pages])
|
||||
} catch (error) {
|
||||
console.error('Failed to create post:', error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
},
|
||||
[mediaService, postsService, setPages, user],
|
||||
)
|
||||
|
||||
return (
|
||||
<SingleColumnLayout navbar={<NavBar />}>
|
||||
<main className={`w-full max-w-3xl mx-auto`}>
|
||||
<NewPostWidget onSubmit={onCreatePost} isSubmitting={isSubmitting} />
|
||||
<FeedView pages={pages} onLoadMore={loadNextPage} />
|
||||
</main>
|
||||
</SingleColumnLayout>
|
||||
)
|
||||
}
|
49
src/app/feed/posts/posts.ts
Normal file
49
src/app/feed/posts/posts.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { Temporal } from '@js-temporal/polyfill'
|
||||
import { components } from '../../api/schema.ts'
|
||||
|
||||
export class Post {
|
||||
public readonly postId: string
|
||||
public readonly content: string
|
||||
public readonly media: PostMedia[]
|
||||
public readonly createdAt: Temporal.Instant
|
||||
public readonly authorName: string
|
||||
|
||||
constructor(
|
||||
postId: string,
|
||||
content: string,
|
||||
media: PostMedia[],
|
||||
createdAt: string | Temporal.Instant,
|
||||
authorName: string,
|
||||
) {
|
||||
this.postId = postId
|
||||
this.content = content
|
||||
this.media = media
|
||||
this.createdAt = Temporal.Instant.from(createdAt)
|
||||
this.authorName = authorName
|
||||
}
|
||||
|
||||
public static fromDto(dto: components['schemas']['PublicPostDto']): Post {
|
||||
console.debug('make post', dto)
|
||||
return new Post(
|
||||
dto.postId,
|
||||
dto.content,
|
||||
dto.media.map((m) => new PostMediaImpl(new URL(m.url), m.width, m.height)),
|
||||
Temporal.Instant.from(dto.createdAt),
|
||||
dto.author.username,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export interface PostMedia {
|
||||
readonly url: URL
|
||||
readonly width: number | null
|
||||
readonly height: number | null
|
||||
}
|
||||
|
||||
class PostMediaImpl implements PostMedia {
|
||||
constructor(
|
||||
readonly url: URL,
|
||||
readonly width: number | null,
|
||||
readonly height: number | null,
|
||||
) {}
|
||||
}
|
62
src/app/feed/posts/postsService.ts
Normal file
62
src/app/feed/posts/postsService.ts
Normal 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
|
||||
}
|
22
src/app/media/mediaService.ts
Normal file
22
src/app/media/mediaService.ts
Normal 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) }
|
||||
}
|
||||
}
|
28
src/app/messageBus/messageBus.ts
Normal file
28
src/app/messageBus/messageBus.ts
Normal 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 }))
|
||||
}
|
12
src/app/messageBus/messageTypes.ts
Normal file
12
src/app/messageBus/messageTypes.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
export interface MessageTypes {
|
||||
'auth:logged-in': {
|
||||
userId: string
|
||||
username: string
|
||||
}
|
||||
'auth:registered': {
|
||||
userId: string
|
||||
username: string
|
||||
}
|
||||
'auth:logged-out': null
|
||||
'auth:unauthorized': null
|
||||
}
|
46
src/app/user/userStore.ts
Normal file
46
src/app/user/userStore.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { createStore, Store, useStore } from '../../utils/store.ts'
|
||||
import { addMessageListener } from '../messageBus/messageBus.ts'
|
||||
|
||||
export interface User {
|
||||
userId: string
|
||||
username: string
|
||||
}
|
||||
|
||||
export type UserStore = Store<User | null>
|
||||
|
||||
const UserKey = 'user'
|
||||
|
||||
export const userStore = createStore<User | null>(loadStoredUser())
|
||||
|
||||
userStore.subscribe((user) => {
|
||||
localStorage.setItem(UserKey, JSON.stringify(user))
|
||||
})
|
||||
|
||||
addMessageListener('auth:logged-in', (e) => {
|
||||
userStore.setState({
|
||||
userId: e.userId,
|
||||
username: e.username,
|
||||
})
|
||||
})
|
||||
|
||||
addMessageListener('auth:registered', (e) => {
|
||||
userStore.setState({
|
||||
userId: e.userId,
|
||||
username: e.username,
|
||||
})
|
||||
})
|
||||
|
||||
addMessageListener('auth: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
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue