Compare commits

..

87 commits
v1.5.0 ... main

Author SHA1 Message Date
88348ed6e1 v1.26.6 2025-08-10 21:23:22 +02:00
91e1116532 comments 2025-08-10 21:20:47 +02:00
52b5a490ac v1.26.5 2025-08-10 18:46:39 +02:00
964ba05724 fix stupid AI bullshit fuckery 2025-08-10 18:45:54 +02:00
a0c450384d v1.26.4 2025-08-10 18:44:18 +02:00
9bc2da8748 remove janky fake alt text 2025-08-10 18:43:32 +02:00
380a9a4f36 v1.26.3 2025-08-10 18:42:34 +02:00
7ecea242f2 add post reaction timeline 2025-08-10 18:41:45 +02:00
5aeed54b20 v1.26.2 2025-08-10 18:15:44 +02:00
30025b4044 refactor post model 2025-08-10 18:08:17 +02:00
62f9de9546 add home link to nav 2025-08-10 16:22:13 +02:00
5f29bc436c use zustand 2025-08-10 16:16:23 +02:00
d2d358bff2 v1.26.1 2025-08-10 13:47:57 +01:00
0afe5eac04 fix TS error 2025-08-10 13:47:36 +01:00
74a05e4678 make post page 2025-08-10 13:45:31 +01:00
aded5a3674 add cursor pointer 2025-08-01 20:58:06 +01:00
cfae831b64 v1.26.0 2025-07-19 12:34:25 +02:00
08defcc894 unhide remember me checkbox 2025-06-21 11:42:39 +02:00
8d1b79b6a3 v1.25.1 2025-06-17 11:28:53 +02:00
7fab3d0d9f add source code link 2025-06-17 11:18:23 +02:00
f7771c7df3 tweak help text 2025-06-17 10:47:46 +02:00
f21d20e08c niceify build script 2025-06-17 10:30:54 +02:00
df0a145f3b add deployment to build script 2025-06-17 10:09:19 +02:00
58a214444f v1.25.0 2025-06-17 09:59:03 +02:00
c0d08897c1 change api url 2025-06-17 09:58:24 +02:00
1fc52a16b9 v1.24.0 2025-06-17 09:57:01 +02:00
4687f0408b adjust spacing in login page to match signup 2025-06-17 09:56:22 +02:00
17ee016398 v1.23.0 2025-06-17 09:46:40 +02:00
bf4e7b27dc just ignore email always 2025-06-17 09:46:21 +02:00
1710d5d91d remove email from authcodes 2025-06-17 09:44:30 +02:00
bc5c2075f4 some changes 2025-06-16 21:28:24 +02:00
5e96ab6955 use dynamic translations 2025-06-15 20:13:18 +02:00
8d2cc0f47b v1.22.0 2025-06-15 19:33:54 +02:00
55fb78ee98 fix roles 2025-06-15 19:32:56 +02:00
a070b30def v1.21.0 2025-06-11 23:22:19 +02:00
4569eedb6b just make the dang thing hidden >:( 2025-06-11 23:22:07 +02:00
52d22ed0d6 comment out the remember me checkbox 2025-06-11 23:18:14 +02:00
ac36a1e3c2 v1.20.0 2025-06-11 23:12:50 +02:00
95ea2a5f23 fix loading feed 2025-06-11 23:12:03 +02:00
cf5494c15b v1.19.0 2025-06-11 22:50:59 +02:00
3b4f384582 add remember me checkbox 2025-05-29 13:21:37 +02:00
334435cf78 v1.18.0 2025-05-28 22:40:50 +02:00
6aeff51857 ignore resist fingerprinting error when resizing image
TODO find another way
2025-05-28 22:35:20 +02:00
ca2d377fc4 make email not required for codes 2025-05-28 22:22:17 +02:00
d1288b8e69 v1.17.0 2025-05-28 22:09:01 +02:00
e16f7941d5 remove unused thing 2025-05-28 22:08:15 +02:00
c21e193fbf reactions 2025-05-28 22:05:51 +02:00
48e7094c5e add and remove reactions 2025-05-28 20:49:08 +02:00
dab626f227 handle returned post from create post endpoint 2025-05-28 20:05:10 +02:00
83835d374b use reactions from post 2025-05-28 19:56:30 +02:00
72389136a7 wip emoji reactions 2025-05-26 23:48:30 +02:00
48ea06294e fix fancy text editor bug 2025-05-26 22:30:34 +02:00
578d348728 v1.16.0 2025-05-26 22:19:23 +02:00
f147933701 make optimization slightly less wonky? 2025-05-26 22:17:49 +02:00
48f1b873a5 v1.15.0 2025-05-26 22:10:11 +02:00
a6022d31c6 shrink imagies 2025-05-26 22:05:52 +02:00
8457604da7 v1.14.0 2025-05-21 13:50:01 +02:00
16194b301b umami 2025-05-21 13:49:17 +02:00
38db3baa0e v1.13.0 2025-05-20 23:49:59 +02:00
80f9b43022 fix super user check 2025-05-20 23:49:45 +02:00
8b92a780bf remove logs 2025-05-20 23:45:40 +02:00
cc3d138fd3 fix user refresh 2025-05-20 23:43:43 +02:00
0b2a64e449 update version commit message 2025-05-20 11:50:53 +02:00
563ca88ed5 chore(release): v1.12.0 2025-05-20 11:50:30 +02:00
ff10228907 remove some debugs 2025-05-20 11:47:59 +02:00
0cbcab6597 injext client 2025-05-20 11:47:16 +02:00
bc97d009ae add debugging 2025-05-20 11:35:58 +02:00
227ad7e0b4 chore(release): v1.11.0 2025-05-20 11:23:57 +02:00
bc2a15bc40 force loading images with https 2025-05-20 11:22:59 +02:00
76843ba7e8 chore(release): v1.10.0 2025-05-20 10:53:55 +02:00
3ff6ef11b0 add some debugs 2025-05-20 10:53:10 +02:00
2eb1656123 chore(release): v1.9.0 2025-05-20 10:47:29 +02:00
7a6c379e79 fix random error 2025-05-20 10:47:21 +02:00
538cdaf8a1 fix random error 2025-05-20 10:46:23 +02:00
248f382276 add version 2025-05-20 10:44:37 +02:00
ff44a2f69f chore(release): v1.8.0 2025-05-20 10:07:09 +02:00
700eaf3eb2 use user from session 2025-05-20 10:06:18 +02:00
5f47162a50 fix user refresher 2025-05-19 17:52:18 +02:00
17c9885ccc refresh user 2025-05-19 09:23:15 +02:00
57e56dc33d chore(release): v1.7.0 2025-05-18 23:08:20 +02:00
21983c3f44 fix code thing dang 2025-05-18 23:07:31 +02:00
6a216d7dd1 chore(release): v1.6.0 2025-05-18 22:58:24 +02:00
f7558f3c93 save code when it's there 2025-05-18 22:57:06 +02:00
bf07b6da4f copy to clipboard 2025-05-18 22:49:06 +02:00
4878897306 signups 2025-05-18 22:45:27 +02:00
27098ec9fa update authservice 2025-05-18 21:20:32 +02:00
ca0a6b2950 fix signup code usage 2025-05-18 19:11:32 +02:00
57 changed files with 2158 additions and 476 deletions

1
.env
View file

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

2
.gitignore vendored
View file

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

View file

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

View file

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

View file

@ -0,0 +1,40 @@
<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>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -1,57 +0,0 @@
#!/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

@ -14,9 +14,7 @@ export async function generateApiSchema(openapiUrl, outputFilePath, pathToPretti
const request = new Request(openapiUrl)
const response = await fetch(request)
const json = await response.text()
const ast = await openapiTS(json, {
pathParamsAsTypes: true,
})
const ast = await openapiTS(json, {})
const prettierConfig = await resolveConfig(pathToPrettierRc, {
useCache: true,
})

View file

@ -1,28 +0,0 @@
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`
}

101
scripts/publish.sh Executable file
View file

@ -0,0 +1,101 @@
#!/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,20 +1,42 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import HomePage from './app/feed/pages/HomePage.tsx'
import { PostsService } from './app/feed/posts/postsService.ts'
import AuthorPage from './app/feed/pages/AuthorPage.tsx'
import { MediaService } from './app/media/mediaService.ts'
import PostPage from './app/feed/pages/PostPage.tsx'
import SignupPage from './app/auth/pages/SignupPage.tsx'
import LoginPage from './app/auth/pages/LoginPage.tsx'
import { AuthService } from './app/auth/authService.ts'
import { useUser } from './app/user/userStore.ts'
import LogoutPage from './app/auth/pages/LogoutPage.tsx'
import UnauthorizedHandler from './app/auth/components/UnauthorizedHandler.tsx'
import AdminPage from './app/admin/pages/AdminPage.tsx'
import SignupCodesManagementPage from './app/admin/pages/subpages/SignupCodesManagementPage.tsx'
import { useRefreshSessionLoop } from './useRefreshSessionLoop.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'
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'
function App() {
const { user } = useUser()
const postService = new PostsService()
const mediaService = new MediaService()
const authService = new AuthService(user)
setGlobal('version', import.meta.env.VITE_FEMTO_VERSION)
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() {
const setUser = useUserStore((state) => state.setUser)
useRefreshSessionLoop(authService)
useEffect(() => {
const user = getUserFromCookie()
console.debug('got user cookie', user)
setUser(user)
}, [setUser])
return (
<BrowserRouter>
@ -24,14 +46,18 @@ function App() {
path={'/'}
element={<HomePage postsService={postService} mediaService={mediaService} />}
/>
<Route path="/u/:username" element={<AuthorPage postsService={postService} />} />
<Route path={'/p/:postId'} element={<PostPage postsService={postService} />} />
<Route path="/login" element={<LoginPage authService={authService} />} />
<Route path="/logout" element={<LogoutPage authService={authService} />} />
<Route path="/signup/:code?" element={<SignupPage authService={authService} />} />
<Route path={'/admin'} element={<AdminPage />}>
<Route
path={'codes'}
element={<SignupCodesManagementPage authService={authService} />}
/>
</Route>
</Routes>
</UnauthorizedHandler>
</BrowserRouter>
)
}
export default App

View file

@ -0,0 +1,24 @@
import NavBar from '../../../components/NavBar'
import NavButton from '../../../components/buttons/NavButton'
import { Outlet } from 'react-router-dom'
export default function AdminPage() {
return (
<div>
<NavBar>
<NavButton to={'/'}>home</NavButton>
</NavBar>
<div className={'w-full max-w-6xl mx-auto grid grid-cols-4'}>
<nav className={'flex flex-col'}>
<NavButton className={'w-full py-4 px-2 text-2xl'} to={'/admin/codes'}>
codes
</NavButton>
</nav>
<div className={'col-span-3 outline-primary-500'}>
<Outlet />
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,218 @@
import { AuthService } from '../../../auth/authService.ts'
import { useEffect, useState, useRef, MouseEvent, useCallback } from 'react'
import { SignupCode } from '../../../auth/signupCode.ts'
import { Temporal } from '@js-temporal/polyfill'
import Button from '../../../../components/buttons/Button.tsx'
interface SignupCodesManagementPageProps {
authService: AuthService
}
export default function SignupCodesManagementPage({ authService }: SignupCodesManagementPageProps) {
const [codes, setCodes] = useState<SignupCode[]>([])
const [code, setCode] = useState('')
const [name, setName] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const dialogRef = useRef<HTMLDialogElement>(null)
const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null)
const [activeCode, setActiveCode] = useState<string | null>(null)
const fetchCodes = useCallback(async () => {
try {
setCodes(await authService.listSignupCodes())
} catch (err) {
console.error('Failed to fetch signup codes:', err)
}
}, [authService])
useEffect(() => {
const timeoutId = setTimeout(fetchCodes)
return () => clearTimeout(timeoutId)
}, [authService, fetchCodes])
const handleCreateCode = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError(null)
try {
await authService.createSignupCode(code, name)
setCode('')
setName('')
dialogRef.current?.close()
fetchCodes()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create signup code')
} finally {
setIsLoading(false)
}
}
const openDialog = () => {
dialogRef.current?.showModal()
}
const closeDialog = () => {
dialogRef.current?.close()
setError(null)
}
const formatDate = (date: Temporal.Instant | null) => {
if (!date) return 'Never'
try {
const jsDate = new Date(date.epochMilliseconds)
// Format as: "Jan 1, 2023, 12:00 PM"
return jsDate.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
} catch (err: unknown) {
console.error(err)
return date.toString()
}
}
const copyCodeToClipboard = (code: string, e: MouseEvent) => {
e.preventDefault()
const host = window.location.origin
const url = `${host}?c=${code}`
navigator.clipboard.writeText(url).catch((err) => {
console.error('Failed to copy:', err)
})
setTooltipPosition(null)
setActiveCode(null)
}
const showTooltip = (code: string, e: MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
setTooltipPosition({
x: rect.left + rect.width / 2,
y: rect.top - 10,
})
setActiveCode(code)
}
const hideTooltip = () => {
setTooltipPosition(null)
setActiveCode(null)
}
return (
<>
<div className="flex justify-between items-center mb-4">
<h1 className="text-xl font-bold">Signup Codes</h1>
<Button onClick={openDialog}>Create New Code</Button>
</div>
<div className="overflow-x-auto">
<table className="min-w-full bg-white">
<thead>
<tr>
<th className="text-left">Code</th>
<th className="text-left">Redeemed By</th>
<th className="text-left">Expires On</th>
</tr>
</thead>
<tbody>
{codes.map((code) => (
<tr key={code.code} className="hover:bg-gray-50">
<td className="py-2 ">
<button
className="bg-primary-100 px-2 py-1 rounded font-mono text-sm cursor-pointer hover:bg-primary-200 transition-colors"
onClick={(e) => copyCodeToClipboard(code.code, e)}
onMouseEnter={(e) => showTooltip(code.code, e)}
onMouseLeave={hideTooltip}
>
{code.code}
</button>
</td>
<td>{code.redeemedBy || 'Not redeemed'}</td>
<td>{formatDate(code.expiresOn)}</td>
</tr>
))}
{codes.length === 0 && (
<tr>
<td colSpan={4} className="py-4 text-center text-gray-500">
No signup codes found
</td>
</tr>
)}
</tbody>
</table>
</div>
<dialog
ref={dialogRef}
className="p-6 rounded-lg shadow-lg backdrop-blur-sm w-full max-w-md m-auto"
>
<h2 className="text-xl font-bold mb-4">Create New Signup Code</h2>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
<form onSubmit={handleCreateCode}>
<div className="mb-4">
<label htmlFor="code" className="block text-sm font-medium text-gray-700 mb-1">
Code ID
</label>
<input
type="text"
id="code"
value={code}
onChange={(e) => setCode(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
required
/>
</div>
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Name
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(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">
<Button type="button" onClick={closeDialog} secondary>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Creating...' : 'Create Code'}
</Button>
</div>
</form>
</dialog>
{tooltipPosition && activeCode && (
<dialog
open
className="fixed p-2 bg-gray-800 text-white text-xs rounded shadow-lg z-50"
style={{
left: `${tooltipPosition.x}px`,
top: `${tooltipPosition.y}px`,
transform: 'translate(-50%, -100%)',
margin: 0,
border: 'none',
}}
>
Copy to clipboard
</dialog>
)}
</>
)
}

View file

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

View file

@ -9,7 +9,7 @@ export interface paths {
get: {
parameters: {
query?: {
From?: string
After?: string
Amount?: number
AuthorId?: string
Author?: string
@ -26,9 +26,9 @@ export interface paths {
[name: string]: unknown
}
content: {
'text/plain': components['schemas']['GetAllPublicPostsResponse']
'application/json': components['schemas']['GetAllPublicPostsResponse']
'text/json': components['schemas']['GetAllPublicPostsResponse']
'text/plain': components['schemas']['LoadPostsResponse']
'application/json': components['schemas']['LoadPostsResponse']
'text/json': components['schemas']['LoadPostsResponse']
}
}
}
@ -68,6 +68,171 @@ export interface paths {
patch?: 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': {
parameters: {
query?: never
@ -112,7 +277,7 @@ export interface paths {
patch?: never
trace?: never
}
[path: `/media/${string}`]: {
'/media/{id}': {
parameters: {
query?: never
header?: never
@ -233,6 +398,78 @@ export interface paths {
patch?: 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': {
parameters: {
query?: never
@ -266,10 +503,198 @@ export interface paths {
patch?: never
trace?: never
}
'/auth/user/{userId}': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get: {
parameters: {
query?: never
header?: never
path: {
userId: string
}
cookie?: never
}
requestBody?: never
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown
}
content: {
'text/plain': components['schemas']['GetUserInfoResult']
'application/json': components['schemas']['GetUserInfoResult']
'text/json': components['schemas']['GetUserInfoResult']
}
}
}
}
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/auth/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: {
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/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 interface components {
schemas: {
AddPostCommentRequest: {
/** Format: uuid */
authorId: string
content: string
}
AddPostReactionRequest: {
emoji: string
}
ChangePasswordRequestBody: {
/** Format: uuid */
userId: string
newPassword: string
}
CreatePostRequest: {
/** Format: uuid */
authorId: string
@ -289,36 +714,64 @@ export interface components {
height: number | null
}
CreatePostResponse: {
/** Format: uuid */
postId: string
post: components['schemas']['PostDto']
}
GetAllPublicPostsResponse: {
posts: components['schemas']['PostDto'][]
CreateSignupCodeRequest: {
code: string
email: string
name: string
}
DeletePostReactionRequest: {
emoji: string
}
GetPostResponse: {
post: components['schemas']['PostDto']
}
GetUserInfoResult: {
/** Format: uuid */
next: string | null
userId: string
username: string
isSuperUser: boolean
}
ListSignupCodesResult: {
signupCodes: components['schemas']['SignupCodeDto'][]
}
LoadPostsResponse: {
posts: components['schemas']['PostDto'][]
}
LoginRequest: {
username: string
password: string
rememberMe: boolean
}
LoginResponse: {
/** Format: uuid */
userId: string
username: string
isSuperUser: boolean
}
PostAuthorDto: {
/** Format: uuid */
authorId: string
username: string
}
PostCommentDto: {
author: string
content: string
/** Format: date-time */
postedOn: string
}
PostDto: {
author: components['schemas']['PostAuthorDto']
/** Format: uuid */
postId: string
content: string
media: components['schemas']['PostMediaDto'][]
reactions: components['schemas']['PostReactionDto'][]
/** Format: date-time */
createdAt: string
possibleReactions: string[]
comments: components['schemas']['PostCommentDto'][]
}
PostMediaDto: {
/** Format: uri */
@ -328,16 +781,33 @@ export interface components {
/** Format: int32 */
height: number | null
}
PostReactionDto: {
emoji: string
authorName: string
/** Format: date-time */
reactedOn: string
}
RegisterRequest: {
username: string
password: string
signupCode: string
email: string | null
rememberMe: boolean
}
RegisterResponse: {
/** Format: uuid */
userId: string
username: string
isSuperUser: boolean
}
SignupCodeDto: {
code: string
email: string
name: string
/** Format: uuid */
redeemingUserId: string | null
redeemingUsername: string | null
/** Format: date-time */
expiresOn: string | null
}
UploadMediaResponse: {
/** Format: uuid */

View file

@ -1,17 +1,14 @@
import { User } from '../user/userStore.ts'
import { dispatchMessage } from '../messageBus/messageBus.ts'
import client from '../api/client.ts'
import { ProblemDetails } from '../../types'
import { SignupCode } from './signupCode.ts'
import { ApiClient } from '../api/client.ts'
export class AuthService {
constructor(private readonly user: User | null) {}
constructor(private readonly client: ApiClient) {}
async login(username: string, password: string) {
if (this.user != null) {
throw new Error('already logged in')
}
const res = await client.POST('/auth/login', {
body: { username, password },
async login(username: string, password: string, rememberMe: boolean = false) {
const res = await this.client.POST('/auth/login', {
body: { username, password, rememberMe },
credentials: 'include',
})
@ -19,33 +16,67 @@ export class AuthService {
throw new Error('invalid credentials')
}
dispatchMessage('auth:logged-in', { ...res.data })
dispatchMessage('auth:logged-in', null)
}
async signup(username: string, password: string, signupCode: string) {
if (this.user != null) {
throw new Error('already logged in')
}
const res = await client.POST('/auth/register', {
body: { username, password, signupCode, email: null },
async signup(
username: string,
password: string,
signupCode: string,
rememberMe: boolean = false,
) {
const res = await this.client.POST('/auth/register', {
body: { username, password, signupCode, email: null, rememberMe },
credentials: 'include',
})
if (!res.data) {
throw new Error('invalid credentials')
console.error(res.error)
throw new Error((res.error as ProblemDetails)?.detail ?? 'invalid credentials')
}
dispatchMessage('auth:registered', { ...res.data })
dispatchMessage('auth:registered', null)
}
async logout() {
if (this.user == null) {
return
}
await client.DELETE('/auth/session', { credentials: 'include' })
await this.client.DELETE('/auth/session', { credentials: 'include' })
dispatchMessage('auth:logged-out', null)
}
async createSignupCode(code: string, name: string) {
const res = await this.client.POST('/auth/signup-codes', {
body: { code, email: '', name },
credentials: 'include',
})
if (!res.data) {
console.error(res.error)
throw new Error('failed to create signup code')
}
}
async listSignupCodes() {
const res = await this.client.GET('/auth/signup-codes', {
credentials: 'include',
})
if (!res.data) {
console.error(res.error)
throw new Error('error')
}
return res.data.signupCodes.map(SignupCode.fromDto)
}
async refreshUser(userId: string) {
await this.client.GET(`/auth/user/{userId}`, {
params: {
path: { userId },
},
credentials: 'include',
})
dispatchMessage('auth:refreshed', null)
}
}

View file

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

View file

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

View file

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

16
src/app/auth/cookies.ts Normal file
View file

@ -0,0 +1,16 @@
export function getCookie(cookieName: string): string | undefined {
return getCookies().get(cookieName)
}
export function getCookies(): Map<string, string> {
return document.cookie
.split('; ')
.map((c) => {
const [name, value] = c.split('=') as [string, string]
return { name, value }
})
.reduce((acc, c) => {
acc.set(c.name, c.value)
return acc
}, new Map<string, string>())
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,20 @@
import { Temporal } from '@js-temporal/polyfill'
import { components } from '../api/schema.ts'
export class SignupCode {
constructor(
public readonly code: string,
public readonly email: string,
public readonly redeemedBy: string | null,
public readonly expiresOn: Temporal.Instant | null,
) {}
static fromDto(dto: components['schemas']['SignupCodeDto']): SignupCode {
return new SignupCode(
dto.code,
dto.email,
dto.redeemingUsername,
dto.expiresOn ? Temporal.Instant.from(dto.expiresOn) : null,
)
}
}

View file

@ -1,36 +0,0 @@
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

@ -1,29 +0,0 @@
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

@ -0,0 +1,58 @@
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,7 +1,9 @@
import { useState } from 'react'
import FancyTextEditor, { TextInputKeyDownEvent } from './inputs/FancyTextEditor.tsx'
import Button from './buttons/Button.tsx'
import { openFileDialog } from '../utils/openFileDialog.ts'
import FancyTextEditor, { TextInputKeyDownEvent } from '../../../components/inputs/FancyTextEditor.tsx'
import Button from '../../../components/buttons/Button.tsx'
import { openFileDialog } from '../../../utils/openFileDialog.ts'
import makePica from 'pica'
import { useTranslations } from '../../i18n/translations.ts'
interface NewPostWidgetProps {
onSubmit: (
@ -21,6 +23,7 @@ interface Attachment {
}
export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPostWidgetProps) {
const { t } = useTranslations()
const [content, setContent] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([])
const [isPublic, setIsPublic] = useState(false)
@ -71,7 +74,7 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
onInput={onContentInput}
onKeyDown={onInputKeyDown}
className="mb-3"
placeholder="write something..."
placeholder={t('post.editor.placeholder')}
/>
{attachments.length > 0 && (
@ -92,7 +95,7 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
<div className="flex justify-between items-center pt-2">
<div className="flex items-center gap-2">
<Button secondary onClick={onAddMediaClicked}>
+ add media
{t('post.add_media.cta')}
</Button>
<label className="flex items-center gap-1 cursor-pointer">
<input
@ -102,14 +105,14 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
disabled={isSubmitting}
className="form-checkbox h-4 w-4 text-blue-600"
/>
<span className="text-primary-500">public</span>
<span className="text-primary-500">{t('post.public.label')}</span>
</label>
</div>
<Button
onClick={handleSubmit}
disabled={isSubmitting || (content.trim() === '' && attachments.length === 0)}
>
post
{t('post.submit.cta')}
</Button>
</div>
</div>
@ -121,11 +124,13 @@ async function createAttachment(file: File): Promise<Attachment> {
throw new Error('not an image')
}
file = await optimizeImageSize(file)
const objectUrl = URL.createObjectURL(file)
const { width, height } = await getImageFileDimensions(objectUrl)
return {
id: crypto.randomUUID(),
id: getRandomId(),
file,
objectUrl,
width,
@ -144,3 +149,95 @@ function getImageFileDimensions(objectURL: string): Promise<{ width: number; hei
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

@ -1,12 +1,24 @@
import { Post, PostMedia } from '../posts/posts.ts'
import { Link } from 'react-router-dom'
import { PostMedia, PostReaction } from '../posts/posts.ts'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { PostInfo } from '../posts/usePostViewModel.ts'
import { useUserStore } from '../../user/user.ts'
interface PostItemProps {
post: Post
post: PostInfo
reactions: PostReaction[]
addReaction: (emoji: string) => void
clearReaction: (emoji: string) => void
hideViewButton?: boolean
}
export default function PostItem({ post }: PostItemProps) {
export default function PostItem({
post,
reactions,
addReaction,
clearReaction,
hideViewButton = false,
}: PostItemProps) {
const formattedDate = post.createdAt.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
@ -29,10 +41,15 @@ export default function PostItem({ post }: PostItemProps) {
return (
<article className={`w-full p-4 ${opacity} transition-opacity duration-500`} key={post.postId}>
<div className="text-sm text-gray-500 mb-3">
<Link to={`/u/${post.authorName}`} className="text-gray-400 hover:underline mr-2">
@{post.authorName}
<span className="text-gray-400 mr-2">@{post.authorName}</span> {formattedDate}
{!hideViewButton && (
<>
{' • '}
<Link to={`/p/${post.postId}`} className="ml-2 text-primary-400 hover:underline">
View
</Link>
{formattedDate}
</>
)}
</div>
<div className="text-gray-800 mb-4 whitespace-pre-wrap">{post.content}</div>
@ -44,24 +61,95 @@ export default function PostItem({ post }: PostItemProps) {
))}
</div>
)}
<PostReactions
post={post}
reactions={reactions}
addReaction={addReaction}
clearReaction={clearReaction}
/>
</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 {
media: PostMedia
}
function PostMediaItem({ media }: PostMediaProps) {
const url = media.url.toString()
const url = new URL(media.url.toString())
if (location.protocol === 'https:' && url.protocol !== 'https:') {
url.protocol = 'https:'
}
const width = media.width ?? undefined
const height = media.height ?? undefined
return (
<img
width={width}
height={height}
src={url}
alt="todo sry :("
src={url.toString()}
className="w-full h-auto"
loading="lazy"
/>

View file

@ -0,0 +1,67 @@
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

@ -1,39 +0,0 @@
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,42 +1,69 @@
import { useCallback, useState } from 'react'
import FeedView from '../components/FeedView.tsx'
import { useRef, useState } from 'react'
import { PostsService } from '../posts/postsService.ts'
import { useUser } from '../../user/userStore.ts'
import { useUserStore } from '../../user/user.ts'
import { MediaService } from '../../media/mediaService.ts'
import NewPostWidget from '../../../components/NewPostWidget.tsx'
import { useFeedViewModel } from '../components/FeedView.ts'
import { Post } from '../posts/posts.ts'
import { Temporal } from '@js-temporal/polyfill'
import NewPostWidget from '../components/NewPostWidget.tsx'
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
import NavBar from '../../../components/NavBar.tsx'
import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx'
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 {
postsService: PostsService
mediaService: MediaService
}
const PageSize = 20
export default function HomePage({ postsService, mediaService }: HomePageProps) {
const { user } = useUser()
const user = useUserStore((state) => state.user)
useSaveSignupCodeToLocalStorage()
const [isSubmitting, setIsSubmitting] = useState(false)
const fetchPosts = useCallback(
async (cursor: string | null, amount: number | null) => {
return postsService.loadPublicFeed(cursor, amount)
},
[postsService],
)
const { posts, addPosts, addReaction, removeReaction, reactions } = usePostViewModel()
const { pages, setPages, loadNextPage } = useFeedViewModel(fetchPosts)
const [hasMore, setHasMore] = useState(true)
const [error, setError] = useState<string | null>(null)
const onCreatePost = useCallback(
async (content: string, files: { file: File; width: number; height: number }[], isPublic: boolean) => {
const cursor = useRef<string | null>(null)
const loading = useRef(false)
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,
files: { file: File; width: number; height: number }[],
isPublic: boolean,
) => {
setIsSubmitting(true)
if (user == null) throw new Error('Not logged in')
try {
const media = await Promise.all(
files.map(async ({ file, width, height }) => {
const { mediaId, url } = await mediaService.uploadFile(file)
const { mediaId, url } = await mediaService.uploadImage(file)
return {
mediaId,
@ -46,20 +73,32 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
}
}),
)
const postId = await postsService.createNew(user.userId, content, media, isPublic)
const post = new Post(postId, content, media, Temporal.Now.instant(), user.username)
setPages((pages) => [[post], ...pages])
const post = await postsService.createNew(user.id, content, media, isPublic)
addPosts([post])
} catch (error) {
console.error('Failed to create post:', error)
} finally {
setIsSubmitting(false)
}
},
[mediaService, postsService, setPages, user],
)
}
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 (
<SingleColumnLayout
navbar={
@ -70,7 +109,22 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
>
<main className={`w-full max-w-3xl mx-auto`}>
{isLoggedIn && <NewPostWidget onSubmit={onCreatePost} isSubmitting={isSubmitting} />}
<FeedView pages={pages} onLoadMore={loadNextPage} />
<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}
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>
</SingleColumnLayout>
)

View file

@ -0,0 +1,96 @@
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,12 +1,30 @@
import { Temporal } from '@js-temporal/polyfill'
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 {
[immerable] = true
public readonly postId: string
public readonly content: string
public readonly media: PostMedia[]
public readonly createdAt: Temporal.Instant
public readonly authorName: string
public readonly reactions: PostReaction[]
public readonly possibleReactions: string[]
public readonly comments: PostComment[]
constructor(
postId: string,
@ -14,12 +32,18 @@ export class Post {
media: PostMedia[],
createdAt: string | Temporal.Instant,
authorName: string,
reactions: PostReaction[],
possibleReactions: string[],
comments: PostComment[],
) {
this.postId = postId
this.content = content
this.media = media
this.createdAt = Temporal.Instant.from(createdAt)
this.authorName = authorName
this.reactions = reactions
this.possibleReactions = possibleReactions
this.comments = comments
}
public static fromDto(dto: components['schemas']['PostDto']): Post {
@ -29,6 +53,9 @@ export class Post {
dto.media.map((m) => new PostMediaImpl(new URL(m.url), m.width, m.height)),
Temporal.Instant.from(dto.createdAt),
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,16 +1,17 @@
import { Post } from './posts.ts'
import client from '../../api/client.ts'
import { ApiClient } from '../../api/client.ts'
import { useUserStore } from '../../user/user.ts'
export class PostsService {
constructor() {}
constructor(private readonly client: ApiClient) {}
async createNew(
authorId: string,
content: string,
media: CreatePostMedia[],
isPublic: boolean,
): Promise<string> {
const response = await client.POST('/posts', {
): Promise<Post> {
const response = await this.client.POST('/posts', {
body: {
authorId,
content,
@ -26,39 +27,68 @@ export class PostsService {
throw new Error('Failed to create post')
}
return response.data.postId
return Post.fromDto(response.data.post)
}
async loadPublicFeed(cursor: string | null, amount: number | null): Promise<Post[]> {
const response = await client.GET('/posts', {
query: { cursor, amount },
async load(postId: string): Promise<Post | null> {
const response = await this.client.GET('/posts/{postId}', {
params: {
path: { postId },
},
credentials: 'include',
})
if (!response.data) {
return []
if (!response.data?.post) {
return null
}
return response.data?.posts.map((post) => Post.fromDto(post))
return Post.fromDto(response.data.post)
}
async loadByAuthor(
username: string,
cursor: string | null,
amount: number | null,
): Promise<Post[]> {
const response = await client.GET('/posts', {
async loadPublicFeed(cursor: string | null, amount: number | null): Promise<{ posts: Post[] }> {
const response = await this.client.GET('/posts', {
params: {
query: { From: cursor ?? undefined, Amount: amount ?? undefined, Author: username },
query: { After: cursor ?? undefined, Amount: amount ?? undefined },
},
credentials: 'include',
})
if (!response.data) {
return []
return { posts: [] }
}
return response.data?.posts.map((post) => Post.fromDto(post))
return { posts: response.data.posts.map(Post.fromDto) }
}
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

@ -0,0 +1,83 @@
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 }
}

8
src/app/femtoApp.ts Normal file
View file

@ -0,0 +1,8 @@
import { FemtoApp } from '../types'
import { produce } from 'immer'
export function setGlobal<K extends keyof FemtoApp>(k: K, v: FemtoApp[K]) {
window.$femto = produce(window.$femto ?? {}, (draft) => {
draft[k] = v
})
}

View file

@ -0,0 +1,41 @@
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

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

View file

@ -1,12 +1,10 @@
import { User } from '../user/user.ts'
export interface MessageTypes {
'auth:logged-in': {
userId: string
username: string
}
'auth:registered': {
userId: string
username: string
}
'auth:logged-in': null
'auth:registered': null
'auth:logged-out': null
'auth:unauthorized': null
'auth:refreshed': null
'user:updated': User | null
}

22
src/app/user/user.ts Normal file
View file

@ -0,0 +1,22 @@
import { create } from 'zustand'
export interface User {
id: string
username: string
roles: Role[]
}
export enum Role {
User = 0,
SuperUser = 1,
}
interface UserState {
user: User | null
setUser: (user: User | null) => void
}
export const useUserStore = create<UserState>()((set) => ({
user: null,
setUser: (user: User | null) => set({ user }),
}))

View file

@ -1,50 +0,0 @@
import { createStore, Store, useStore } from '../../utils/store.ts'
import { addMessageListener } from '../messageBus/messageBus.ts'
export interface User {
userId: string
username: string
}
export type UserStore = Store<User | null>
const UserKey = 'user'
export const userStore = createStore<User | null>(loadStoredUser())
userStore.subscribe((user) => {
localStorage.setItem(UserKey, JSON.stringify(user))
})
addMessageListener('auth:logged-in', (e) => {
userStore.setState({
userId: e.userId,
username: e.username,
})
})
addMessageListener('auth:registered', (e) => {
userStore.setState({
userId: e.userId,
username: e.username,
})
})
addMessageListener('auth:logged-out', () => {
userStore.setState(null)
})
export const useUser = () => {
const [user] = useStore(userStore)
return { user }
}
function loadStoredUser(): User | null {
const json = localStorage.getItem(UserKey)
if (json) {
return JSON.parse(json) as User
} else {
return null
}
}

View file

@ -1,9 +1,35 @@
import { PropsWithChildren } from 'react'
import { PropsWithChildren, ReactNode } from 'react'
import { Role, useUserStore } from '../app/user/user.ts'
import NavButton from './buttons/NavButton.tsx'
type NavBarProps = unknown
type NavBarProps = {
leftChildren?: ReactNode
}
export default function NavBar({ children }: PropsWithChildren<NavBarProps>) {
const user = useUserStore((state) => state.user)
const isSuperUser = user?.roles.includes(Role.SuperUser)
return (
<nav className={`w-full flex flex-row justify-end gap-4 px-4 md:px-8 py-3`}>{children}</nav>
<nav className={`w-full flex flex-row justify-between 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}
{isSuperUser && <NavButton to={'/admin/codes'}>admin</NavButton>}
<SourceCodeLink />
</div>
</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

@ -2,6 +2,7 @@ import { PropsWithChildren } from 'react'
import { Link } from 'react-router-dom'
interface NavLinkButtonProps {
to: string | To
className?: string
}
interface To {
@ -10,9 +11,13 @@ interface To {
hash?: string
}
export default function NavButton({ to, children }: PropsWithChildren<NavLinkButtonProps>) {
export default function NavButton({
to,
className: extraClasses = '',
children,
}: PropsWithChildren<NavLinkButtonProps>) {
return (
<Link className={`text-primary-500`} to={to}>
<Link className={`text-primary-500 hover:text-primary-600 ${extraClasses}`} to={to}>
{children}
</Link>
)

View file

@ -15,7 +15,7 @@ export interface TextInputKeyDownEvent {
}
export default function FancyTextEditor({
value: _value,
value: value,
onInput,
onKeyDown,
className: extraClasses = '',
@ -24,10 +24,7 @@ export default function FancyTextEditor({
const divRef = useRef<HTMLDivElement>(null)
const [hasFocus, setHasFocus] = useState(false)
// 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()
const trimmedValue = value.trim()
// 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,
@ -38,12 +35,12 @@ export default function FancyTextEditor({
return
}
if (!value && !hasFocus) {
if (!trimmedValue && !hasFocus) {
div.innerText = placeholder
} else if (div.innerText !== value) {
div.innerText = value
} else if (div.innerText.trim() !== trimmedValue) {
div.innerText = trimmedValue
}
}, [hasFocus, placeholder, value])
}, [hasFocus, placeholder, trimmedValue])
useEffect(() => {
const div = divRef.current!

View file

@ -18,7 +18,7 @@ export function useIntersectionLoad(
) {
const observerRef = useRef<IntersectionObserver | null>(null)
const loading = useRef(false)
const timeoutRef = useRef<number | null>(null)
const timeoutRef = useRef<Timeout | null>(null)
useEffect(() => {
const el = elementRef.current

17
src/hooks/useOnMounted.ts Normal file
View file

@ -0,0 +1,17 @@
import { useEffect, useRef } from 'react'
export function useOnMounted(callback: () => void | Promise<void>) {
const isMounted = useRef(false)
useEffect(() => {
if (isMounted.current) return
isMounted.current = true
const timeoutId = setTimeout(callback)
return () => {
isMounted.current = false
clearTimeout(timeoutId)
}
}, [callback])
}

View file

@ -0,0 +1,14 @@
import { useSearchParams } from 'react-router-dom'
import { useEffect } from 'react'
export function useSaveSignupCodeToLocalStorage() {
const [searchParams] = useSearchParams()
const code = searchParams.get('c')
useEffect(() => {
if (code) {
localStorage.setItem('signupCode', code)
}
}, [code])
}

24
src/types.d.ts vendored Normal file
View file

@ -0,0 +1,24 @@
import { User } from './app/user/user.ts'
export interface ProblemDetails {
detail: string
title: string
status: number
type: string
traceId: string
}
declare global {
interface Window {
$femto: FemtoApp
}
type Timeout = ReturnType<typeof setTimeout>
}
export interface FemtoApp {
version: string
user: User | null
authService: AuthService | null
postsService: PostsService | null
}

View file

@ -0,0 +1,29 @@
import { useEffect } from 'react'
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
export function useRefreshSessionLoop(authService: AuthService) {
const user = useUserStore((state) => state.user)
const userId = user?.id ?? null
useEffect(() => {
if (userId == null) {
return
}
const timeouts: Timeout[] = []
timeouts.push(
setTimeout(async function refreshUser() {
await authService.refreshUser(userId)
timeouts.push(setTimeout(refreshUser, 60_000))
}),
)
return () => {
timeouts.forEach(clearTimeout)
}
}, [authService, userId])
}

17
src/utils/debounce.ts Normal file
View file

@ -0,0 +1,17 @@
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],
)
}

3
src/utils/delay.ts Normal file
View file

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

View file

@ -0,0 +1,19 @@
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

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
export interface Store<T> {
getState: () => T
@ -37,7 +37,21 @@ export function createStore<T extends object | null>(initialState: T): Store<T>
export function useStore<T>(store: Store<T>) {
const [selectedState, setSelectedState] = useState(() => store.getState())
useEffect(() => store.subscribe((newState) => setSelectedState(newState)), [store])
useEffect(() => {
const unsubscribe = store.subscribe((newState) => setSelectedState(newState))
return [selectedState, setSelectedState] as const
return () => {
unsubscribe()
}
}, [store])
const setState = useCallback(
(nextState: T | ((prevState: T) => T)) => {
setSelectedState(nextState)
store.setState(nextState)
},
[store],
)
return [selectedState, setState] as const
}

View file

@ -1,8 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import fs from 'node:fs'
// 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()],
}
})

View file

@ -778,6 +778,18 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
"@types/node@^22.15.19":
version "22.15.19"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.15.19.tgz#ba9f321675243af0456d607fa82a4865931e0cef"
integrity sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==
dependencies:
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":
version "19.1.3"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.3.tgz#3f0c60804441bf34d19f8dd0d44405c0c0e21bfa"
@ -1567,6 +1579,11 @@ globals@^16.0.0:
resolved "https://registry.yarnpkg.com/globals/-/globals-16.0.0.tgz#3d7684652c5c4fbd086ec82f9448214da49382d8"
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:
version "1.2.0"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
@ -1915,6 +1932,14 @@ ms@^2.1.3:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
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:
version "3.3.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
@ -1935,7 +1960,7 @@ node-releases@^2.0.19:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314"
integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==
object-assign@^4:
object-assign@^4, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
@ -2045,6 +2070,16 @@ path-to-regexp@^8.0.0:
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz#73990cc29e57a3ff2a0d914095156df5db79e8b4"
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:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
@ -2439,6 +2474,11 @@ typescript@~5.7.2:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.3.tgz#919b44a7dbb8583a9b856d162be24a54bf80073e"
integrity sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==
undici-types@~6.21.0:
version "6.21.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
unpipe@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
@ -2483,6 +2523,11 @@ vite@^6.3.1:
optionalDependencies:
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:
version "2.0.2"
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
@ -2529,3 +2574,8 @@ zod@^3.23.8, zod@^3.24.2:
version "3.24.3"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.3.tgz#1f40f750a05e477396da64438e0e1c0995dafd87"
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==