Compare commits

..

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

57 changed files with 475 additions and 2157 deletions

1
.env Normal file
View file

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

2
.gitignore vendored
View file

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

View file

@ -5,7 +5,6 @@
<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.26.6",
"version": "1.5.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"build:deploy": "bash scripts/publish.sh",
"build:deploy": "bash scripts/bump-build-push.sh",
"generate:schema": "node scripts/generate-schema.mjs"
},
"dependencies": {
@ -16,17 +16,13 @@
"@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",
"zustand": "^5.0.7"
"tailwindcss": "^4.1.5"
},
"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

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

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

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

View file

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

View file

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

View file

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

View file

@ -1,42 +1,20 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import HomePage from './app/feed/pages/HomePage.tsx'
import PostPage from './app/feed/pages/PostPage.tsx'
import { PostsService } from './app/feed/posts/postsService.ts'
import AuthorPage from './app/feed/pages/AuthorPage.tsx'
import { MediaService } from './app/media/mediaService.ts'
import SignupPage from './app/auth/pages/SignupPage.tsx'
import LoginPage from './app/auth/pages/LoginPage.tsx'
import { AuthService } from './app/auth/authService.ts'
import { useUser } from './app/user/userStore.ts'
import LogoutPage from './app/auth/pages/LogoutPage.tsx'
import UnauthorizedHandler from './app/auth/components/UnauthorizedHandler.tsx'
import 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'
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])
function App() {
const { user } = useUser()
const postService = new PostsService()
const mediaService = new MediaService()
const authService = new AuthService(user)
return (
<BrowserRouter>
@ -46,18 +24,14 @@ export default function App() {
path={'/'}
element={<HomePage postsService={postService} mediaService={mediaService} />}
/>
<Route path={'/p/:postId'} element={<PostPage postsService={postService} />} />
<Route path="/u/:username" element={<AuthorPage postsService={postService} />} />
<Route path="/login" element={<LoginPage authService={authService} />} />
<Route path="/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

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

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

View file

@ -9,7 +9,7 @@ export interface paths {
get: {
parameters: {
query?: {
After?: string
From?: string
Amount?: number
AuthorId?: string
Author?: string
@ -26,9 +26,9 @@ export interface paths {
[name: string]: unknown
}
content: {
'text/plain': components['schemas']['LoadPostsResponse']
'application/json': components['schemas']['LoadPostsResponse']
'text/json': components['schemas']['LoadPostsResponse']
'text/plain': components['schemas']['GetAllPublicPostsResponse']
'application/json': components['schemas']['GetAllPublicPostsResponse']
'text/json': components['schemas']['GetAllPublicPostsResponse']
}
}
}
@ -68,171 +68,6 @@ 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
@ -277,7 +112,7 @@ export interface paths {
patch?: never
trace?: never
}
'/media/{id}': {
[path: `/media/${string}`]: {
parameters: {
query?: never
header?: never
@ -398,78 +233,6 @@ 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
@ -503,198 +266,10 @@ 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
@ -714,64 +289,36 @@ export interface components {
height: number | null
}
CreatePostResponse: {
post: components['schemas']['PostDto']
}
CreateSignupCodeRequest: {
code: string
email: string
name: string
}
DeletePostReactionRequest: {
emoji: string
}
GetPostResponse: {
post: components['schemas']['PostDto']
}
GetUserInfoResult: {
/** Format: uuid */
userId: string
username: string
isSuperUser: boolean
postId: string
}
ListSignupCodesResult: {
signupCodes: components['schemas']['SignupCodeDto'][]
}
LoadPostsResponse: {
GetAllPublicPostsResponse: {
posts: components['schemas']['PostDto'][]
/** Format: uuid */
next: string | null
}
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 */
@ -781,33 +328,16 @@ 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
rememberMe: boolean
email: string | null
}
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,14 +1,17 @@
import { User } from '../user/userStore.ts'
import { dispatchMessage } from '../messageBus/messageBus.ts'
import { ProblemDetails } from '../../types'
import { SignupCode } from './signupCode.ts'
import { ApiClient } from '../api/client.ts'
import client from '../api/client.ts'
export class AuthService {
constructor(private readonly client: ApiClient) {}
constructor(private readonly user: User | null) {}
async login(username: string, password: string, rememberMe: boolean = false) {
const res = await this.client.POST('/auth/login', {
body: { username, password, rememberMe },
async login(username: string, password: string) {
if (this.user != null) {
throw new Error('already logged in')
}
const res = await client.POST('/auth/login', {
body: { username, password },
credentials: 'include',
})
@ -16,67 +19,33 @@ export class AuthService {
throw new Error('invalid credentials')
}
dispatchMessage('auth:logged-in', null)
dispatchMessage('auth:logged-in', { ...res.data })
}
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 },
async signup(username: string, password: string, signupCode: string) {
if (this.user != null) {
throw new Error('already logged in')
}
const res = await client.POST('/auth/register', {
body: { username, password, signupCode, email: null },
credentials: 'include',
})
if (!res.data) {
console.error(res.error)
throw new Error((res.error as ProblemDetails)?.detail ?? 'invalid credentials')
throw new Error('invalid credentials')
}
dispatchMessage('auth:registered', null)
dispatchMessage('auth:registered', { ...res.data })
}
async logout() {
await this.client.DELETE('/auth/session', { credentials: 'include' })
if (this.user == null) {
return
}
await 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,11 +1,9 @@
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 { t } = useTranslations()
const user = useUserStore((state) => state.user)
const { user } = useUser()
const { pathname } = useLocation()
@ -17,15 +15,15 @@ export default function AuthNavButtons() {
if (loggedIn) {
return (
<>
<NavButton to="/logout">{t('nav.logout')}</NavButton>
<NavButton to="/logout">logout</NavButton>
</>
)
} else {
const search = redirectQuery.toString()
return (
<>
<NavButton to={{ pathname: '/login', search }}>{t('nav.login')}</NavButton>
<NavButton to={{ pathname: '/signup', search }}>{t('nav.register')}</NavButton>
<NavButton to={{ pathname: '/login', search }}>login</NavButton>
<NavButton to={{ pathname: '/signup', search }}>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 = useUserStore((state) => state.user)
const { user } = useUser()
const navigate = useNavigate()

View file

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

View file

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

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

View file

@ -4,29 +4,25 @@ import TextInput from '../../../components/inputs/TextInput.tsx'
import Button from '../../../components/buttons/Button.tsx'
import { AuthService } from '../authService.ts'
import { useNavigate } from 'react-router-dom'
import { useUserStore } from '../../user/user.ts'
import { useUser } from '../../user/userStore.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 = useUserStore((state) => state.user)
const { user } = useUser()
useEffect(() => {
if (user) {
@ -53,7 +49,7 @@ export default function LoginPage({ authService }: LoginPageProps) {
setIsSubmitting(true)
try {
await authService.login(username, password, rememberMe)
await authService.login(username, password)
} catch (error: unknown) {
setError(error instanceof Error ? error.message : 'something went terribly wrong')
} finally {
@ -65,7 +61,7 @@ export default function LoginPage({ authService }: LoginPageProps) {
<SingleColumnLayout
navbar={
<NavBar>
<NavButton to={'/'}>{t('nav.home')}</NavButton>
<NavButton to={'/'}>home</NavButton>
</NavBar>
}
>
@ -74,7 +70,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">
{t('auth.username.label')}
username
</label>
<TextInput
ref={usernameInputRef}
@ -87,7 +83,7 @@ export default function LoginPage({ authService }: LoginPageProps) {
<div className="flex flex-col gap-1">
<label htmlFor="password" className="text-sm text-gray-600">
{t('auth.password.label')}
password
</label>
<TextInput
ref={passwordInputRef}
@ -95,32 +91,19 @@ export default function LoginPage({ authService }: LoginPageProps) {
id="password"
value={password}
onInput={setPassword}
className={'mb-4'}
className={'mb-3'}
/>
</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 ? t('misc.loading') : t('auth.login.cta')}
{isSubmitting ? 'wait...' : 'make login pls'}
</Button>
<LinkButton secondary to={{ pathname: '/signup', search: window.location.search }}>
{t('auth.login.register_instead')}
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 { useUserStore } from '../../user/user.ts'
import { useUser } from '../../user/userStore.ts'
interface LogoutPageProps {
authService: AuthService
@ -9,7 +9,7 @@ interface LogoutPageProps {
export default function LogoutPage({ authService }: LogoutPageProps) {
const navigate = useNavigate()
const user = useUserStore((state) => state.user)
const { user } = useUser()
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,12 +17,10 @@ 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)
@ -32,6 +30,8 @@ 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,12 +44,13 @@ export default function SignupPage({ authService }: SignupPageProps) {
localStorage.setItem(SignupCodeKey, theSignupCode)
} else {
theSignupCode = localStorage.getItem(SignupCodeKey)
setSignupCode(theSignupCode)
}
if (!theSignupCode) {
dialogRef.current?.showModal()
}
}, [code, signupCode])
useEffect(() => {}, [signupCode])
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
@ -75,11 +76,8 @@ export default function SignupPage({ authService }: SignupPageProps) {
setIsSubmitting(true)
try {
await authService.signup(username, password, signupCode, rememberMe)
await authService.signup(username, password, signupCode)
navigate('/')
} catch (e: unknown) {
const err = e as Error
setError(err.message)
} finally {
setIsSubmitting(false)
}
@ -89,7 +87,7 @@ export default function SignupPage({ authService }: SignupPageProps) {
<SingleColumnLayout
navbar={
<NavBar>
<NavButton to={'/'}>{t('nav.home')}</NavButton>
<NavButton to={'/'}>home</NavButton>
</NavBar>
}
>
@ -98,7 +96,6 @@ 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}
@ -107,47 +104,53 @@ 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 ? t('misc.loading') : t('auth.register.cta')}
{isSubmitting ? 'wait...' : 'give me an account pls'}
</Button>
<LinkButton secondary to={'/login'}>
{t('auth.register.login_instead')}
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
@ -155,11 +158,11 @@ interface FormInputProps {
ref: Ref<HTMLInputElement>
}
function FormInput({ id, label, value, onInput, error, type = 'text', ref }: FormInputProps) {
function FormInput({ id, value, onInput, error, type = 'text', ref }: FormInputProps) {
return (
<div className="flex flex-col gap-1">
<label htmlFor={id} className="text-sm text-gray-600">
{label}
{id}
</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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,104 +1,65 @@
import { useRef, useState } from 'react'
import { useCallback, useState } from 'react'
import FeedView from '../components/FeedView.tsx'
import { PostsService } from '../posts/postsService.ts'
import { useUserStore } from '../../user/user.ts'
import { useUser } from '../../user/userStore.ts'
import { MediaService } from '../../media/mediaService.ts'
import NewPostWidget from '../components/NewPostWidget.tsx'
import NewPostWidget from '../../../components/NewPostWidget.tsx'
import { useFeedViewModel } from '../components/FeedView.ts'
import { Post } from '../posts/posts.ts'
import { Temporal } from '@js-temporal/polyfill'
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
import 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 = useUserStore((state) => state.user)
useSaveSignupCodeToLocalStorage()
const { user } = useUser()
const [isSubmitting, setIsSubmitting] = useState(false)
const { posts, addPosts, addReaction, removeReaction, reactions } = usePostViewModel()
const fetchPosts = useCallback(
async (cursor: string | null, amount: number | null) => {
return postsService.loadPublicFeed(cursor, amount)
},
[postsService],
)
const [hasMore, setHasMore] = useState(true)
const [error, setError] = useState<string | null>(null)
const { pages, setPages, loadNextPage } = useFeedViewModel(fetchPosts)
const cursor = useRef<string | null>(null)
const loading = useRef(false)
const onCreatePost = useCallback(
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 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.uploadImage(file)
return {
mediaId,
url,
width,
height,
}
}),
)
const post = await postsService.createNew(user.id, content, media, isPublic)
addPosts([post])
} catch (error) {
console.error('Failed to create post:', error)
} finally {
setIsSubmitting(false)
}
}
return {
mediaId,
url,
width,
height,
}
}),
)
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])
} 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={
@ -109,22 +70,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
>
<main className={`w-full max-w-3xl mx-auto`}>
{isLoggedIn && <NewPostWidget onSubmit={onCreatePost} isSubmitting={isSubmitting} />}
<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>
<FeedView pages={pages} onLoadMore={loadNextPage} />
</main>
</SingleColumnLayout>
)

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

50
src/app/user/userStore.ts Normal file
View file

@ -0,0 +1,50 @@
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,35 +1,9 @@
import { PropsWithChildren, ReactNode } from 'react'
import { Role, useUserStore } from '../app/user/user.ts'
import NavButton from './buttons/NavButton.tsx'
import { PropsWithChildren } from 'react'
type NavBarProps = {
leftChildren?: ReactNode
}
type NavBarProps = unknown
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-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>
<nav className={`w-full flex flex-row justify-end gap-4 px-4 md:px-8 py-3`}>{children}</nav>
)
}

View file

@ -1,9 +1,7 @@
import { useState } from 'react'
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'
import FancyTextEditor, { TextInputKeyDownEvent } from './inputs/FancyTextEditor.tsx'
import Button from './buttons/Button.tsx'
import { openFileDialog } from '../utils/openFileDialog.ts'
interface NewPostWidgetProps {
onSubmit: (
@ -23,7 +21,6 @@ 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)
@ -74,7 +71,7 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
onInput={onContentInput}
onKeyDown={onInputKeyDown}
className="mb-3"
placeholder={t('post.editor.placeholder')}
placeholder="write something..."
/>
{attachments.length > 0 && (
@ -95,7 +92,7 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
<div className="flex justify-between items-center pt-2">
<div className="flex items-center gap-2">
<Button secondary onClick={onAddMediaClicked}>
{t('post.add_media.cta')}
+ add media
</Button>
<label className="flex items-center gap-1 cursor-pointer">
<input
@ -105,14 +102,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">{t('post.public.label')}</span>
<span className="text-primary-500">public</span>
</label>
</div>
<Button
onClick={handleSubmit}
disabled={isSubmitting || (content.trim() === '' && attachments.length === 0)}
>
{t('post.submit.cta')}
post
</Button>
</div>
</div>
@ -124,13 +121,11 @@ 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: getRandomId(),
id: crypto.randomUUID(),
file,
objectUrl,
width,
@ -149,95 +144,3 @@ 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

@ -2,7 +2,6 @@ import { PropsWithChildren } from 'react'
import { Link } from 'react-router-dom'
interface NavLinkButtonProps {
to: string | To
className?: string
}
interface To {
@ -11,13 +10,9 @@ interface To {
hash?: string
}
export default function NavButton({
to,
className: extraClasses = '',
children,
}: PropsWithChildren<NavLinkButtonProps>) {
export default function NavButton({ to, children }: PropsWithChildren<NavLinkButtonProps>) {
return (
<Link className={`text-primary-500 hover:text-primary-600 ${extraClasses}`} to={to}>
<Link className={`text-primary-500`} 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,7 +24,10 @@ export default function FancyTextEditor({
const divRef = useRef<HTMLDivElement>(null)
const [hasFocus, setHasFocus] = useState(false)
const trimmedValue = value.trim()
// the contenteditable likes to slip in newlines at the bottom of our innerText
// which makes it bad to check for empty string because it might be "\n"
// so we just trim it upfront and then fogeddaboudit
const value = _value.trim()
// The funky mechanics here are to stop the cursor from jumping back the start.
// It probably will have the cursor jump to the start if anything changes programmatically,
@ -35,12 +38,12 @@ export default function FancyTextEditor({
return
}
if (!trimmedValue && !hasFocus) {
if (!value && !hasFocus) {
div.innerText = placeholder
} else if (div.innerText.trim() !== trimmedValue) {
div.innerText = trimmedValue
} else if (div.innerText !== value) {
div.innerText = value
}
}, [hasFocus, placeholder, trimmedValue])
}, [hasFocus, placeholder, value])
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<Timeout | null>(null)
const timeoutRef = useRef<number | null>(null)
useEffect(() => {
const el = elementRef.current

View file

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

@ -1,14 +0,0 @@
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
View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,8 @@
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(() => {
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf-8'))
process.env.VITE_FEMTO_VERSION = packageJson.version
return {
plugins: [react(), tailwindcss()],
}
export default defineConfig({
plugins: [react(), tailwindcss()],
})

View file

@ -778,18 +778,6 @@
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"
@ -1579,11 +1567,6 @@ 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"
@ -1932,14 +1915,6 @@ 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"
@ -1960,7 +1935,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.1.1:
object-assign@^4:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
@ -2070,16 +2045,6 @@ 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"
@ -2474,11 +2439,6 @@ 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"
@ -2523,11 +2483,6 @@ 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"
@ -2574,8 +2529,3 @@ 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==