diff --git a/package.json b/package.json
index 7d69c88..0563151 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/scripts/generate-client.mjs b/scripts/generate-client.mjs
new file mode 100644
index 0000000..3dce7d0
--- /dev/null
+++ b/scripts/generate-client.mjs
@@ -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')
+}
diff --git a/scripts/generate-schema.mjs b/scripts/generate-schema.mjs
index 8c3c78b..4381bbf 100644
--- a/scripts/generate-schema.mjs
+++ b/scripts/generate-schema.mjs
@@ -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')
}
diff --git a/src/App.tsx b/src/App.tsx
index 057f804..bba7b0f 100644
--- a/src/App.tsx
+++ b/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 (
-
- }
- />
- } />
- } />
- } />
- } />
-
+
+
+ }>
+ }
+ />
+ } />
+
+ } />
+ } />
+ } />
+
+
)
}
diff --git a/src/api/api.ts b/src/api/api.ts
deleted file mode 100644
index 8d89fdf..0000000
--- a/src/api/api.ts
+++ /dev/null
@@ -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
-
- uploadMedia(file: File): Promise
-
- createPost(
- authorId: string,
- content: string,
- media: components['schemas']['CreatePostRequestMedia'][],
- ): Promise
-}
-
-export interface AuthApi {
- login(username: string, password: string): Promise
-
- signup(
- username: string,
- password: string,
- signupCode: string,
- email: string | null,
- ): Promise
-
- deleteSession(sessionToken: string): Promise
-}
-
-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 {
- const url = new URL(`posts`, this.apiHost)
- if (amount != null) url.searchParams.set('amount', amount.toString())
- if (cursor != null) url.searchParams.set('from', cursor)
- if (author != null) url.searchParams.set('author', author)
-
- const res = await doGetRequest(url)
- return res as components['schemas']['GetAllPublicPostsResponse']
- }
-
- async uploadMedia(file: File): Promise {
- const url = new URL('media', this.apiHost)
- const body = new FormData()
- body.append('file', file)
- const response = await fetch(
- new Request(url, {
- method: 'POST',
- body: body,
- }),
- )
-
- if (!response.ok) throw new Error(await response.text())
- return await response.json()
- }
-
- async createPost(
- authorId: string,
- content: string,
- media: components['schemas']['CreatePostRequestMedia'][],
- ): Promise {
- const url = new URL('posts', this.apiHost)
- const body: components['schemas']['CreatePostRequest'] = {
- authorId,
- content,
- media,
- }
-
- const res = await doPostRequest(url, body)
- return res as components['schemas']['CreatePostResponse']
- }
-}
-
-class AuthApiImpl implements AuthApi {
- constructor(private readonly apiHost: string) {}
- async login(username: string, password: string): Promise {
- const url = new URL('/auth/login', this.apiHost)
- const body: components['schemas']['LoginRequest'] = {
- username,
- password,
- }
- const res = await doPostRequest(url, body)
- return res as components['schemas']['LoginResponse']
- }
-
- async signup(
- username: string,
- password: string,
- signupCode: string,
- email: string | null,
- ): Promise {
- const url = new URL('/auth/signup', this.apiHost)
- const body: components['schemas']['SignupRequest'] = {
- username,
- password,
- signupCode,
- email,
- }
- const res = await doPostRequest(url, body)
- return res as components['schemas']['SignupResponse']
- }
-
- async deleteSession(sessionToken: string): Promise {
- const url = new URL('/auth/delete-session', this.apiHost)
- const body: components['schemas']['DeleteSessionRequest'] = {
- sessionToken,
- }
- await doPostRequest(url, body)
- }
-}
-
-async function doGetRequest(url: URL): Promise {
- 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 {
- 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()
-}
diff --git a/src/app/api/client.ts b/src/app/api/client.ts
new file mode 100644
index 0000000..095af42
--- /dev/null
+++ b/src/app/api/client.ts
@@ -0,0 +1,18 @@
+import { paths } from './schema.ts'
+import createClient, { Middleware } from 'openapi-fetch'
+import { dispatchMessage } from '../messageBus/messageBus.ts'
+
+const client = createClient({ 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
diff --git a/src/api/schema.ts b/src/app/api/schema.ts
similarity index 88%
rename from src/api/schema.ts
rename to src/app/api/schema.ts
index 2e3e150..fcbae1c 100644
--- a/src/api/schema.ts
+++ b/src/app/api/schema.ts
@@ -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 */
diff --git a/src/app/auth/authService.ts b/src/app/auth/authService.ts
new file mode 100644
index 0000000..3668822
--- /dev/null
+++ b/src/app/auth/authService.ts
@@ -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)
+ }
+}
diff --git a/src/app/auth/components/Protected.tsx b/src/app/auth/components/Protected.tsx
new file mode 100644
index 0000000..c875f0d
--- /dev/null
+++ b/src/app/auth/components/Protected.tsx
@@ -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
+}
diff --git a/src/app/auth/components/UnauthorizedHandler.tsx b/src/app/auth/components/UnauthorizedHandler.tsx
new file mode 100644
index 0000000..0c34d64
--- /dev/null
+++ b/src/app/auth/components/UnauthorizedHandler.tsx
@@ -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) {
+ const navigate = useNavigate()
+ useMessageListener('auth:unauthorized', async () => {
+ console.debug('unauth triggered')
+ navigate('/logout')
+ })
+
+ return <>{children}>
+}
diff --git a/src/auth/LoginPage.tsx b/src/app/auth/pages/LoginPage.tsx
similarity index 84%
rename from src/auth/LoginPage.tsx
rename to src/app/auth/pages/LoginPage.tsx
index f84f1a9..e4cc09b 100644
--- a/src/auth/LoginPage.tsx
+++ b/src/app/auth/pages/LoginPage.tsx
@@ -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'}
+ register instead?
+
{error}
diff --git a/src/auth/LogoutPage.tsx b/src/app/auth/pages/LogoutPage.tsx
similarity index 76%
rename from src/auth/LogoutPage.tsx
rename to src/app/auth/pages/LogoutPage.tsx
index 4a6650c..5311344 100644
--- a/src/auth/LogoutPage.tsx
+++ b/src/app/auth/pages/LogoutPage.tsx
@@ -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)
diff --git a/src/auth/SignupPage.tsx b/src/app/auth/pages/SignupPage.tsx
similarity index 80%
rename from src/auth/SignupPage.tsx
rename to src/app/auth/pages/SignupPage.tsx
index 755c49b..61ada31 100644
--- a/src/auth/SignupPage.tsx
+++ b/src/app/auth/pages/SignupPage.tsx
@@ -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(null)
- const emailInputRef = useRef(null)
const passwordInputRef = useRef(null)
const dialogRef = useRef(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}
/>
-
+
{isSubmitting ? 'wait...' : 'give me an account pls'}
+ login instead?
@@ -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 :/")
diff --git a/src/feed/FeedView.ts b/src/app/feed/components/FeedView.ts
similarity index 71%
rename from src/feed/FeedView.ts
rename to src/app/feed/components/FeedView.ts
index bad58e4..b79acb9 100644
--- a/src/feed/FeedView.ts
+++ b/src/app/feed/components/FeedView.ts
@@ -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([])
const [hasMore, setHasMore] = useState(true)
+ const [error, setError] = useState(null)
const cursor = useRef(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
}
diff --git a/src/feed/FeedView.tsx b/src/app/feed/components/FeedView.tsx
similarity index 85%
rename from src/feed/FeedView.tsx
rename to src/app/feed/components/FeedView.tsx
index e6d7ab6..d35256f 100644
--- a/src/feed/FeedView.tsx
+++ b/src/app/feed/components/FeedView.tsx
@@ -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 {
diff --git a/src/feed/PostItem.tsx b/src/app/feed/components/PostItem.tsx
similarity index 94%
rename from src/feed/PostItem.tsx
rename to src/app/feed/components/PostItem.tsx
index 4334e23..2a346d8 100644
--- a/src/feed/PostItem.tsx
+++ b/src/app/feed/components/PostItem.tsx
@@ -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 {
diff --git a/src/feed/AuthorPage.tsx b/src/app/feed/pages/AuthorPage.tsx
similarity index 64%
rename from src/feed/AuthorPage.tsx
rename to src/app/feed/pages/AuthorPage.tsx
index d9ec260..31705ec 100644
--- a/src/feed/AuthorPage.tsx
+++ b/src/app/feed/pages/AuthorPage.tsx
@@ -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
diff --git a/src/feed/HomePage.tsx b/src/app/feed/pages/HomePage.tsx
similarity index 79%
rename from src/feed/HomePage.tsx
rename to src/app/feed/pages/HomePage.tsx
index 61509a0..cbb106d 100644
--- a/src/feed/HomePage.tsx
+++ b/src/app/feed/pages/HomePage.tsx
@@ -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
diff --git a/src/feed/models/posts/posts.ts b/src/app/feed/posts/posts.ts
similarity index 95%
rename from src/feed/models/posts/posts.ts
rename to src/app/feed/posts/posts.ts
index c9323ba..dc58713 100644
--- a/src/feed/models/posts/posts.ts
+++ b/src/app/feed/posts/posts.ts
@@ -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
diff --git a/src/app/feed/posts/postsService.ts b/src/app/feed/posts/postsService.ts
new file mode 100644
index 0000000..5130a9e
--- /dev/null
+++ b/src/app/feed/posts/postsService.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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
+}
diff --git a/src/app/media/mediaService.ts b/src/app/media/mediaService.ts
new file mode 100644
index 0000000..c63c627
--- /dev/null
+++ b/src/app/media/mediaService.ts
@@ -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) }
+ }
+}
diff --git a/src/app/messageBus/messageBus.ts b/src/app/messageBus/messageBus.ts
new file mode 100644
index 0000000..c428cb8
--- /dev/null
+++ b/src/app/messageBus/messageBus.ts
@@ -0,0 +1,28 @@
+import { MessageTypes } from './messageTypes.ts'
+
+export type Listener = (
+ message: MessageTypes[E],
+) => void | Promise
+
+type Unlisten = () => void
+
+export function addMessageListener(
+ e: E,
+ listener: Listener,
+): 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: E, message: MessageTypes[E]) {
+ console.debug('dispatching message', e, message)
+ window.dispatchEvent(new CustomEvent(e, { detail: message }))
+}
diff --git a/src/messageBus/messageTypes.ts b/src/app/messageBus/messageTypes.ts
similarity index 54%
rename from src/messageBus/messageTypes.ts
rename to src/app/messageBus/messageTypes.ts
index 0e94833..3ecfdcb 100644
--- a/src/messageBus/messageTypes.ts
+++ b/src/app/messageBus/messageTypes.ts
@@ -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
}
diff --git a/src/store/userStore.ts b/src/app/user/userStore.ts
similarity index 63%
rename from src/store/userStore.ts
rename to src/app/user/userStore.ts
index ea56b34..237301b 100644
--- a/src/store/userStore.ts
+++ b/src/app/user/userStore.ts
@@ -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
@@ -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)
})
diff --git a/src/auth/authService.ts b/src/auth/authService.ts
deleted file mode 100644
index ea2f981..0000000
--- a/src/auth/authService.ts
+++ /dev/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', {})
- }
-}
diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx
index 8928779..cce9c9d 100644
--- a/src/components/NavBar.tsx
+++ b/src/components/NavBar.tsx
@@ -1,4 +1,4 @@
-import { useUser } from '../store/userStore'
+import { useUser } from '../app/user/userStore'
import NavLinkButton from './NavLinkButton'
export default function NavBar() {
diff --git a/src/components/NavLinkButton.tsx b/src/components/NavLinkButton.tsx
index f77f2d9..890aa16 100644
--- a/src/components/NavLinkButton.tsx
+++ b/src/components/NavLinkButton.tsx
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react'
-import { Link } from 'react-router'
+import { Link } from 'react-router-dom'
interface NavLinkButtonProps {
to: string
}
diff --git a/src/components/SecondaryButton.tsx b/src/components/SecondaryButton.tsx
index 3beafe7..24b80b2 100644
--- a/src/components/SecondaryButton.tsx
+++ b/src/components/SecondaryButton.tsx
@@ -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}
diff --git a/src/components/SecondaryLinkButton.tsx b/src/components/SecondaryLinkButton.tsx
new file mode 100644
index 0000000..1e77c0f
--- /dev/null
+++ b/src/components/SecondaryLinkButton.tsx
@@ -0,0 +1,18 @@
+import { PropsWithChildren } from 'react'
+
+interface SecondaryLinkButtonProps {
+ href: string
+ className?: string
+}
+
+export default function SecondaryLinkButton({
+ href,
+ className: extraClasses = '',
+ children,
+}: PropsWithChildren) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/SecondaryNavButton.tsx b/src/components/SecondaryNavButton.tsx
new file mode 100644
index 0000000..bd8f542
--- /dev/null
+++ b/src/components/SecondaryNavButton.tsx
@@ -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) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/feed/models/posts/postsService.ts b/src/feed/models/posts/postsService.ts
deleted file mode 100644
index e24aeca..0000000
--- a/src/feed/models/posts/postsService.ts
+++ /dev/null
@@ -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 {
- 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 {
- 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 {
- 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
-}
diff --git a/src/hooks/useMessageListener.ts b/src/hooks/useMessageListener.ts
new file mode 100644
index 0000000..8703922
--- /dev/null
+++ b/src/hooks/useMessageListener.ts
@@ -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: E, listener: Listener) {
+ useEffect(() => addMessageListener(e, listener), [e, listener])
+}
diff --git a/src/index.css b/src/index.css
index 7288ac8..34c2096 100644
--- a/src/index.css
+++ b/src/index.css
@@ -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;
+ }
}
\ No newline at end of file
diff --git a/src/messageBus/addMessageListener.ts b/src/messageBus/addMessageListener.ts
deleted file mode 100644
index 9023bfc..0000000
--- a/src/messageBus/addMessageListener.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { MessageTypes } from './messageTypes.ts'
-
-export function addMessageListener(
- e: E,
- listener: (message: MessageTypes[E]) => void,
-) {
- window.addEventListener(e, (event) => listener((event as CustomEvent).detail))
-}
-
-export function dispatchMessage(e: E, message: MessageTypes[E]) {
- window.dispatchEvent(new CustomEvent(e, { detail: message }))
-}
diff --git a/src/model/media/mediaService.ts b/src/model/media/mediaService.ts
deleted file mode 100644
index a0fd756..0000000
--- a/src/model/media/mediaService.ts
+++ /dev/null
@@ -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) }
- }
-}
diff --git a/src/store/store.ts b/src/utils/store.ts
similarity index 100%
rename from src/store/store.ts
rename to src/utils/store.ts
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 850fd66..4b41f47 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -20,7 +20,8 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
- "noUncheckedSideEffectImports": true
+ "noUncheckedSideEffectImports": true,
+ "noUncheckedIndexedAccess": true
},
"include": ["src"]
}
diff --git a/yarn.lock b/yarn.lock
index 4b81daf..c5454e1 100644
--- a/yarn.lock
+++ b/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"