From 313f1def49c4855f024eef9047687022a6119bb5 Mon Sep 17 00:00:00 2001 From: john Date: Fri, 16 May 2025 16:09:35 +0200 Subject: [PATCH] some change --- package.json | 4 +- scripts/generate-client.mjs | 29 ++++ scripts/generate-schema.mjs | 6 +- src/App.tsx | 54 +++--- src/api/api.ts | 154 ------------------ src/app/api/client.ts | 18 ++ src/{ => app}/api/schema.ts | 39 ++--- src/app/auth/authService.ts | 51 ++++++ src/app/auth/components/Protected.tsx | 13 ++ .../auth/components/UnauthorizedHandler.tsx | 17 ++ src/{auth => app/auth/pages}/LoginPage.tsx | 13 +- src/{auth => app/auth/pages}/LogoutPage.tsx | 6 +- src/{auth => app/auth/pages}/SignupPage.tsx | 48 ++---- src/{feed => app/feed/components}/FeedView.ts | 12 +- .../feed/components}/FeedView.tsx | 4 +- .../feed/components}/PostItem.tsx | 4 +- src/{feed => app/feed/pages}/AuthorPage.tsx | 12 +- src/{feed => app/feed/pages}/HomePage.tsx | 18 +- src/{feed/models => app/feed}/posts/posts.ts | 2 +- src/app/feed/posts/postsService.ts | 62 +++++++ src/app/media/mediaService.ts | 22 +++ src/app/messageBus/messageBus.ts | 28 ++++ src/{ => app}/messageBus/messageTypes.ts | 9 +- src/{store => app/user}/userStore.ts | 17 +- src/auth/authService.ts | 40 ----- src/components/NavBar.tsx | 2 +- src/components/NavLinkButton.tsx | 2 +- src/components/SecondaryButton.tsx | 6 +- src/components/SecondaryLinkButton.tsx | 18 ++ src/components/SecondaryNavButton.tsx | 19 +++ src/feed/models/posts/postsService.ts | 37 ----- src/hooks/useMessageListener.ts | 7 + src/index.css | 17 ++ src/messageBus/addMessageListener.ts | 12 -- src/model/media/mediaService.ts | 9 - src/{store => utils}/store.ts | 0 tsconfig.app.json | 3 +- yarn.lock | 62 +++++-- 38 files changed, 475 insertions(+), 401 deletions(-) create mode 100644 scripts/generate-client.mjs delete mode 100644 src/api/api.ts create mode 100644 src/app/api/client.ts rename src/{ => app}/api/schema.ts (88%) create mode 100644 src/app/auth/authService.ts create mode 100644 src/app/auth/components/Protected.tsx create mode 100644 src/app/auth/components/UnauthorizedHandler.tsx rename src/{auth => app/auth/pages}/LoginPage.tsx (84%) rename src/{auth => app/auth/pages}/LogoutPage.tsx (76%) rename src/{auth => app/auth/pages}/SignupPage.tsx (80%) rename src/{feed => app/feed/components}/FeedView.ts (71%) rename src/{feed => app/feed/components}/FeedView.tsx (85%) rename src/{feed => app/feed/components}/PostItem.tsx (94%) rename src/{feed => app/feed/pages}/AuthorPage.tsx (64%) rename src/{feed => app/feed/pages}/HomePage.tsx (79%) rename src/{feed/models => app/feed}/posts/posts.ts (95%) create mode 100644 src/app/feed/posts/postsService.ts create mode 100644 src/app/media/mediaService.ts create mode 100644 src/app/messageBus/messageBus.ts rename src/{ => app}/messageBus/messageTypes.ts (54%) rename src/{store => app/user}/userStore.ts (63%) delete mode 100644 src/auth/authService.ts create mode 100644 src/components/SecondaryLinkButton.tsx create mode 100644 src/components/SecondaryNavButton.tsx delete mode 100644 src/feed/models/posts/postsService.ts create mode 100644 src/hooks/useMessageListener.ts delete mode 100644 src/messageBus/addMessageListener.ts delete mode 100644 src/model/media/mediaService.ts rename src/{store => utils}/store.ts (100%) 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"