Compare commits
No commits in common. "main" and "v1.2.0" have entirely different histories.
60 changed files with 483 additions and 2187 deletions
0
.env
Normal file
0
.env
Normal file
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -22,5 +22,3 @@ dist-ssr
|
|||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
|
|
35
Dockerfile
35
Dockerfile
|
@ -1,15 +1,4 @@
|
|||
# Stage 1: Dependencies install (cached if lockfile unchanged)
|
||||
FROM node:22-alpine AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Only copy dependency-related files to leverage cache
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Install dependencies
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# Stage 2: Build the app
|
||||
# Stage 1: Build the Vite React app
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
ARG VITE_API_URL
|
||||
|
@ -17,25 +6,15 @@ ENV VITE_API_URL=$VITE_API_URL
|
|||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy deps from previous stage to cache node_modules
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/package.json ./package.json
|
||||
COPY --from=deps /app/yarn.lock ./yarn.lock
|
||||
# Install dependencies
|
||||
COPY package.json /app
|
||||
COPY yarn.lock /app
|
||||
RUN yarn install
|
||||
|
||||
# Copy rest of app
|
||||
COPY src/ ./src
|
||||
COPY public/ ./public
|
||||
COPY .env.production ./.env.production
|
||||
COPY index.html ./index.html
|
||||
COPY tsconfig.json ./tsconfig.json
|
||||
COPY tsconfig.app.json ./tsconfig.app.json
|
||||
COPY tsconfig.node.json ./tsconfig.node.json
|
||||
COPY vite.config.ts ./vite.config.ts
|
||||
|
||||
# Build the Vite app
|
||||
COPY . .
|
||||
RUN yarn build
|
||||
|
||||
# Stage 3: Serve with Caddy
|
||||
# Stage 2: Serve with Caddy
|
||||
FROM caddy:alpine
|
||||
|
||||
# Copy built app to the web root
|
||||
|
|
|
@ -2,10 +2,9 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/icon_64.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<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>
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
{
|
||||
"name": "femto-webapp",
|
||||
"private": true,
|
||||
"version": "1.26.6",
|
||||
"version": "1.2.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",
|
||||
"generate:schema": "node scripts/generate-schema.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
@ -16,17 +15,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",
|
||||
|
|
|
@ -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 |
Binary file not shown.
Before Width: | Height: | Size: 7.7 KiB |
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
57
scripts/bump-build-push.sh
Executable file
57
scripts/bump-build-push.sh
Executable 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."
|
|
@ -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,
|
||||
})
|
||||
|
|
28
scripts/get-local-api-url.js
Normal file
28
scripts/get-local-api-url.js
Normal 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`
|
||||
}
|
|
@ -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
|
52
src/App.tsx
52
src/App.tsx
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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: `${location.protocol}//${location.hostname}:5181` })
|
||||
|
||||
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
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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}</>
|
||||
}
|
|
@ -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>())
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
36
src/app/feed/components/FeedView.ts
Normal file
36
src/app/feed/components/FeedView.ts
Normal 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
|
||||
}
|
29
src/app/feed/components/FeedView.tsx
Normal file
29
src/app/feed/components/FeedView.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
<span>clicked</span>
|
||||
<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>
|
||||
</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',
|
||||
})
|
||||
}
|
39
src/app/feed/pages/AuthorPage.tsx
Normal file
39
src/app/feed/pages/AuthorPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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) })),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 }
|
||||
}
|
|
@ -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
|
||||
})
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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..."
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
50
src/app/user/userStore.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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('')}`
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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])
|
||||
}
|
|
@ -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
24
src/types.d.ts
vendored
|
@ -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
|
||||
}
|
|
@ -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])
|
||||
}
|
|
@ -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],
|
||||
)
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()],
|
||||
})
|
||||
|
|
52
yarn.lock
52
yarn.lock
|
@ -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==
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue