some change
This commit is contained in:
parent
d4a1492d56
commit
313f1def49
38 changed files with 475 additions and 401 deletions
|
@ -14,15 +14,17 @@
|
|||
"@js-temporal/polyfill": "^0.5.1",
|
||||
"@tailwindcss/vite": "^4.1.5",
|
||||
"immer": "^10.1.1",
|
||||
"openapi-fetch": "^0.14.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router": "^7.5.3",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"tailwindcss": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
|
|
29
scripts/generate-client.mjs
Normal file
29
scripts/generate-client.mjs
Normal file
|
@ -0,0 +1,29 @@
|
|||
import openapiTS, { astToString } from 'openapi-typescript'
|
||||
import prettier from 'prettier'
|
||||
import path from 'path'
|
||||
import fs from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const { format, resolveConfig } = prettier
|
||||
/**
|
||||
* @param openapiUrl {string}
|
||||
* @param outputFilePath {string}
|
||||
* @param pathToPrettierRc {string}
|
||||
*/
|
||||
export async function generateApiClient(openapiUrl, outputFilePath, pathToPrettierRc) {
|
||||
const request = new Request(openapiUrl)
|
||||
const response = await fetch(request)
|
||||
const json = await response.text()
|
||||
const ast = await openapiTS(json, {})
|
||||
const prettierConfig = await resolveConfig(pathToPrettierRc, {
|
||||
useCache: true,
|
||||
})
|
||||
let schemaCode = astToString(ast)
|
||||
schemaCode = await format(schemaCode, { parser: 'typescript', ...prettierConfig })
|
||||
await fs.writeFile(path.join(outputFilePath), schemaCode)
|
||||
}
|
||||
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
if (!process.env.OPENAPI_URL) throw new Error('OPENAPI_URL is not defined')
|
||||
await generateApiClient(process.env.OPENAPI_URL, './src/app/api/schema.ts', './.prettierrc')
|
||||
}
|
|
@ -14,7 +14,9 @@ export async function generateApiSchema(openapiUrl, outputFilePath, pathToPretti
|
|||
const request = new Request(openapiUrl)
|
||||
const response = await fetch(request)
|
||||
const json = await response.text()
|
||||
const ast = await openapiTS(json, {})
|
||||
const ast = await openapiTS(json, {
|
||||
pathParamsAsTypes: true,
|
||||
})
|
||||
const prettierConfig = await resolveConfig(pathToPrettierRc, {
|
||||
useCache: true,
|
||||
})
|
||||
|
@ -25,5 +27,5 @@ export async function generateApiSchema(openapiUrl, outputFilePath, pathToPretti
|
|||
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
if (!process.env.OPENAPI_URL) throw new Error('OPENAPI_URL is not defined')
|
||||
await generateApiSchema(process.env.OPENAPI_URL, './src/api/schema.ts', './.prettierrc')
|
||||
await generateApiSchema(process.env.OPENAPI_URL, './src/app/api/schema.ts', './.prettierrc')
|
||||
}
|
||||
|
|
54
src/App.tsx
54
src/App.tsx
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
154
src/api/api.ts
154
src/api/api.ts
|
@ -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
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
|
|
@ -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 */
|
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}</>
|
||||
}
|
|
@ -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>
|
|
@ -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)
|
|
@ -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 :/")
|
|
@ -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
|
||||
}
|
|
@ -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 {
|
|
@ -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 {
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
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 }))
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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', {})
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { useUser } from '../store/userStore'
|
||||
import { useUser } from '../app/user/userStore'
|
||||
import NavLinkButton from './NavLinkButton'
|
||||
|
||||
export default function NavBar() {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { PropsWithChildren } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { Link } from 'react-router-dom'
|
||||
interface NavLinkButtonProps {
|
||||
to: string
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
18
src/components/SecondaryLinkButton.tsx
Normal file
18
src/components/SecondaryLinkButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
19
src/components/SecondaryNavButton.tsx
Normal file
19
src/components/SecondaryNavButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
7
src/hooks/useMessageListener.ts
Normal file
7
src/hooks/useMessageListener.ts
Normal 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])
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 }))
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
|
@ -20,7 +20,8 @@
|
|||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
62
yarn.lock
62
yarn.lock
|
@ -768,6 +768,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
|
||||
integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
|
||||
|
||||
"@types/history@^4.7.11":
|
||||
version "4.7.11"
|
||||
resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.11.tgz#56588b17ae8f50c53983a524fc3cc47437969d64"
|
||||
integrity sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==
|
||||
|
||||
"@types/json-schema@^7.0.15":
|
||||
version "7.0.15"
|
||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
|
||||
|
@ -778,6 +783,30 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.3.tgz#3f0c60804441bf34d19f8dd0d44405c0c0e21bfa"
|
||||
integrity sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==
|
||||
|
||||
"@types/react-router-dom@^5.3.3":
|
||||
version "5.3.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.3.3.tgz#e9d6b4a66fcdbd651a5f106c2656a30088cc1e83"
|
||||
integrity sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==
|
||||
dependencies:
|
||||
"@types/history" "^4.7.11"
|
||||
"@types/react" "*"
|
||||
"@types/react-router" "*"
|
||||
|
||||
"@types/react-router@*":
|
||||
version "5.1.20"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.20.tgz#88eccaa122a82405ef3efbcaaa5dcdd9f021387c"
|
||||
integrity sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==
|
||||
dependencies:
|
||||
"@types/history" "^4.7.11"
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*":
|
||||
version "19.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.4.tgz#4d125f014d6ac26b4759775698db118701e314fe"
|
||||
integrity sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g==
|
||||
dependencies:
|
||||
csstype "^3.0.2"
|
||||
|
||||
"@types/react@^19.0.10":
|
||||
version "19.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.2.tgz#11df86f66f188f212c90ecb537327ec68bfd593f"
|
||||
|
@ -1930,6 +1959,18 @@ once@^1.4.0:
|
|||
dependencies:
|
||||
wrappy "1"
|
||||
|
||||
openapi-fetch@^0.14.0:
|
||||
version "0.14.0"
|
||||
resolved "https://registry.yarnpkg.com/openapi-fetch/-/openapi-fetch-0.14.0.tgz#4f87d867cb91edf0b63acb7e5eaf366517dcb545"
|
||||
integrity sha512-PshIdm1NgdLvb05zp8LqRQMNSKzIlPkyMxYFxwyHR+UlKD4t2nUjkDhNxeRbhRSEd3x5EUNh2w5sJYwkhOH4fg==
|
||||
dependencies:
|
||||
openapi-typescript-helpers "^0.0.15"
|
||||
|
||||
openapi-typescript-helpers@^0.0.15:
|
||||
version "0.0.15"
|
||||
resolved "https://registry.yarnpkg.com/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz#96ffa762a5e01ef66a661b163d5f1109ed1967ed"
|
||||
integrity sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==
|
||||
|
||||
openapi-typescript@^7.6.1:
|
||||
version "7.6.1"
|
||||
resolved "https://registry.yarnpkg.com/openapi-typescript/-/openapi-typescript-7.6.1.tgz#e39d1e21ebf43f91712703f7063118246d099d19"
|
||||
|
@ -2100,14 +2141,20 @@ react-refresh@^0.17.0:
|
|||
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.17.0.tgz#b7e579c3657f23d04eccbe4ad2e58a8ed51e7e53"
|
||||
integrity sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==
|
||||
|
||||
react-router@^7.5.3:
|
||||
version "7.5.3"
|
||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.5.3.tgz#9e5420832af8c3690740c1797d4fa54613fea06d"
|
||||
integrity sha512-3iUDM4/fZCQ89SXlDa+Ph3MevBrozBAI655OAfWQlTm9nBR0IKlrmNwFow5lPHttbwvITZfkeeeZFP6zt3F7pw==
|
||||
react-router-dom@^7.6.0:
|
||||
version "7.6.0"
|
||||
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.6.0.tgz#eadcede43856dc714fa3572a946fd7502775c017"
|
||||
integrity sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==
|
||||
dependencies:
|
||||
react-router "7.6.0"
|
||||
|
||||
react-router@7.6.0:
|
||||
version "7.6.0"
|
||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.6.0.tgz#e2d0872d7bea8df79465a8bba9a20c87c32ce995"
|
||||
integrity sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==
|
||||
dependencies:
|
||||
cookie "^1.0.1"
|
||||
set-cookie-parser "^2.6.0"
|
||||
turbo-stream "2.4.0"
|
||||
|
||||
react@^19.0.0:
|
||||
version "19.1.0"
|
||||
|
@ -2357,11 +2404,6 @@ tslib@^2.4.0, tslib@^2.8.0:
|
|||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
||||
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
||||
|
||||
turbo-stream@2.4.0:
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/turbo-stream/-/turbo-stream-2.4.0.tgz#1e4fca6725e90fa14ac4adb782f2d3759a5695f0"
|
||||
integrity sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==
|
||||
|
||||
type-check@^0.4.0, type-check@~0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue