Compare commits

..

No commits in common. "main" and "v1.9.0" have entirely different histories.
main ... v1.9.0

46 changed files with 514 additions and 1551 deletions

1
.env Normal file
View file

@ -0,0 +1 @@
VITE_API_URL=http://localhost:5181

2
.gitignore vendored
View file

@ -22,5 +22,3 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.env

View file

@ -5,7 +5,6 @@
<link rel="icon" type="image/png" href="/icon_64.png" /> <link rel="icon" type="image/png" href="/icon_64.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>social media website</title> <title>social media website</title>
<script defer src="https://umami.botris.dev/script.js" data-website-id="bc6b66d7-9f71-426b-81f4-abea7a1f9034"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -1,14 +1,14 @@
{ {
"name": "femto-webapp", "name": "femto-webapp",
"private": true, "private": true,
"version": "1.26.6", "version": "1.9.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"build:deploy": "bash scripts/publish.sh", "build:deploy": "bash scripts/bump-build-push.sh",
"generate:schema": "node scripts/generate-schema.mjs" "generate:schema": "node scripts/generate-schema.mjs"
}, },
"dependencies": { "dependencies": {
@ -16,17 +16,14 @@
"@tailwindcss/vite": "^4.1.5", "@tailwindcss/vite": "^4.1.5",
"immer": "^10.1.1", "immer": "^10.1.1",
"openapi-fetch": "^0.14.0", "openapi-fetch": "^0.14.0",
"pica": "^9.0.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.6.0", "react-router-dom": "^7.6.0",
"tailwindcss": "^4.1.5", "tailwindcss": "^4.1.5"
"zustand": "^5.0.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",
"@types/node": "^22.15.19", "@types/node": "^22.15.19",
"@types/pica": "^9.0.5",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",

View file

@ -1,40 +0,0 @@
<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<metadata
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"
>
<rdf:RDF>
<cc:Work rdf:about="https://codeberg.org/forgejo/meta/src/branch/readme/branding#logo">
<dc:title>Forgejo logo</dc:title>
<cc:creator rdf:resource="https://caesarschinas.com/"><cc:attributionName>Caesar Schinas</cc:attributionName></cc:creator>
<cc:license rdf:resource="http://creativecommons.org/licenses/by-sa/4.0/" />
</cc:Work>
</rdf:RDF>
</metadata>
<style type="text/css">
circle {
fill: none;
stroke: #000;
stroke-width: 15;
}
path {
fill: none;
stroke: #000;
stroke-width: 25;
}
.orange {
stroke: oklch(55.1% 0.027 264.364);
}
.red {
--color-primary-700: var(--color-gray-700);
}
</style>
<g transform="translate(28,28)">
<path d="M58 168 v-98 a50 50 0 0 1 50-50 h20" class="orange" />
<path d="M58 168 v-30 a50 50 0 0 1 50-50 h20" class="red" />
<circle cx="142" cy="20" r="18" class="orange" />
<circle cx="142" cy="88" r="18" class="red" />
<circle cx="58" cy="180" r="18" class="red" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

57
scripts/bump-build-push.sh Executable file
View file

@ -0,0 +1,57 @@
#!/bin/bash
set -euo pipefail
# CONFIGURATION
REGISTRY="docker.botris.dev"
USERNAME="johnbotris"
IMAGE_NAME="femto-webapp"
# Add this before the docker build line
export VITE_API_URL="https://femto-api.botris.social"
# Step 0: Ensure clean working directory
if [[ -n $(git status --porcelain) ]]; then
echo "❌ Uncommitted changes detected. Please commit or stash them before running this script."
exit 1
fi
# Step 1: Store current version to revert if needed
OLD_VERSION=$(node -p "require('./package.json').version")
echo "🔍 Current version: $OLD_VERSION"
# Step 2: Bump version without Git tag/commit
echo "🚀 Bumping minor version..."
yarn version --minor --no-git-tag-version
NEW_VERSION=$(node -p "require('./package.json').version")
echo "📦 New version: $NEW_VERSION"
# Step 3: Attempt Docker build
echo "🔧 Building Docker image..."
if ! docker build --build-arg VITE_API_URL="$VITE_API_URL" -t $IMAGE_NAME .; then
echo "❌ Docker build failed. Reverting version bump..."
git checkout -- package.json yarn.lock
exit 1
fi
# Step 4: Tag and push Docker image
FULL_IMAGE="$REGISTRY/$USERNAME/$IMAGE_NAME"
echo "🏷️ Tagging Docker image..."
docker tag $IMAGE_NAME $FULL_IMAGE:$NEW_VERSION
docker tag $IMAGE_NAME $FULL_IMAGE:latest
echo "📤 Pushing Docker images..."
docker push $FULL_IMAGE:$NEW_VERSION
docker push $FULL_IMAGE:latest
# Step 5: Commit version bump & tag
echo "✅ Committing and tagging version bump..."
git add package.json yarn.lock
git commit -m "chore(release): v$NEW_VERSION"
git tag "v$NEW_VERSION"
git push origin main
git push origin "v$NEW_VERSION"
echo "🎉 Release v$NEW_VERSION complete."

View file

@ -0,0 +1,28 @@
import os from 'node:os'
/**
* Get the private IP addr of the dev machine to use as the API url.
* This is preferred to using localhost or 0.0.0.0 because it allows
* us to use the dev client from other devices (i.e. phones)
* @returns {string}
*/
export function getLocalApiUrl() {
const addresses = Object.values(os.networkInterfaces())
.flat()
.filter((addr) => !addr.internal)
.filter((addr) => addr.family === 'IPv4')
.map((addr) => addr.address)
let address = addresses.find((addr) => addr.startsWith('192.168')) ?? addresses.at(0)
if (address === undefined) {
console.warn("Couldn't identify the local address for the server. falling back to localhost")
address = 'localhost'
}
if (addresses.length > 1) {
console.warn(`chose API URL ${address} from possible choices: ${addresses.join(', ')}`)
}
return `http://${address}:7295`
}

View file

@ -1,101 +0,0 @@
#!/bin/bash
set -euo pipefail
# Function to display help text
show_help() {
echo "Usage: $0 [OPTIONS]"
echo
echo "Description:"
echo " This script automates the process of bumping the version, building a Docker image,"
echo " pushing it to the registry, and optionally deploying to production."
echo
echo "Options:"
echo " -h,--help Display this help message and exit"
echo " -d,--deploy Deploy to production after building and pushing"
echo " --major Bump the major version (x.0.0)"
echo " --minor Bump the minor version (0.x.0)"
echo " --patch Bump the patch version (0.0.x) [default]"
echo
echo "Examples:"
echo " $0 # Bump patch version, build and push"
echo " $0 --minor # Bump minor version, build and push"
echo " $0 --major -d # Bump major version, build, push and deploy"
echo " $0 --patch --deploy # Bump patch version, build, push and deploy"
echo
}
# Parse command line arguments
DEPLOY=false
VERSION_TYPE="patch" # Default to patch version bump
for arg in "$@"; do
case "$arg" in
-h|--help) show_help; exit 0 ;;
-d|--deploy) DEPLOY=true ;;
--major) VERSION_TYPE="major" ;;
--minor) VERSION_TYPE="minor" ;;
--patch) VERSION_TYPE="patch" ;;
*) echo "Unknown option: $arg"; echo "Usage: $0 [-h|--help] [-d|--deploy] [--major|--minor|--patch]"; exit 1 ;;
esac
done
# CONFIGURATION
REGISTRY="docker.botris.dev"
USERNAME="johnbotris"
IMAGE_NAME="femto-webapp"
# Add this before the docker build line
export VITE_API_URL="https://api.botris.social"
# Step 0: Ensure clean working directory
if [[ -n $(git status --porcelain) ]]; then
echo "❌ Uncommitted changes detected. Please commit or stash them before running this script."
exit 1
fi
# Step 1: Store current version to revert if needed
OLD_VERSION=$(node -p "require('./package.json').version")
echo "🔍 Current version: $OLD_VERSION"
# Step 2: Bump version without Git tag/commit
echo "🚀 Bumping $VERSION_TYPE version..."
yarn version --$VERSION_TYPE --no-git-tag-version
NEW_VERSION=$(node -p "require('./package.json').version")
echo "📦 New version: $NEW_VERSION"
# Step 3: Attempt Docker build
echo "🔧 Building Docker image..."
if ! docker build --build-arg VITE_API_URL="$VITE_API_URL" -t $IMAGE_NAME .; then
echo "❌ Docker build failed. Reverting version bump..."
git checkout -- package.json yarn.lock
exit 1
fi
# Step 4: Tag and push Docker image
FULL_IMAGE="$REGISTRY/$USERNAME/$IMAGE_NAME"
echo "🏷️ Tagging Docker image..."
docker tag $IMAGE_NAME $FULL_IMAGE:$NEW_VERSION
docker tag $IMAGE_NAME $FULL_IMAGE:latest
echo "📤 Pushing Docker images..."
docker push $FULL_IMAGE:$NEW_VERSION
docker push $FULL_IMAGE:latest
# Step 5: Commit version bump & tag
echo "✅ Committing and tagging version bump..."
git add package.json yarn.lock
git commit -m "v$NEW_VERSION"
git tag "v$NEW_VERSION"
git push origin main
git push origin "v$NEW_VERSION"
echo "🎉 Release v$NEW_VERSION complete."
# Step 6: Deploy if flag is set
if [ "$DEPLOY" = true ]; then
echo "🚀 Deploying to production..."
ssh john@botris.social 'bash /home/john/docker/femto/update.sh'
echo "✅ Deployment complete."
fi

View file

@ -1,6 +1,6 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom' import { BrowserRouter, Route, Routes } from 'react-router-dom'
import HomePage from './app/feed/pages/HomePage.tsx' import HomePage from './app/feed/pages/HomePage.tsx'
import PostPage from './app/feed/pages/PostPage.tsx' import AuthorPage from './app/feed/pages/AuthorPage.tsx'
import SignupPage from './app/auth/pages/SignupPage.tsx' import SignupPage from './app/auth/pages/SignupPage.tsx'
import LoginPage from './app/auth/pages/LoginPage.tsx' import LoginPage from './app/auth/pages/LoginPage.tsx'
import LogoutPage from './app/auth/pages/LogoutPage.tsx' import LogoutPage from './app/auth/pages/LogoutPage.tsx'
@ -8,36 +8,13 @@ import UnauthorizedHandler from './app/auth/components/UnauthorizedHandler.tsx'
import AdminPage from './app/admin/pages/AdminPage.tsx' import AdminPage from './app/admin/pages/AdminPage.tsx'
import SignupCodesManagementPage from './app/admin/pages/subpages/SignupCodesManagementPage.tsx' import SignupCodesManagementPage from './app/admin/pages/subpages/SignupCodesManagementPage.tsx'
import { useRefreshSessionLoop } from './useRefreshSessionLoop.ts' import { useRefreshSessionLoop } from './useRefreshSessionLoop.ts'
import { setGlobal } from './app/femtoApp.ts' import { initApp } from './initApp.ts'
import { PostsService } from './app/feed/posts/postsService.ts'
import { MediaService } from './app/media/mediaService.ts'
import { AuthService } from './app/auth/authService.ts'
import { initClient } from './app/api/client.ts'
import { useEffect } from 'react'
import { useUserStore } from './app/user/user.ts'
import { getUserFromCookie } from './app/auth/getUserFromCookie.ts'
setGlobal('version', import.meta.env.VITE_FEMTO_VERSION) const { postService, mediaService, authService } = initApp()
const client = initClient()
const postService = new PostsService(client)
const mediaService = new MediaService(client)
const authService = new AuthService(client)
setGlobal('postsService', postService)
setGlobal('authService', authService)
export default function App() { export default function App() {
const setUser = useUserStore((state) => state.setUser)
useRefreshSessionLoop(authService) useRefreshSessionLoop(authService)
useEffect(() => {
const user = getUserFromCookie()
console.debug('got user cookie', user)
setUser(user)
}, [setUser])
return ( return (
<BrowserRouter> <BrowserRouter>
<UnauthorizedHandler> <UnauthorizedHandler>
@ -46,7 +23,7 @@ export default function App() {
path={'/'} path={'/'}
element={<HomePage postsService={postService} mediaService={mediaService} />} element={<HomePage postsService={postService} mediaService={mediaService} />}
/> />
<Route path={'/p/:postId'} element={<PostPage postsService={postService} />} /> <Route path="/u/:username" element={<AuthorPage postsService={postService} />} />
<Route path="/login" element={<LoginPage authService={authService} />} /> <Route path="/login" element={<LoginPage authService={authService} />} />
<Route path="/logout" element={<LogoutPage authService={authService} />} /> <Route path="/logout" element={<LogoutPage authService={authService} />} />
<Route path="/signup/:code?" element={<SignupPage authService={authService} />} /> <Route path="/signup/:code?" element={<SignupPage authService={authService} />} />

View file

@ -1,5 +1,5 @@
import { AuthService } from '../../../auth/authService.ts' import { AuthService } from '../../../auth/authService.ts'
import { useEffect, useState, useRef, MouseEvent, useCallback } from 'react' import { useEffect, useState, useRef, MouseEvent } from 'react'
import { SignupCode } from '../../../auth/signupCode.ts' import { SignupCode } from '../../../auth/signupCode.ts'
import { Temporal } from '@js-temporal/polyfill' import { Temporal } from '@js-temporal/polyfill'
import Button from '../../../../components/buttons/Button.tsx' import Button from '../../../../components/buttons/Button.tsx'
@ -12,24 +12,25 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa
const [codes, setCodes] = useState<SignupCode[]>([]) const [codes, setCodes] = useState<SignupCode[]>([])
const [code, setCode] = useState('') const [code, setCode] = useState('')
const [name, setName] = useState('') const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const dialogRef = useRef<HTMLDialogElement>(null) const dialogRef = useRef<HTMLDialogElement>(null)
const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null) const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null)
const [activeCode, setActiveCode] = useState<string | null>(null) const [activeCode, setActiveCode] = useState<string | null>(null)
const fetchCodes = useCallback(async () => { const fetchCodes = async () => {
try { try {
setCodes(await authService.listSignupCodes()) setCodes(await authService.listSignupCodes())
} catch (err) { } catch (err) {
console.error('Failed to fetch signup codes:', err) console.error('Failed to fetch signup codes:', err)
} }
}, [authService]) }
useEffect(() => { useEffect(() => {
const timeoutId = setTimeout(fetchCodes) const timeoutId = setTimeout(fetchCodes)
return () => clearTimeout(timeoutId) return () => clearTimeout(timeoutId)
}, [authService, fetchCodes]) }, [authService])
const handleCreateCode = async (e: React.FormEvent) => { const handleCreateCode = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@ -37,11 +38,12 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa
setError(null) setError(null)
try { try {
await authService.createSignupCode(code, name) await authService.createSignupCode(code, email, name)
setCode('') setCode('')
setName('') setName('')
setEmail('')
dialogRef.current?.close() dialogRef.current?.close()
fetchCodes() fetchCodes() // Refresh the table
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create signup code') setError(err instanceof Error ? err.message : 'Failed to create signup code')
} finally { } finally {
@ -81,7 +83,12 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa
e.preventDefault() e.preventDefault()
const host = window.location.origin const host = window.location.origin
const url = `${host}?c=${code}` const url = `${host}?c=${code}`
navigator.clipboard.writeText(url).catch((err) => { navigator.clipboard.writeText(url)
.then(() => {
// Optional: Show a success message or notification
console.log('Copied to clipboard:', url)
})
.catch(err => {
console.error('Failed to copy:', err) console.error('Failed to copy:', err)
}) })
setTooltipPosition(null) setTooltipPosition(null)
@ -92,7 +99,7 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
setTooltipPosition({ setTooltipPosition({
x: rect.left + rect.width / 2, x: rect.left + rect.width / 2,
y: rect.top - 10, y: rect.top - 10
}) })
setActiveCode(code) setActiveCode(code)
} }
@ -114,6 +121,7 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa
<thead> <thead>
<tr> <tr>
<th className="text-left">Code</th> <th className="text-left">Code</th>
<th className="text-left">Email</th>
<th className="text-left">Redeemed By</th> <th className="text-left">Redeemed By</th>
<th className="text-left">Expires On</th> <th className="text-left">Expires On</th>
</tr> </tr>
@ -131,6 +139,7 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa
{code.code} {code.code}
</button> </button>
</td> </td>
<td>{code.email}</td>
<td>{code.redeemedBy || 'Not redeemed'}</td> <td>{code.redeemedBy || 'Not redeemed'}</td>
<td>{formatDate(code.expiresOn)}</td> <td>{formatDate(code.expiresOn)}</td>
</tr> </tr>
@ -187,6 +196,20 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa
/> />
</div> </div>
<div className="mb-4">
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
required
/>
</div>
<div className="flex justify-end space-x-2"> <div className="flex justify-end space-x-2">
<Button type="button" onClick={closeDialog} secondary> <Button type="button" onClick={closeDialog} secondary>
Cancel Cancel

View file

@ -1,25 +1,18 @@
import { paths } from './schema.ts' import { paths } from './schema.ts'
import createClient, { Middleware } from 'openapi-fetch' import createClient, { Middleware } from 'openapi-fetch'
import { dispatchMessage } from '../messageBus/messageBus.ts' import { dispatchMessage } from '../messageBus/messageBus.ts'
import { useUserStore } from '../user/user.ts'
import { getUserFromCookie } from '../auth/getUserFromCookie.ts'
export const initClient = () => { const client = createClient<paths>({ baseUrl: import.meta.env.VITE_API_URL })
const client = createClient<paths>({ baseUrl: import.meta.env.VITE_API_URL })
const UnauthorizedHandlerMiddleware: Middleware = { const UnauthorizedHandlerMiddleware: Middleware = {
async onResponse({ response }) { async onResponse({ response }) {
if (response.status === 401) { if (response.status === 401) {
dispatchMessage('auth:unauthorized', null) dispatchMessage('auth:unauthorized', null)
} }
const user = getUserFromCookie()
console.debug('got user cookie', user)
useUserStore.getState().setUser(user)
}, },
}
client.use(UnauthorizedHandlerMiddleware)
return client
} }
export type ApiClient = ReturnType<typeof initClient> client.use(UnauthorizedHandlerMiddleware)
// todo inject this if necessary
export default client

View file

@ -9,7 +9,7 @@ export interface paths {
get: { get: {
parameters: { parameters: {
query?: { query?: {
After?: string From?: string
Amount?: number Amount?: number
AuthorId?: string AuthorId?: string
Author?: string Author?: string
@ -26,9 +26,9 @@ export interface paths {
[name: string]: unknown [name: string]: unknown
} }
content: { content: {
'text/plain': components['schemas']['LoadPostsResponse'] 'text/plain': components['schemas']['GetAllPublicPostsResponse']
'application/json': components['schemas']['LoadPostsResponse'] 'application/json': components['schemas']['GetAllPublicPostsResponse']
'text/json': components['schemas']['LoadPostsResponse'] 'text/json': components['schemas']['GetAllPublicPostsResponse']
} }
} }
} }
@ -68,171 +68,6 @@ export interface paths {
patch?: never patch?: never
trace?: never trace?: never
} }
'/posts/{postId}': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get: {
parameters: {
query?: never
header?: never
path: {
postId: string
}
cookie?: never
}
requestBody?: never
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown
}
content: {
'text/plain': components['schemas']['GetPostResponse']
'application/json': components['schemas']['GetPostResponse']
'text/json': components['schemas']['GetPostResponse']
}
}
}
}
put?: never
post?: never
delete: {
parameters: {
query?: never
header?: never
path: {
postId: string
}
cookie?: never
}
requestBody?: never
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
options?: never
head?: never
patch?: never
trace?: never
}
'/posts/{postId}/reactions': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
post: {
parameters: {
query?: never
header?: never
path: {
postId: string
}
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['AddPostReactionRequest']
'text/json': components['schemas']['AddPostReactionRequest']
'application/*+json': components['schemas']['AddPostReactionRequest']
}
}
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
delete: {
parameters: {
query?: never
header?: never
path: {
postId: string
}
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['DeletePostReactionRequest']
'text/json': components['schemas']['DeletePostReactionRequest']
'application/*+json': components['schemas']['DeletePostReactionRequest']
}
}
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
options?: never
head?: never
patch?: never
trace?: never
}
'/posts/{postId}/comments': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
post: {
parameters: {
query?: never
header?: never
path: {
postId: string
}
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['AddPostCommentRequest']
'text/json': components['schemas']['AddPostCommentRequest']
'application/*+json': components['schemas']['AddPostCommentRequest']
}
}
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/media': { '/media': {
parameters: { parameters: {
query?: never query?: never
@ -252,8 +87,7 @@ export interface paths {
requestBody: { requestBody: {
content: { content: {
'multipart/form-data': { 'multipart/form-data': {
/** Format: binary */ file?: components['schemas']['IFormFile']
file?: string
} }
} }
} }
@ -398,78 +232,6 @@ export interface paths {
patch?: never patch?: never
trace?: never trace?: never
} }
'/auth/change-password': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
post: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['ChangePasswordRequestBody']
'text/json': components['schemas']['ChangePasswordRequestBody']
'application/*+json': components['schemas']['ChangePasswordRequestBody']
}
}
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/auth/delete-current-session': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
post: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/auth/session': { '/auth/session': {
parameters: { parameters: {
query?: never query?: never
@ -527,9 +289,9 @@ export interface paths {
[name: string]: unknown [name: string]: unknown
} }
content: { content: {
'text/plain': components['schemas']['GetUserInfoResult'] 'text/plain': components['schemas']['RefreshUserResult']
'application/json': components['schemas']['GetUserInfoResult'] 'application/json': components['schemas']['RefreshUserResult']
'text/json': components['schemas']['GetUserInfoResult'] 'text/json': components['schemas']['RefreshUserResult']
} }
} }
} }
@ -602,99 +364,10 @@ export interface paths {
patch?: never patch?: never
trace?: never trace?: never
} }
'/auth/create-signup-code': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
post: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['CreateSignupCodeRequest']
'text/json': components['schemas']['CreateSignupCodeRequest']
'application/*+json': components['schemas']['CreateSignupCodeRequest']
}
}
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/auth/list-signup-codes': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown
}
content: {
'text/plain': components['schemas']['ListSignupCodesResult']
'application/json': components['schemas']['ListSignupCodesResult']
'text/json': components['schemas']['ListSignupCodesResult']
}
}
}
}
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
} }
export type webhooks = Record<string, never> export type webhooks = Record<string, never>
export interface components { export interface components {
schemas: { schemas: {
AddPostCommentRequest: {
/** Format: uuid */
authorId: string
content: string
}
AddPostReactionRequest: {
emoji: string
}
ChangePasswordRequestBody: {
/** Format: uuid */
userId: string
newPassword: string
}
CreatePostRequest: { CreatePostRequest: {
/** Format: uuid */ /** Format: uuid */
authorId: string authorId: string
@ -714,35 +387,27 @@ export interface components {
height: number | null height: number | null
} }
CreatePostResponse: { CreatePostResponse: {
post: components['schemas']['PostDto'] /** Format: uuid */
postId: string
} }
CreateSignupCodeRequest: { CreateSignupCodeRequest: {
code: string code: string
email: string email: string
name: string name: string
} }
DeletePostReactionRequest: { GetAllPublicPostsResponse: {
emoji: string posts: components['schemas']['PostDto'][]
}
GetPostResponse: {
post: components['schemas']['PostDto']
}
GetUserInfoResult: {
/** Format: uuid */ /** Format: uuid */
userId: string next: string | null
username: string
isSuperUser: boolean
} }
/** Format: binary */
IFormFile: string
ListSignupCodesResult: { ListSignupCodesResult: {
signupCodes: components['schemas']['SignupCodeDto'][] signupCodes: components['schemas']['SignupCodeDto'][]
} }
LoadPostsResponse: {
posts: components['schemas']['PostDto'][]
}
LoginRequest: { LoginRequest: {
username: string username: string
password: string password: string
rememberMe: boolean
} }
LoginResponse: { LoginResponse: {
/** Format: uuid */ /** Format: uuid */
@ -755,23 +420,14 @@ export interface components {
authorId: string authorId: string
username: string username: string
} }
PostCommentDto: {
author: string
content: string
/** Format: date-time */
postedOn: string
}
PostDto: { PostDto: {
author: components['schemas']['PostAuthorDto'] author: components['schemas']['PostAuthorDto']
/** Format: uuid */ /** Format: uuid */
postId: string postId: string
content: string content: string
media: components['schemas']['PostMediaDto'][] media: components['schemas']['PostMediaDto'][]
reactions: components['schemas']['PostReactionDto'][]
/** Format: date-time */ /** Format: date-time */
createdAt: string createdAt: string
possibleReactions: string[]
comments: components['schemas']['PostCommentDto'][]
} }
PostMediaDto: { PostMediaDto: {
/** Format: uri */ /** Format: uri */
@ -781,17 +437,17 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
height: number | null height: number | null
} }
PostReactionDto: { RefreshUserResult: {
emoji: string /** Format: uuid */
authorName: string userId: string
/** Format: date-time */ username: string
reactedOn: string isSuperUser: boolean
} }
RegisterRequest: { RegisterRequest: {
username: string username: string
password: string password: string
signupCode: string signupCode: string
rememberMe: boolean email: string | null
} }
RegisterResponse: { RegisterResponse: {
/** Format: uuid */ /** Format: uuid */

View file

@ -1,14 +1,15 @@
import { dispatchMessage } from '../messageBus/messageBus.ts' import { dispatchMessage } from '../messageBus/messageBus.ts'
import client from '../api/client.ts'
import { ProblemDetails } from '../../types' import { ProblemDetails } from '../../types'
import { SignupCode } from './signupCode.ts' import { SignupCode } from './signupCode.ts'
import { ApiClient } from '../api/client.ts' import { getCookie } from './cookies.ts'
export class AuthService { export class AuthService {
constructor(private readonly client: ApiClient) {} constructor() {}
async login(username: string, password: string, rememberMe: boolean = false) { async login(username: string, password: string) {
const res = await this.client.POST('/auth/login', { const res = await client.POST('/auth/login', {
body: { username, password, rememberMe }, body: { username, password },
credentials: 'include', credentials: 'include',
}) })
@ -19,14 +20,9 @@ export class AuthService {
dispatchMessage('auth:logged-in', null) dispatchMessage('auth:logged-in', null)
} }
async signup( async signup(username: string, password: string, signupCode: string) {
username: string, const res = await client.POST('/auth/register', {
password: string, body: { username, password, signupCode, email: null },
signupCode: string,
rememberMe: boolean = false,
) {
const res = await this.client.POST('/auth/register', {
body: { username, password, signupCode, email: null, rememberMe },
credentials: 'include', credentials: 'include',
}) })
@ -39,14 +35,14 @@ export class AuthService {
} }
async logout() { async logout() {
await this.client.DELETE('/auth/session', { credentials: 'include' }) await client.DELETE('/auth/session', { credentials: 'include' })
dispatchMessage('auth:logged-out', null) dispatchMessage('auth:logged-out', null)
} }
async createSignupCode(code: string, name: string) { async createSignupCode(code: string, email: string, name: string) {
const res = await this.client.POST('/auth/signup-codes', { const res = await client.POST('/auth/signup-codes', {
body: { code, email: '', name }, body: { code, email, name },
credentials: 'include', credentials: 'include',
}) })
@ -57,7 +53,7 @@ export class AuthService {
} }
async listSignupCodes() { async listSignupCodes() {
const res = await this.client.GET('/auth/signup-codes', { const res = await client.GET('/auth/signup-codes', {
credentials: 'include', credentials: 'include',
}) })
@ -70,7 +66,11 @@ export class AuthService {
} }
async refreshUser(userId: string) { async refreshUser(userId: string) {
await this.client.GET(`/auth/user/{userId}`, { if (getCookie('hasSession') !== 'true') {
return null
}
await client.GET(`/auth/user/{userId}`, {
params: { params: {
path: { userId }, path: { userId },
}, },

View file

@ -1,11 +1,9 @@
import { useUser } from '../../user/user.ts'
import NavButton from '../../../components/buttons/NavButton.tsx' import NavButton from '../../../components/buttons/NavButton.tsx'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { useTranslations } from '../../i18n/translations.ts'
import { useUserStore } from '../../user/user.ts'
export default function AuthNavButtons() { export default function AuthNavButtons() {
const { t } = useTranslations() const user = useUser()
const user = useUserStore((state) => state.user)
const { pathname } = useLocation() const { pathname } = useLocation()
@ -17,15 +15,15 @@ export default function AuthNavButtons() {
if (loggedIn) { if (loggedIn) {
return ( return (
<> <>
<NavButton to="/logout">{t('nav.logout')}</NavButton> <NavButton to="/logout">logout</NavButton>
</> </>
) )
} else { } else {
const search = redirectQuery.toString() const search = redirectQuery.toString()
return ( return (
<> <>
<NavButton to={{ pathname: '/login', search }}>{t('nav.login')}</NavButton> <NavButton to={{ pathname: '/login', search }}>login</NavButton>
<NavButton to={{ pathname: '/signup', search }}>{t('nav.register')}</NavButton> <NavButton to={{ pathname: '/signup', search }}>register</NavButton>
</> </>
) )
} }

View file

@ -1,9 +1,9 @@
import { useUser } from '../../user/user.ts'
import { useNavigate, Outlet } from 'react-router-dom' import { useNavigate, Outlet } from 'react-router-dom'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useUserStore } from '../../user/user.ts'
export default function Protected() { export default function Protected() {
const user = useUserStore((state) => state.user) const user = useUser()
const navigate = useNavigate() const navigate = useNavigate()

View file

@ -1,6 +1,6 @@
import { PropsWithChildren, useEffect, useRef } from 'react' import { PropsWithChildren, useEffect, useRef } from 'react'
import { AuthService } from '../authService.ts' import { AuthService } from '../authService.ts'
import { useUserStore } from '../../user/user.ts' import { useUser } from '../../user/user.ts'
interface RefreshUserProps { interface RefreshUserProps {
authService: AuthService authService: AuthService
@ -10,7 +10,7 @@ export default function RefreshUser({
authService, authService,
children, children,
}: PropsWithChildren<RefreshUserProps>) { }: PropsWithChildren<RefreshUserProps>) {
const user = useUserStore((state) => state.user) const user = useUser()
const didRefresh = useRef(false) const didRefresh = useRef(false)
useEffect(() => { useEffect(() => {
@ -18,7 +18,7 @@ export default function RefreshUser({
if (didRefresh.current) return if (didRefresh.current) return
if (user == null) return if (user == null) return
didRefresh.current = true didRefresh.current = true
await authService.refreshUser(user.id) await authService.refreshUser(user.userId)
}) })
return () => clearTimeout(timeoutId) return () => clearTimeout(timeoutId)
}, [authService, user]) }, [authService, user])

View file

@ -1,11 +0,0 @@
import { User } from '../user/user.ts'
import { getCookie } from './cookies.ts'
export function getUserFromCookie(): User | null {
const userCookie = getCookie('user')
if (!userCookie) return null
// TODO validate but it should be fine
return JSON.parse(decodeURIComponent(userCookie)) as User
}

View file

@ -4,29 +4,25 @@ import TextInput from '../../../components/inputs/TextInput.tsx'
import Button from '../../../components/buttons/Button.tsx' import Button from '../../../components/buttons/Button.tsx'
import { AuthService } from '../authService.ts' import { AuthService } from '../authService.ts'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useUserStore } from '../../user/user.ts' import { useUser } from '../../user/user.ts'
import NavBar from '../../../components/NavBar.tsx' import NavBar from '../../../components/NavBar.tsx'
import NavButton from '../../../components/buttons/NavButton.tsx' import NavButton from '../../../components/buttons/NavButton.tsx'
import LinkButton from '../../../components/buttons/LinkButton.tsx' import LinkButton from '../../../components/buttons/LinkButton.tsx'
import { useTranslations } from '../../i18n/translations.ts'
interface LoginPageProps { interface LoginPageProps {
authService: AuthService authService: AuthService
} }
export default function LoginPage({ authService }: LoginPageProps) { export default function LoginPage({ authService }: LoginPageProps) {
const { t } = useTranslations()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [rememberMe, setRememberMe] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const usernameInputRef = useRef<HTMLInputElement | null>(null) const usernameInputRef = useRef<HTMLInputElement | null>(null)
const passwordInputRef = useRef<HTMLInputElement | null>(null) const passwordInputRef = useRef<HTMLInputElement | null>(null)
const navigate = useNavigate() const navigate = useNavigate()
const user = useUserStore((state) => state.user) const user = useUser()
useEffect(() => { useEffect(() => {
if (user) { if (user) {
@ -53,7 +49,7 @@ export default function LoginPage({ authService }: LoginPageProps) {
setIsSubmitting(true) setIsSubmitting(true)
try { try {
await authService.login(username, password, rememberMe) await authService.login(username, password)
} catch (error: unknown) { } catch (error: unknown) {
setError(error instanceof Error ? error.message : 'something went terribly wrong') setError(error instanceof Error ? error.message : 'something went terribly wrong')
} finally { } finally {
@ -65,7 +61,7 @@ export default function LoginPage({ authService }: LoginPageProps) {
<SingleColumnLayout <SingleColumnLayout
navbar={ navbar={
<NavBar> <NavBar>
<NavButton to={'/'}>{t('nav.home')}</NavButton> <NavButton to={'/'}>home</NavButton>
</NavBar> </NavBar>
} }
> >
@ -74,7 +70,7 @@ export default function LoginPage({ authService }: LoginPageProps) {
<form className="flex flex-col gap-4 max-w-md" onSubmit={onSubmit}> <form className="flex flex-col gap-4 max-w-md" onSubmit={onSubmit}>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="username" className="text-sm text-gray-600"> <label htmlFor="username" className="text-sm text-gray-600">
{t('auth.username.label')} username
</label> </label>
<TextInput <TextInput
ref={usernameInputRef} ref={usernameInputRef}
@ -87,7 +83,7 @@ export default function LoginPage({ authService }: LoginPageProps) {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="password" className="text-sm text-gray-600"> <label htmlFor="password" className="text-sm text-gray-600">
{t('auth.password.label')} password
</label> </label>
<TextInput <TextInput
ref={passwordInputRef} ref={passwordInputRef}
@ -95,32 +91,19 @@ export default function LoginPage({ authService }: LoginPageProps) {
id="password" id="password"
value={password} value={password}
onInput={setPassword} onInput={setPassword}
className={'mb-4'} className={'mb-3'}
/> />
</div> </div>
<div className="flex items-center gap-2 ">
<input
type="checkbox"
id="rememberMe"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="h-4 w-4"
/>
<label htmlFor="rememberMe" className="text-sm text-gray-600">
{t('auth.remember_me.label')}
</label>
</div>
<Button className="mt-4" disabled={isSubmitting} type="submit"> <Button className="mt-4" disabled={isSubmitting} type="submit">
{isSubmitting ? t('misc.loading') : t('auth.login.cta')} {isSubmitting ? 'wait...' : 'make login pls'}
</Button> </Button>
<LinkButton secondary to={{ pathname: '/signup', search: window.location.search }}> <LinkButton secondary to={{ pathname: '/signup', search: window.location.search }}>
{t('auth.login.register_instead')} register instead?
</LinkButton> </LinkButton>
<span className={'text-xs h-3 text-red-500'}>{error}</span> <span className="text-xs h-3 text-red-500">{error}</span>
</form> </form>
</div> </div>
</main> </main>

View file

@ -1,7 +1,7 @@
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { AuthService } from '../authService.ts' import { AuthService } from '../authService.ts'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useUserStore } from '../../user/user.ts' import { useUser } from '../../user/user.ts'
interface LogoutPageProps { interface LogoutPageProps {
authService: AuthService authService: AuthService
@ -9,9 +9,10 @@ interface LogoutPageProps {
export default function LogoutPage({ authService }: LogoutPageProps) { export default function LogoutPage({ authService }: LogoutPageProps) {
const navigate = useNavigate() const navigate = useNavigate()
const user = useUserStore((state) => state.user) const user = useUser()
useEffect(() => { useEffect(() => {
console.debug(user)
if (!user) { if (!user) {
navigate('/login') navigate('/login')
} }

View file

@ -3,12 +3,12 @@ import { useEffect, useRef, useState, FormEvent, useCallback, Ref } from 'react'
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx' import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
import TextInput from '../../../components/inputs/TextInput.tsx' import TextInput from '../../../components/inputs/TextInput.tsx'
import Button from '../../../components/buttons/Button.tsx' import Button from '../../../components/buttons/Button.tsx'
import AnchorButton from '../../../components/buttons/AnchorButton.tsx'
import { invalid, valid, Validation } from '../../../utils/validation.ts' import { invalid, valid, Validation } from '../../../utils/validation.ts'
import { AuthService } from '../authService.ts' import { AuthService } from '../authService.ts'
import LinkButton from '../../../components/buttons/LinkButton.tsx' import LinkButton from '../../../components/buttons/LinkButton.tsx'
import NavBar from '../../../components/NavBar.tsx' import NavBar from '../../../components/NavBar.tsx'
import NavButton from '../../../components/buttons/NavButton.tsx' import NavButton from '../../../components/buttons/NavButton.tsx'
import { useTranslations } from '../../i18n/translations.ts'
const SignupCodeKey = 'signupCode' const SignupCodeKey = 'signupCode'
@ -17,11 +17,9 @@ interface SignupPageProps {
} }
export default function SignupPage({ authService }: SignupPageProps) { export default function SignupPage({ authService }: SignupPageProps) {
const { t } = useTranslations()
const { code } = useParams() const { code } = useParams()
const [signupCode, setSignupCode] = useState<string | null>(null) const [signupCode, setSignupCode] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [rememberMe, setRememberMe] = useState(false)
const [error, setError] = useState<string>('') const [error, setError] = useState<string>('')
const [username, setUsername, usernameError, validateUsername] = const [username, setUsername, usernameError, validateUsername] =
useValidatedInput(isValidUsername) useValidatedInput(isValidUsername)
@ -32,6 +30,8 @@ export default function SignupPage({ authService }: SignupPageProps) {
const userNameInputRef = useRef<HTMLInputElement | null>(null) const userNameInputRef = useRef<HTMLInputElement | null>(null)
const passwordInputRef = useRef<HTMLInputElement | null>(null) const passwordInputRef = useRef<HTMLInputElement | null>(null)
const dialogRef = useRef<HTMLDialogElement | null>(null)
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => { useEffect(() => {
@ -46,6 +46,10 @@ export default function SignupPage({ authService }: SignupPageProps) {
theSignupCode = localStorage.getItem(SignupCodeKey) theSignupCode = localStorage.getItem(SignupCodeKey)
setSignupCode(theSignupCode) setSignupCode(theSignupCode)
} }
if (!theSignupCode) {
dialogRef.current?.showModal()
}
}, [code, signupCode]) }, [code, signupCode])
useEffect(() => {}, [signupCode]) useEffect(() => {}, [signupCode])
@ -75,7 +79,7 @@ export default function SignupPage({ authService }: SignupPageProps) {
setIsSubmitting(true) setIsSubmitting(true)
try { try {
await authService.signup(username, password, signupCode, rememberMe) await authService.signup(username, password, signupCode)
navigate('/') navigate('/')
} catch (e: unknown) { } catch (e: unknown) {
const err = e as Error const err = e as Error
@ -89,7 +93,7 @@ export default function SignupPage({ authService }: SignupPageProps) {
<SingleColumnLayout <SingleColumnLayout
navbar={ navbar={
<NavBar> <NavBar>
<NavButton to={'/'}>{t('nav.home')}</NavButton> <NavButton to={'/'}>home</NavButton>
</NavBar> </NavBar>
} }
> >
@ -98,7 +102,6 @@ export default function SignupPage({ authService }: SignupPageProps) {
<form className="flex flex-col gap-4 max-w-md" onSubmit={onSubmit}> <form className="flex flex-col gap-4 max-w-md" onSubmit={onSubmit}>
<FormInput <FormInput
id="username" id="username"
label={t('auth.username.label')}
value={username} value={username}
onInput={setUsername} onInput={setUsername}
error={usernameError} error={usernameError}
@ -107,47 +110,55 @@ export default function SignupPage({ authService }: SignupPageProps) {
<FormInput <FormInput
id="password" id="password"
label={t('auth.password.label')}
value={password} value={password}
onInput={setPassword} onInput={setPassword}
error={passwordError} error={passwordError}
type="password" type="password"
ref={passwordInputRef} ref={passwordInputRef}
/> />
<div className="flex items-center gap-2">
<input
type="checkbox"
id="rememberMe"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="h-4 w-4"
/>
<label htmlFor="rememberMe" className="text-sm text-gray-600">
{t('auth.remember_me.label')}
</label>
</div>
<Button <Button
className="mt-4" className="mt-4"
disabled={isSubmitting || !!usernameError || !!passwordError} disabled={isSubmitting || !!usernameError || !!passwordError}
type="submit" type="submit"
> >
{isSubmitting ? t('misc.loading') : t('auth.register.cta')} {isSubmitting ? 'wait...' : 'give me an account pls'}
</Button> </Button>
<LinkButton secondary to={'/login'}> <LinkButton secondary to={'/login'}>
{t('auth.register.login_instead')} login instead?
</LinkButton> </LinkButton>
<span className="text-xs h-3 text-red-500">{error}</span> <span className="text-xs h-3 text-red-500">{error}</span>
</form> </form>
</div> </div>
</main> </main>
<dialog
id="go-away-dialog"
ref={dialogRef}
className="p-6 rounded-lg shadow-lg m-auto outline-none"
>
<div className="text-gray-600 flex flex-col gap-2">
<h1 className={`font-bold text-lg`}>STOP !!!</h1>
<p>You need an invitation to sign up</p>
<p>
I'm surprised you even found your way here without one and honestly I'd prefer it if you
would leave
</p>
<p>
If you <span className="italic">do</span> want to create an account, you should know who
to contact
</p>
<AnchorButton className={`mt-4`} href="https://en.wikipedia.org/wiki/Special:Random">
I'm sorry I'll go somewhere else :(
</AnchorButton>
</div>
</dialog>
</SingleColumnLayout> </SingleColumnLayout>
) )
} }
interface FormInputProps { interface FormInputProps {
id: string id: string
label: string
value: string value: string
onInput: (value: string) => void onInput: (value: string) => void
error: string | null error: string | null
@ -155,11 +166,11 @@ interface FormInputProps {
ref: Ref<HTMLInputElement> ref: Ref<HTMLInputElement>
} }
function FormInput({ id, label, value, onInput, error, type = 'text', ref }: FormInputProps) { function FormInput({ id, value, onInput, error, type = 'text', ref }: FormInputProps) {
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor={id} className="text-sm text-gray-600"> <label htmlFor={id} className="text-sm text-gray-600">
{label} {id}
</label> </label>
<TextInput ref={ref} type={type} id={id} value={value} onInput={onInput} /> <TextInput ref={ref} type={type} id={id} value={value} onInput={onInput} />
<div className="text-xs h-3 text-red-500">{error}</div> <div className="text-xs h-3 text-red-500">{error}</div>

View file

@ -0,0 +1,36 @@
import { useCallback, useRef, useState } from 'react'
import { Post } from '../posts/posts.ts'
const PageSize = 20
export function useFeedViewModel(
loadMore: (cursor: string | null, amount: number) => Promise<Post[]>,
) {
const [pages, setPages] = useState<Post[][]>([])
const [hasMore, setHasMore] = useState(true)
const [error, setError] = useState<string | null>(null)
const cursor = useRef<string | null>(null)
const loading = useRef(false)
const loadNextPage = useCallback(async () => {
if (loading.current || !hasMore || error) return
loading.current = true
try {
const delay = new Promise((resolve) => setTimeout(resolve, 500))
const pagePromise = loadMore(cursor.current, PageSize)
const [page] = await Promise.all([pagePromise, delay])
setHasMore(page.length >= PageSize)
cursor.current = page.at(-1)?.postId ?? null
setPages((prev) => [...prev, page])
} catch (e: unknown) {
const err = e as Error
setError(err.message)
} finally {
loading.current = false
}
}, [loadMore, hasMore, error])
return { pages, setPages, loadNextPage, error } as const
}

View file

@ -0,0 +1,29 @@
import { useRef } from 'react'
import { useIntersectionLoad } from '../../../hooks/useIntersectionLoad.ts'
import { Post } from '../posts/posts.ts'
import PostItem from './PostItem.tsx'
interface FeedViewProps {
pages: Post[][]
onLoadMore: () => Promise<void>
}
export default function FeedView({ pages, onLoadMore }: FeedViewProps) {
const sentinelRef = useRef<HTMLDivElement | null>(null)
const posts = pages.flat()
useIntersectionLoad(onLoadMore, sentinelRef)
return (
<div className="w-full">
<div className="flex flex-col gap-6 w-full">
<div className="flex flex-col gap-6 w-full">
{posts.map((post) => (
<PostItem key={post.postId} post={post} />
))}
</div>
</div>
<div ref={sentinelRef} className="h-1" />
</div>
)
}

View file

@ -1,58 +0,0 @@
import { useState } from 'react'
import FancyTextEditor, {
TextInputKeyDownEvent,
} from '../../../components/inputs/FancyTextEditor.tsx'
import Button from '../../../components/buttons/Button.tsx'
import { useTranslations } from '../../i18n/translations.ts'
interface NewCommentWidgetProps {
onSubmit: (content: string) => void
isSubmitting?: boolean
}
export default function NewCommentWidget({
onSubmit,
isSubmitting = false,
}: NewCommentWidgetProps) {
const { t } = useTranslations()
const [content, setContent] = useState('')
const onContentInput = (value: string) => {
setContent(value)
}
const handleSubmit = () => {
if (!content.trim()) {
return
}
onSubmit(content)
setContent('')
}
const onInputKeyDown = (e: TextInputKeyDownEvent) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault()
handleSubmit()
}
}
return (
<div className="w-full p-4 border-b border-gray-200">
<FancyTextEditor
value={content}
onInput={onContentInput}
onKeyDown={onInputKeyDown}
className="mb-3"
placeholder={t('post.editor.placeholder')}
/>
<div className="flex justify-end items-center">
<Button onClick={handleSubmit} disabled={isSubmitting || content.trim() === ''}>
{t('post.submit.cta')}
</Button>
</div>
</div>
)
}

View file

@ -1,24 +1,12 @@
import { PostMedia, PostReaction } from '../posts/posts.ts' import { Post, PostMedia } from '../posts/posts.ts'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { PostInfo } from '../posts/usePostViewModel.ts' import { useEffect, useState } from 'react'
import { useUserStore } from '../../user/user.ts'
interface PostItemProps { interface PostItemProps {
post: PostInfo post: Post
reactions: PostReaction[]
addReaction: (emoji: string) => void
clearReaction: (emoji: string) => void
hideViewButton?: boolean
} }
export default function PostItem({ export default function PostItem({ post }: PostItemProps) {
post,
reactions,
addReaction,
clearReaction,
hideViewButton = false,
}: PostItemProps) {
const formattedDate = post.createdAt.toLocaleString('en-US', { const formattedDate = post.createdAt.toLocaleString('en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
@ -41,15 +29,10 @@ export default function PostItem({
return ( return (
<article className={`w-full p-4 ${opacity} transition-opacity duration-500`} key={post.postId}> <article className={`w-full p-4 ${opacity} transition-opacity duration-500`} key={post.postId}>
<div className="text-sm text-gray-500 mb-3"> <div className="text-sm text-gray-500 mb-3">
<span className="text-gray-400 mr-2">@{post.authorName}</span> {formattedDate} <Link to={`/u/${post.authorName}`} className="text-gray-400 hover:underline mr-2">
{!hideViewButton && ( @{post.authorName}
<>
{' • '}
<Link to={`/p/${post.postId}`} className="ml-2 text-primary-400 hover:underline">
View
</Link> </Link>
</> {formattedDate}
)}
</div> </div>
<div className="text-gray-800 mb-4 whitespace-pre-wrap">{post.content}</div> <div className="text-gray-800 mb-4 whitespace-pre-wrap">{post.content}</div>
@ -61,95 +44,24 @@ export default function PostItem({
))} ))}
</div> </div>
)} )}
<PostReactions
post={post}
reactions={reactions}
addReaction={addReaction}
clearReaction={clearReaction}
/>
</article> </article>
) )
} }
interface PostReactionsProps {
post: PostInfo
reactions: PostReaction[]
addReaction: (emoji: string) => void
clearReaction: (emoji: string) => void
}
function PostReactions({ post, reactions, addReaction, clearReaction }: PostReactionsProps) {
const username = useUserStore((state) => state.user?.username)
return (
<div className="flex flex-wrap gap-2 mt-3 justify-end">
{post.possibleReactions.map((emoji) => {
const count = reactions.filter((r) => r.emoji === emoji).length
const didReact = reactions.some((r) => r.emoji == emoji && r.authorName == username)
const onClick = () => {
if (didReact) {
clearReaction(emoji)
} else {
addReaction(emoji)
}
}
return (
<PostReactionButton
key={emoji}
emoji={emoji}
didReact={didReact}
count={count}
onClick={onClick}
/>
)
})}
</div>
)
}
interface PostReactionButtonProps {
emoji: string
didReact: boolean
count: number
onClick: () => void
}
function PostReactionButton({ emoji, didReact, onClick, count }: PostReactionButtonProps) {
const formattedCount = count < 100 ? count.toString() : `99+`
return (
<button
key={emoji}
onClick={onClick}
className={`flex items-center px-2 py-1 rounded-full border cursor-pointer ${
didReact ? 'bg-gray-100 border-gray-400' : 'bg-white border-gray-200'
} hover:bg-gray-100 transition-colors`}
>
<span className="mr-1">{emoji}</span>
<span className="text-xs text-gray-600">{formattedCount}</span>
</button>
)
}
interface PostMediaProps { interface PostMediaProps {
media: PostMedia media: PostMedia
} }
function PostMediaItem({ media }: PostMediaProps) { function PostMediaItem({ media }: PostMediaProps) {
const url = new URL(media.url.toString()) const url = media.url.toString()
if (location.protocol === 'https:' && url.protocol !== 'https:') {
url.protocol = 'https:'
}
const width = media.width ?? undefined const width = media.width ?? undefined
const height = media.height ?? undefined const height = media.height ?? undefined
return ( return (
<img <img
width={width} width={width}
height={height} height={height}
src={url.toString()} src={url}
alt="todo sry :("
className="w-full h-auto" className="w-full h-auto"
loading="lazy" loading="lazy"
/> />

View file

@ -1,67 +0,0 @@
import { PostComment, PostReaction } from '../posts/posts.ts'
import { Temporal } from '@js-temporal/polyfill'
interface PostTimelineProps {
reactions: PostReaction[]
comments: PostComment[]
}
export function PostTimeline({ reactions, comments }: PostTimelineProps) {
const items = [
...reactions.map((reaction) => ({
timestamp: reaction.reactedOn,
component: (
<ReactionItem
key={'reaction-' + reaction.authorName + reaction.reactedOn.toString()}
reaction={reaction}
/>
),
})),
...comments.map((comment) => ({
timestamp: comment.postedOn,
component: (
<CommentItem
key={'comment-' + comment.author + comment.postedOn.toString()}
comment={comment}
/>
),
})),
].toSorted((a, b) => Temporal.Instant.compare(a.timestamp, b.timestamp))
return (
<div className={`flex flex-col gap-4 mb-4 px-4`}>{items.map((item) => item.component)}</div>
)
}
function ReactionItem({ reaction }: { reaction: PostReaction }) {
return (
<div className={`flex flex-col`}>
<span className={`text-gray-400 text-xs -mb-0.5`}>{formatItemDate(reaction.reactedOn)}</span>
<div className={`flex flex-row items-baseline text-gray-400`}>
<span>@{reaction.authorName}</span>&nbsp;
<span>clicked</span>&nbsp;
<span>{reaction.emoji}</span>
</div>
</div>
)
}
function CommentItem({ comment }: { comment: PostComment }) {
return (
<div className={`flex flex-col`}>
<div className={`text-gray-400 text-xs -mb-0.5`}>{formatItemDate(comment.postedOn)}</div>
<div className={`flex flex-row items-baseline text-gray-500`}>
<span className={`text-gray-400`}>@{comment.author}</span>&nbsp;
</div>
<div className={`ml-1 text-gray-600`}>{comment.content}</div>
</div>
)
}
function formatItemDate(date: Temporal.Instant) {
return date.toLocaleString('en-AU', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
})
}

View file

@ -0,0 +1,39 @@
import { useCallback } from 'react'
import FeedView from '../components/FeedView.tsx'
import { PostsService } from '../posts/postsService.ts'
import { useParams } from 'react-router-dom'
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
import NavBar from '../../../components/NavBar.tsx'
import { useFeedViewModel } from '../components/FeedView.ts'
import NavButton from '../../../components/buttons/NavButton.tsx'
import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx'
interface AuthorPageParams {
postsService: PostsService
}
export default function AuthorPage({ postsService }: AuthorPageParams) {
const { username } = useParams()
const fetchPosts = useCallback(
async (cursor: string | null, amount: number | null) => {
return postsService.loadByAuthor(username!, cursor, amount)
},
[postsService, username],
)
const { pages, loadNextPage } = useFeedViewModel(fetchPosts)
return (
<SingleColumnLayout
navbar={
<NavBar>
<NavButton to={'/'}>home</NavButton>
<AuthNavButtons />
</NavBar>
}
>
<FeedView pages={pages} onLoadMore={loadNextPage} />
</SingleColumnLayout>
)
}

View file

@ -1,59 +1,38 @@
import { useRef, useState } from 'react' import { useCallback, useState } from 'react'
import FeedView from '../components/FeedView.tsx'
import { PostsService } from '../posts/postsService.ts' import { PostsService } from '../posts/postsService.ts'
import { useUserStore } from '../../user/user.ts' import { useUser } from '../../user/user.ts'
import { MediaService } from '../../media/mediaService.ts' import { MediaService } from '../../media/mediaService.ts'
import NewPostWidget from '../components/NewPostWidget.tsx' 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 SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
import NavBar from '../../../components/NavBar.tsx' import NavBar from '../../../components/NavBar.tsx'
import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx' import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx'
import { useSaveSignupCodeToLocalStorage } from '../../../hooks/useSaveSignupCodeToLocalStorage.ts' import { useSaveSignupCodeToLocalStorage } from '../../../hooks/useSaveSignupCodeToLocalStorage.ts'
import PostItem from '../components/PostItem.tsx'
import { useIntersectionLoad } from '../../../hooks/useIntersectionLoad.ts'
import { delay } from '../../../utils/delay.ts'
import { usePostViewModel } from '../posts/usePostViewModel.ts'
import { Temporal } from '@js-temporal/polyfill'
interface HomePageProps { interface HomePageProps {
postsService: PostsService postsService: PostsService
mediaService: MediaService mediaService: MediaService
} }
const PageSize = 20
export default function HomePage({ postsService, mediaService }: HomePageProps) { export default function HomePage({ postsService, mediaService }: HomePageProps) {
const user = useUserStore((state) => state.user) const user = useUser()
useSaveSignupCodeToLocalStorage() useSaveSignupCodeToLocalStorage()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const { posts, addPosts, addReaction, removeReaction, reactions } = usePostViewModel() const fetchPosts = useCallback(
async (cursor: string | null, amount: number | null) => {
return postsService.loadPublicFeed(cursor, amount)
},
[postsService],
)
const [hasMore, setHasMore] = useState(true) const { pages, setPages, loadNextPage } = useFeedViewModel(fetchPosts)
const [error, setError] = useState<string | null>(null)
const cursor = useRef<string | null>(null) const onCreatePost = useCallback(
const loading = useRef(false) async (
const loadNextPage = async () => {
if (loading.current || !hasMore || error) return
loading.current = true
try {
const [{ posts }] = await Promise.all([
postsService.loadPublicFeed(cursor.current, PageSize),
delay(500),
])
setHasMore(posts.length >= PageSize)
cursor.current = posts.at(-1)?.postId ?? null
addPosts(posts)
} catch (e: unknown) {
setError((e as Error).message)
} finally {
loading.current = false
}
}
const onCreatePost = async (
content: string, content: string,
files: { file: File; width: number; height: number }[], files: { file: File; width: number; height: number }[],
isPublic: boolean, isPublic: boolean,
@ -63,7 +42,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
try { try {
const media = await Promise.all( const media = await Promise.all(
files.map(async ({ file, width, height }) => { files.map(async ({ file, width, height }) => {
const { mediaId, url } = await mediaService.uploadImage(file) const { mediaId, url } = await mediaService.uploadFile(file)
return { return {
mediaId, mediaId,
@ -73,32 +52,20 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
} }
}), }),
) )
const post = await postsService.createNew(user.id, content, media, isPublic) const postId = await postsService.createNew(user.userId, content, media, isPublic)
addPosts([post]) const post = new Post(postId, content, media, Temporal.Now.instant(), user.username)
setPages((pages) => [[post], ...pages])
} catch (error) { } catch (error) {
console.error('Failed to create post:', error) console.error('Failed to create post:', error)
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
} },
[mediaService, postsService, setPages, user],
)
const isLoggedIn = user != null const isLoggedIn = user != null
const onAddReaction = async (postId: string, emoji: string) => {
await postsService.addReaction(postId, emoji)
addReaction(postId, emoji, user!.username, Temporal.Now.instant())
}
const onClearReaction = async (postId: string, emoji: string) => {
await postsService.removeReaction(postId, emoji)
removeReaction(postId, emoji, user!.username)
}
const sentinelRef = useRef<HTMLDivElement | null>(null)
useIntersectionLoad(loadNextPage, sentinelRef)
return ( return (
<SingleColumnLayout <SingleColumnLayout
navbar={ navbar={
@ -109,22 +76,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
> >
<main className={`w-full max-w-3xl mx-auto`}> <main className={`w-full max-w-3xl mx-auto`}>
{isLoggedIn && <NewPostWidget onSubmit={onCreatePost} isSubmitting={isSubmitting} />} {isLoggedIn && <NewPostWidget onSubmit={onCreatePost} isSubmitting={isSubmitting} />}
<div className="w-full"> <FeedView pages={pages} onLoadMore={loadNextPage} />
<div className="flex flex-col gap-6 w-full">
<div className="flex flex-col gap-6 w-full">
{posts.map((post) => (
<PostItem
key={post.postId}
post={post}
reactions={reactions[post.postId] ?? []}
addReaction={(emoji) => onAddReaction(post.postId, emoji)}
clearReaction={(emoji) => onClearReaction(post.postId, emoji)}
/>
))}
</div>
</div>
<div ref={sentinelRef} className="h-1" />
</div>
</main> </main>
</SingleColumnLayout> </SingleColumnLayout>
) )

View file

@ -1,96 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { PostsService } from '../posts/postsService.ts'
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
import NavBar from '../../../components/NavBar.tsx'
import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx'
import PostItem from '../components/PostItem.tsx'
import NavButton from '../../../components/buttons/NavButton.tsx'
import { useTranslations } from '../../i18n/translations.ts'
import { usePostViewModel } from '../posts/usePostViewModel.ts'
import { Temporal } from '@js-temporal/polyfill'
import { useUserStore } from '../../user/user.ts'
import { PostTimeline } from '../components/PostTimeline.tsx'
import NewCommentWidget from '../components/NewCommentWidget.tsx'
interface PostPageProps {
postsService: PostsService
}
export default function PostPage({ postsService }: PostPageProps) {
const { postId } = useParams<{ postId: string }>()
const { posts, setPosts, addReaction, reactions: _reactions, removeReaction } = usePostViewModel()
const { t } = useTranslations()
const username = useUserStore((state) => state.user?.username)
const post = posts.at(0)
const reactions = (post?.postId ? _reactions[post.postId] : []) ?? []
const loadPost = useCallback(() => {
if (!postId) return
postsService.load(postId).then((post) => setPosts(post ? [post] : []))
}, [postId, postsService, setPosts])
useEffect(() => {
loadPost()
}, [loadPost])
const onAddReaction = async (emoji: string) => {
if (!username) return
if (!post) return
await postsService.addReaction(post.postId, emoji)
addReaction(post.postId, emoji, username, Temporal.Now.instant())
}
const onClearReaction = async (emoji: string) => {
if (!username) return
if (!post) return
await postsService.removeReaction(post.postId, emoji)
removeReaction(post.postId, emoji, username)
}
async function onSubmitComment(content: string) {
if (!postId) return
if (!content.trim()) return
try {
setIsSubmittingComment(true)
await postsService.addComment(postId, content)
} finally {
setIsSubmittingComment(false)
}
loadPost()
}
const [isSubmittingComment, setIsSubmittingComment] = useState(false)
return (
<SingleColumnLayout
navbar={
<NavBar>
<NavButton to={{ pathname: '/' }}>{t('nav.home')}</NavButton>
<AuthNavButtons />
</NavBar>
}
>
<main className="w-full max-w-3xl mx-auto">
{post && (
<div className="w-full">
<PostItem
post={post}
reactions={reactions}
addReaction={onAddReaction}
clearReaction={onClearReaction}
hideViewButton={true}
/>
<PostTimeline reactions={reactions} comments={post.comments} />
<NewCommentWidget onSubmit={onSubmitComment} isSubmitting={isSubmittingComment} />
</div>
)}
</main>
</SingleColumnLayout>
)
}

View file

@ -1,30 +1,12 @@
import { Temporal } from '@js-temporal/polyfill' import { Temporal } from '@js-temporal/polyfill'
import { components } from '../../api/schema.ts' import { components } from '../../api/schema.ts'
import { immerable } from 'immer'
export interface PostReaction {
emoji: string
authorName: string
reactedOn: Temporal.Instant
}
export interface PostComment {
author: string
content: string
postedOn: Temporal.Instant
}
export class Post { export class Post {
[immerable] = true
public readonly postId: string public readonly postId: string
public readonly content: string public readonly content: string
public readonly media: PostMedia[] public readonly media: PostMedia[]
public readonly createdAt: Temporal.Instant public readonly createdAt: Temporal.Instant
public readonly authorName: string public readonly authorName: string
public readonly reactions: PostReaction[]
public readonly possibleReactions: string[]
public readonly comments: PostComment[]
constructor( constructor(
postId: string, postId: string,
@ -32,18 +14,12 @@ export class Post {
media: PostMedia[], media: PostMedia[],
createdAt: string | Temporal.Instant, createdAt: string | Temporal.Instant,
authorName: string, authorName: string,
reactions: PostReaction[],
possibleReactions: string[],
comments: PostComment[],
) { ) {
this.postId = postId this.postId = postId
this.content = content this.content = content
this.media = media this.media = media
this.createdAt = Temporal.Instant.from(createdAt) this.createdAt = Temporal.Instant.from(createdAt)
this.authorName = authorName this.authorName = authorName
this.reactions = reactions
this.possibleReactions = possibleReactions
this.comments = comments
} }
public static fromDto(dto: components['schemas']['PostDto']): Post { public static fromDto(dto: components['schemas']['PostDto']): Post {
@ -53,9 +29,6 @@ export class Post {
dto.media.map((m) => new PostMediaImpl(new URL(m.url), m.width, m.height)), dto.media.map((m) => new PostMediaImpl(new URL(m.url), m.width, m.height)),
Temporal.Instant.from(dto.createdAt), Temporal.Instant.from(dto.createdAt),
dto.author.username, dto.author.username,
dto.reactions.map((r) => ({ ...r, reactedOn: Temporal.Instant.from(r.reactedOn) })),
dto.possibleReactions,
dto.comments.map((c) => ({ ...c, postedOn: Temporal.Instant.from(c.postedOn) })),
) )
} }
} }

View file

@ -1,17 +1,16 @@
import { Post } from './posts.ts' import { Post } from './posts.ts'
import { ApiClient } from '../../api/client.ts' import client from '../../api/client.ts'
import { useUserStore } from '../../user/user.ts'
export class PostsService { export class PostsService {
constructor(private readonly client: ApiClient) {} constructor() {}
async createNew( async createNew(
authorId: string, authorId: string,
content: string, content: string,
media: CreatePostMedia[], media: CreatePostMedia[],
isPublic: boolean, isPublic: boolean,
): Promise<Post> { ): Promise<string> {
const response = await this.client.POST('/posts', { const response = await client.POST('/posts', {
body: { body: {
authorId, authorId,
content, content,
@ -27,68 +26,39 @@ export class PostsService {
throw new Error('Failed to create post') throw new Error('Failed to create post')
} }
return Post.fromDto(response.data.post) return response.data.postId
} }
async load(postId: string): Promise<Post | null> { async loadPublicFeed(cursor: string | null, amount: number | null): Promise<Post[]> {
const response = await this.client.GET('/posts/{postId}', { const response = await client.GET('/posts', {
params: { query: { cursor, amount },
path: { postId },
},
credentials: 'include', credentials: 'include',
}) })
if (!response.data?.post) { if (!response.data) {
return null return []
} }
return Post.fromDto(response.data.post) return response.data?.posts.map((post) => Post.fromDto(post))
} }
async loadPublicFeed(cursor: string | null, amount: number | null): Promise<{ posts: Post[] }> { async loadByAuthor(
const response = await this.client.GET('/posts', { username: string,
cursor: string | null,
amount: number | null,
): Promise<Post[]> {
const response = await client.GET('/posts', {
params: { params: {
query: { After: cursor ?? undefined, Amount: amount ?? undefined }, query: { From: cursor ?? undefined, Amount: amount ?? undefined, Author: username },
}, },
credentials: 'include', credentials: 'include',
}) })
if (!response.data) { if (!response.data) {
return { posts: [] } return []
} }
return { posts: response.data.posts.map(Post.fromDto) } return response.data?.posts.map((post) => Post.fromDto(post))
}
async addReaction(postId: string, emoji: string): Promise<void> {
await this.client.POST('/posts/{postId}/reactions', {
params: {
path: { postId },
},
body: { emoji },
credentials: 'include',
})
}
async removeReaction(postId: string, emoji: string): Promise<void> {
await this.client.DELETE('/posts/{postId}/reactions', {
params: {
path: { postId },
},
body: { emoji },
credentials: 'include',
})
}
async addComment(postId: string, content: string): Promise<void> {
const authorId = useUserStore.getState().user?.id
if (!authorId) return
await this.client.POST('/posts/{postId}/comments', {
params: { path: { postId } },
body: { content, authorId },
credentials: 'include',
})
} }
} }

View file

@ -1,83 +0,0 @@
import { useCallback, useState } from 'react'
import { Post, PostComment, PostMedia, PostReaction } from './posts.ts'
import { Temporal } from '@js-temporal/polyfill'
import { produce } from 'immer'
export interface PostInfo {
postId: string
authorName: string
content: string
createdAt: Temporal.Instant
media: PostMedia[]
possibleReactions: string[]
comments: PostComment[]
}
type ReactionMap = Record<string, PostReaction[]>
export function usePostViewModel() {
const [posts, _setPosts] = useState<PostInfo[]>([])
const [reactions, setReactions] = useState<ReactionMap>({})
const setPosts = useCallback((posts: Post[]) => {
_setPosts([...posts])
setReactions(
posts.reduce((acc, post) => {
acc[post.postId] = [...post.reactions]
return acc
}, {} as ReactionMap),
)
}, [])
const addPosts = useCallback((posts: Post[]) => {
_setPosts((current) => {
return [...current, ...posts]
})
setReactions((current) =>
produce(current, (draft) => {
for (const post of posts) {
draft[post.postId] = [...post.reactions]
}
}),
)
}, [])
function addReaction(
postId: string,
emoji: string,
authorName: string,
reactedOn: Temporal.Instant,
) {
setReactions((current) =>
produce(current, (draft) => {
if (draft[postId]?.some((r) => r.emoji === emoji && r.authorName == authorName)) {
return
}
const reaction: PostReaction = { emoji, authorName, reactedOn }
if (!draft[postId]) {
draft[postId] = [{ ...reaction }]
} else {
draft[postId].push({ ...reaction })
}
}),
)
}
function removeReaction(postId: string, emoji: string, authorName: string) {
setReactions((current) =>
produce(current, (draft) => {
if (!draft[postId]) return
draft[postId] = draft[postId].filter(
(r) => r.emoji !== emoji || r.authorName !== authorName,
)
}),
)
}
return { posts, reactions, addPosts, setPosts, addReaction, removeReaction }
}

View file

@ -1,41 +0,0 @@
import en from './translations/en.json' assert { type: 'json' }
interface Translation {
'auth.login.cta': string
'auth.login.register_instead': string
'auth.password.label': string
'auth.register.cta': string
'auth.register.login_instead': string
'auth.remember_me.label': string
'auth.username.label': string
'misc.loading': string
'nav.admin': string
'nav.home': string
'nav.login': string
'nav.logout': string
'nav.register': string
'post.add_media.cta': string
'post.editor.placeholder': string
'post.public.label': string
'post.submit.cta': string
}
export type TranslationKey = keyof Translation
export interface UseTranslations {
t: <K extends TranslationKey>(key: K) => Translation[K]
}
export function useTranslations(): UseTranslations {
// TODO somehow handle other languages (reactively)
const texts = en as Translation
function getText<K extends TranslationKey>(key: K): Translation[K] {
return texts[key] ?? key
}
return { t: getText }
}

View file

@ -1,19 +0,0 @@
{
"nav.home": "home",
"nav.login": "login",
"nav.register": "register",
"nav.admin": "admin",
"auth.login.cta": "login",
"auth.login.register_instead": "register instead?",
"auth.register.cta": "signup",
"auth.register.login_instead": "login instead?",
"auth.username.label": "username",
"auth.password.label": "password",
"auth.remember_me.label": "stay logged in",
"misc.loading": "wait...",
"nav.logout": "logout",
"post.add_media.cta": "+ add media",
"post.public.label": "public",
"post.submit.cta": "post",
"post.editor.placeholder": "write something..."
}

View file

@ -1,12 +1,12 @@
import { ApiClient } from '../api/client.ts' import client from '../api/client.ts'
export class MediaService { export class MediaService {
constructor(private readonly client: ApiClient) {} constructor() {}
async uploadImage(file: File): Promise<{ mediaId: string; url: URL }> { async uploadFile(file: File): Promise<{ mediaId: string; url: URL }> {
const body = new FormData() const body = new FormData()
body.append('file', file) body.append('file', file)
const response = await this.client.POST('/media', { const response = await client.POST('/media', {
// @ts-expect-error this endpoint takes multipart/form-data which means passing a FormData as the body // @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 // maybe openapi-fetch only wants to handle JSON? who knows
body, body,

View file

@ -1,22 +1,48 @@
import { create } from 'zustand' import { addMessageListener, dispatchMessage } from '../messageBus/messageBus.ts'
import { getCookie } from '../auth/cookies.ts'
import { useMessageListener } from '../../hooks/useMessageListener.ts'
import { useState } from 'react'
import { setGlobal } from '../femtoApp.ts'
export interface User { export interface User {
id: string userId: string
username: string username: string
roles: Role[] isSuperUser: boolean
} }
export enum Role { let globalUser: User | null
User = 0,
SuperUser = 1, export function initUser() {
updateUser()
} }
interface UserState { function updateUser() {
user: User | null globalUser = getUserFromCookie()
setUser: (user: User | null) => void console.debug(globalUser)
setGlobal('user', globalUser)
dispatchMessage('user:updated', globalUser)
} }
export const useUserStore = create<UserState>()((set) => ({ addMessageListener('auth:logged-in', updateUser)
user: null, addMessageListener('auth:registered', updateUser)
setUser: (user: User | null) => set({ user }), addMessageListener('auth:logged-out', updateUser)
})) addMessageListener('auth:refreshed', updateUser)
export function useUser(): User | null {
const [user, setUser] = useState(globalUser)
useMessageListener('user:updated', (u) => {
setUser(u)
})
return user
}
function getUserFromCookie(): User | null {
const userCookie = getCookie('user')
if (!userCookie) return null
// TODO validate but it should be fine
return JSON.parse(decodeURIComponent(userCookie)) as User
}

View file

@ -1,35 +1,15 @@
import { PropsWithChildren, ReactNode } from 'react' import { PropsWithChildren } from 'react'
import { Role, useUserStore } from '../app/user/user.ts' import { useUser } from '../app/user/user.ts'
import NavButton from './buttons/NavButton.tsx' import NavButton from './buttons/NavButton.tsx'
type NavBarProps = { type NavBarProps = unknown
leftChildren?: ReactNode
}
export default function NavBar({ children }: PropsWithChildren<NavBarProps>) { export default function NavBar({ children }: PropsWithChildren<NavBarProps>) {
const user = useUserStore((state) => state.user) const user = useUser()
const isSuperUser = user?.roles.includes(Role.SuperUser)
return ( return (
<nav className={`w-full flex flex-row justify-between px-4 md:px-8 py-3`}> <nav className={`w-full flex flex-row justify-end gap-4 px-4 md:px-8 py-3`}>
<div className={`flex flex-row justify-start gap-4`}></div>
<div className={`flex flex-row justify-end gap-4`}>
{children} {children}
{isSuperUser && <NavButton to={'/admin/codes'}>admin</NavButton>} {user?.isSuperUser && <NavButton to={'/admin/codes'}>admin</NavButton>}
<SourceCodeLink />
</div>
</nav> </nav>
) )
} }
function SourceCodeLink() {
return (
<a
className={`size-6`}
href="https://git.botris.dev/botris.social"
target="_blank"
title={'source code'}
>
<img style={{ color: 'red' }} src="/forgejo-logo-primary.svg" alt="Forgejo Logo" />
</a>
)
}

View file

@ -1,9 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import FancyTextEditor, { TextInputKeyDownEvent } from '../../../components/inputs/FancyTextEditor.tsx' import FancyTextEditor, { TextInputKeyDownEvent } from './inputs/FancyTextEditor.tsx'
import Button from '../../../components/buttons/Button.tsx' import Button from './buttons/Button.tsx'
import { openFileDialog } from '../../../utils/openFileDialog.ts' import { openFileDialog } from '../utils/openFileDialog.ts'
import makePica from 'pica'
import { useTranslations } from '../../i18n/translations.ts'
interface NewPostWidgetProps { interface NewPostWidgetProps {
onSubmit: ( onSubmit: (
@ -23,7 +21,6 @@ interface Attachment {
} }
export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPostWidgetProps) { export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPostWidgetProps) {
const { t } = useTranslations()
const [content, setContent] = useState('') const [content, setContent] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([]) const [attachments, setAttachments] = useState<Attachment[]>([])
const [isPublic, setIsPublic] = useState(false) const [isPublic, setIsPublic] = useState(false)
@ -74,7 +71,7 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
onInput={onContentInput} onInput={onContentInput}
onKeyDown={onInputKeyDown} onKeyDown={onInputKeyDown}
className="mb-3" className="mb-3"
placeholder={t('post.editor.placeholder')} placeholder="write something..."
/> />
{attachments.length > 0 && ( {attachments.length > 0 && (
@ -95,7 +92,7 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
<div className="flex justify-between items-center pt-2"> <div className="flex justify-between items-center pt-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button secondary onClick={onAddMediaClicked}> <Button secondary onClick={onAddMediaClicked}>
{t('post.add_media.cta')} + add media
</Button> </Button>
<label className="flex items-center gap-1 cursor-pointer"> <label className="flex items-center gap-1 cursor-pointer">
<input <input
@ -105,14 +102,14 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
disabled={isSubmitting} disabled={isSubmitting}
className="form-checkbox h-4 w-4 text-blue-600" className="form-checkbox h-4 w-4 text-blue-600"
/> />
<span className="text-primary-500">{t('post.public.label')}</span> <span className="text-primary-500">public</span>
</label> </label>
</div> </div>
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting || (content.trim() === '' && attachments.length === 0)} disabled={isSubmitting || (content.trim() === '' && attachments.length === 0)}
> >
{t('post.submit.cta')} post
</Button> </Button>
</div> </div>
</div> </div>
@ -124,13 +121,11 @@ async function createAttachment(file: File): Promise<Attachment> {
throw new Error('not an image') throw new Error('not an image')
} }
file = await optimizeImageSize(file)
const objectUrl = URL.createObjectURL(file) const objectUrl = URL.createObjectURL(file)
const { width, height } = await getImageFileDimensions(objectUrl) const { width, height } = await getImageFileDimensions(objectUrl)
return { return {
id: getRandomId(), id: crypto.randomUUID(),
file, file,
objectUrl, objectUrl,
width, width,
@ -149,95 +144,3 @@ function getImageFileDimensions(objectURL: string): Promise<{ width: number; hei
img.src = objectURL img.src = objectURL
}) })
} }
const pica = makePica()
async function optimizeImageSize(
file: File,
{
targetMaxWidth = 1920,
targetMaxHeight = 1080,
targetSizeBytes = 500 * 1024,
outputType = 'image/jpeg',
quality = 0.9,
}: {
targetMaxWidth?: number
targetMaxHeight?: number
targetSizeBytes?: number
outputType?: string
quality?: number
} = {},
): Promise<File> {
const img = document.createElement('img')
const url = URL.createObjectURL(file)
img.src = url
await img.decode()
console.debug('processing image', {
width: img.width,
height: img.height,
targetMaxWidth,
targetMaxHeight,
targetSizeBytes,
outputType,
quality,
})
const scale = Math.min(1, targetMaxWidth / img.width, targetMaxHeight / img.height)
const width = Math.floor(img.width * scale)
const height = Math.floor(img.height * scale)
const originalSize = file.size
const srcCanvas = document.createElement('canvas')
srcCanvas.width = img.width
srcCanvas.height = img.height
srcCanvas.getContext('2d')!.drawImage(img, 0, 0)
const dstCanvas = document.createElement('canvas')
dstCanvas.width = width
dstCanvas.height = height
try {
// TODO resistFingerprinting in FF and other causes this to break.
// knowing this, i would still rather be able to post from other browsers for now
// and will hopefully find a better solution
await pica.resize(srcCanvas, dstCanvas)
} catch (e) {
console.error('cant resize image', e)
return file
}
let blob = await pica.toBlob(dstCanvas, outputType, quality)
while (blob.size > targetSizeBytes && quality > 0.1) {
quality -= 0.1
blob = await pica.toBlob(dstCanvas, outputType, quality)
}
console.debug(
`optimized image rendered at ${Math.round(quality * 100)}% quality to ${blob.size / 1000}KB from ${originalSize / 1000}KB`,
)
URL.revokeObjectURL(url)
return new File([blob], file.name, { type: file.type })
}
function getRandomId() {
if (window.isSecureContext) {
return crypto.randomUUID()
}
// Fallback using getRandomValues
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
// Format according to RFC4122 version 4
bytes[6] = (bytes[6]! & 0x0f) | 0x40
bytes[8] = (bytes[8]! & 0x3f) | 0x80
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0'))
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`
}

View file

@ -15,7 +15,7 @@ export interface TextInputKeyDownEvent {
} }
export default function FancyTextEditor({ export default function FancyTextEditor({
value: value, value: _value,
onInput, onInput,
onKeyDown, onKeyDown,
className: extraClasses = '', className: extraClasses = '',
@ -24,7 +24,10 @@ export default function FancyTextEditor({
const divRef = useRef<HTMLDivElement>(null) const divRef = useRef<HTMLDivElement>(null)
const [hasFocus, setHasFocus] = useState(false) const [hasFocus, setHasFocus] = useState(false)
const trimmedValue = value.trim() // the contenteditable likes to slip in newlines at the bottom of our innerText
// which makes it bad to check for empty string because it might be "\n"
// so we just trim it upfront and then fogeddaboudit
const value = _value.trim()
// The funky mechanics here are to stop the cursor from jumping back the start. // The funky mechanics here are to stop the cursor from jumping back the start.
// It probably will have the cursor jump to the start if anything changes programmatically, // It probably will have the cursor jump to the start if anything changes programmatically,
@ -35,12 +38,12 @@ export default function FancyTextEditor({
return return
} }
if (!trimmedValue && !hasFocus) { if (!value && !hasFocus) {
div.innerText = placeholder div.innerText = placeholder
} else if (div.innerText.trim() !== trimmedValue) { } else if (div.innerText !== value) {
div.innerText = trimmedValue div.innerText = value
} }
}, [hasFocus, placeholder, trimmedValue]) }, [hasFocus, placeholder, value])
useEffect(() => { useEffect(() => {
const div = divRef.current! const div = divRef.current!

16
src/initApp.ts Normal file
View file

@ -0,0 +1,16 @@
import { initUser } from './app/user/user.ts'
import { setGlobal } from './app/femtoApp.ts'
import { PostsService } from './app/feed/posts/postsService.ts'
import { MediaService } from './app/media/mediaService.ts'
import { AuthService } from './app/auth/authService.ts'
export function initApp() {
setGlobal('version', import.meta.env.VITE_FEMTO_VERSION)
initUser()
const postService = new PostsService()
const mediaService = new MediaService()
const authService = new AuthService()
return { postService, mediaService, authService }
}

2
src/types.d.ts vendored
View file

@ -19,6 +19,4 @@ declare global {
export interface FemtoApp { export interface FemtoApp {
version: string version: string
user: User | null user: User | null
authService: AuthService | null
postsService: PostsService | null
} }

View file

@ -1,11 +1,11 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useUser } from './app/user/user.ts'
import { AuthService } from './app/auth/authService.ts' import { AuthService } from './app/auth/authService.ts'
import { useUserStore } from './app/user/user.ts'
// Starts a loop that pings the server to keep the session alive, while also getting any updates on the user profile // Starts a loop that pings the server to keep the session alive, while also getting any updates on the user profile
export function useRefreshSessionLoop(authService: AuthService) { export function useRefreshSessionLoop(authService: AuthService) {
const user = useUserStore((state) => state.user) const user = useUser()
const userId = user?.id ?? null const userId = user?.userId ?? null
useEffect(() => { useEffect(() => {
if (userId == null) { if (userId == null) {
@ -17,7 +17,6 @@ export function useRefreshSessionLoop(authService: AuthService) {
timeouts.push( timeouts.push(
setTimeout(async function refreshUser() { setTimeout(async function refreshUser() {
await authService.refreshUser(userId) await authService.refreshUser(userId)
timeouts.push(setTimeout(refreshUser, 60_000)) timeouts.push(setTimeout(refreshUser, 60_000))
}), }),
) )

View file

@ -1,17 +0,0 @@
import { useCallback, useRef } from 'react'
export function useDebounce<Args extends unknown[]>(
fn: (...args: Args) => Promise<void>,
delay: number,
) {
const timeout = useRef<ReturnType<typeof setTimeout> | null>(null)
return useCallback(
(...args: Args) => {
if (timeout.current) clearTimeout(timeout.current)
setTimeout(() => fn(...args), delay)
},
[delay, fn],
)
}

View file

@ -1,3 +0,0 @@
export function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View file

@ -1,19 +0,0 @@
export function groupByAndMap<T, U>(
items: T[],
groupBy: (item: T) => string,
map: (item: T) => U,
): Record<string, U[]> {
const groupings: Record<string, U[]> = {}
for (const item of items) {
const key = groupBy(item)
if (!groupings[key]) {
groupings[key] = []
}
groupings[key].push(map(item))
}
return groupings
}

View file

@ -3,13 +3,11 @@ import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import fs from 'node:fs' import fs from 'node:fs'
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf-8'))
process.env.VITE_FEMTO_VERSION = packageJson.version
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig(() => { export default defineConfig({
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf-8'))
process.env.VITE_FEMTO_VERSION = packageJson.version
return {
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
}
}) })

View file

@ -785,11 +785,6 @@
dependencies: dependencies:
undici-types "~6.21.0" undici-types "~6.21.0"
"@types/pica@^9.0.5":
version "9.0.5"
resolved "https://registry.yarnpkg.com/@types/pica/-/pica-9.0.5.tgz#a526b51d45b7cb70423b7af0223ab9afd151a26e"
integrity sha512-OSd4905yxFNtRanHuyyQAfC9AkxiYcbhlzP606Gl6rFcYRgq4vdLCZuYKokLQBihgrkNzyPkoeykvJDWcPjaCw==
"@types/react-dom@^19.0.4": "@types/react-dom@^19.0.4":
version "19.1.3" version "19.1.3"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.3.tgz#3f0c60804441bf34d19f8dd0d44405c0c0e21bfa" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.3.tgz#3f0c60804441bf34d19f8dd0d44405c0c0e21bfa"
@ -1579,11 +1574,6 @@ globals@^16.0.0:
resolved "https://registry.yarnpkg.com/globals/-/globals-16.0.0.tgz#3d7684652c5c4fbd086ec82f9448214da49382d8" resolved "https://registry.yarnpkg.com/globals/-/globals-16.0.0.tgz#3d7684652c5c4fbd086ec82f9448214da49382d8"
integrity sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A== integrity sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A==
glur@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/glur/-/glur-1.1.2.tgz#f20ea36db103bfc292343921f1f91e83c3467689"
integrity sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==
gopd@^1.2.0: gopd@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
@ -1932,14 +1922,6 @@ ms@^2.1.3:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
multimath@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/multimath/-/multimath-2.0.0.tgz#0d37acf67c328f30e3d8c6b0d3209e6082710302"
integrity sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==
dependencies:
glur "^1.1.2"
object-assign "^4.1.1"
nanoid@^3.3.8: nanoid@^3.3.8:
version "3.3.11" version "3.3.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
@ -1960,7 +1942,7 @@ node-releases@^2.0.19:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314"
integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==
object-assign@^4, object-assign@^4.1.1: object-assign@^4:
version "4.1.1" version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
@ -2070,16 +2052,6 @@ path-to-regexp@^8.0.0:
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz#73990cc29e57a3ff2a0d914095156df5db79e8b4" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz#73990cc29e57a3ff2a0d914095156df5db79e8b4"
integrity sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ== integrity sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==
pica@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/pica/-/pica-9.0.1.tgz#9ba5a5e81fc09dca9800abef9fb8388434b18b2f"
integrity sha512-v0U4vY6Z3ztz9b4jBIhCD3WYoecGXCQeCsYep+sXRefViL+mVVoTL+wqzdPeE+GpBFsRUtQZb6dltvAt2UkMtQ==
dependencies:
glur "^1.1.2"
multimath "^2.0.0"
object-assign "^4.1.1"
webworkify "^1.5.0"
picocolors@^1.1.1: picocolors@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
@ -2523,11 +2495,6 @@ vite@^6.3.1:
optionalDependencies: optionalDependencies:
fsevents "~2.3.3" fsevents "~2.3.3"
webworkify@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/webworkify/-/webworkify-1.5.0.tgz#734ad87a774de6ebdd546e1d3e027da5b8f4a42c"
integrity sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==
which@^2.0.1: which@^2.0.1:
version "2.0.2" version "2.0.2"
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
@ -2574,8 +2541,3 @@ zod@^3.23.8, zod@^3.24.2:
version "3.24.3" version "3.24.3"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.3.tgz#1f40f750a05e477396da64438e0e1c0995dafd87" resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.3.tgz#1f40f750a05e477396da64438e0e1c0995dafd87"
integrity sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg== integrity sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==
zustand@^5.0.7:
version "5.0.7"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.7.tgz#e325364e82c992a84bf386d8445aa7f180c450dc"
integrity sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==