Compare commits
No commits in common. "main" and "v1.22.0" have entirely different histories.
33 changed files with 310 additions and 1022 deletions
|
@ -1,14 +1,14 @@
|
||||||
{
|
{
|
||||||
"name": "femto-webapp",
|
"name": "femto-webapp",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.26.6",
|
"version": "1.22.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"build:deploy": "bash scripts/publish.sh",
|
"build:deploy": "bash scripts/bump-build-push.sh",
|
||||||
"generate:schema": "node scripts/generate-schema.mjs"
|
"generate:schema": "node scripts/generate-schema.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -20,8 +20,7 @@
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.6.0",
|
"react-router-dom": "^7.6.0",
|
||||||
"tailwindcss": "^4.1.5",
|
"tailwindcss": "^4.1.5"
|
||||||
"zustand": "^5.0.7"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.22.0",
|
"@eslint/js": "^9.22.0",
|
||||||
|
|
|
@ -1,40 +0,0 @@
|
||||||
<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<metadata
|
|
||||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
|
||||||
xmlns:cc="http://creativecommons.org/ns#"
|
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
|
||||||
>
|
|
||||||
<rdf:RDF>
|
|
||||||
<cc:Work rdf:about="https://codeberg.org/forgejo/meta/src/branch/readme/branding#logo">
|
|
||||||
<dc:title>Forgejo logo</dc:title>
|
|
||||||
<cc:creator rdf:resource="https://caesarschinas.com/"><cc:attributionName>Caesar Schinas</cc:attributionName></cc:creator>
|
|
||||||
<cc:license rdf:resource="http://creativecommons.org/licenses/by-sa/4.0/" />
|
|
||||||
</cc:Work>
|
|
||||||
</rdf:RDF>
|
|
||||||
</metadata>
|
|
||||||
<style type="text/css">
|
|
||||||
circle {
|
|
||||||
fill: none;
|
|
||||||
stroke: #000;
|
|
||||||
stroke-width: 15;
|
|
||||||
}
|
|
||||||
path {
|
|
||||||
fill: none;
|
|
||||||
stroke: #000;
|
|
||||||
stroke-width: 25;
|
|
||||||
}
|
|
||||||
.orange {
|
|
||||||
stroke: oklch(55.1% 0.027 264.364);
|
|
||||||
}
|
|
||||||
.red {
|
|
||||||
--color-primary-700: var(--color-gray-700);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<g transform="translate(28,28)">
|
|
||||||
<path d="M58 168 v-98 a50 50 0 0 1 50-50 h20" class="orange" />
|
|
||||||
<path d="M58 168 v-30 a50 50 0 0 1 50-50 h20" class="red" />
|
|
||||||
<circle cx="142" cy="20" r="18" class="orange" />
|
|
||||||
<circle cx="142" cy="88" r="18" class="red" />
|
|
||||||
<circle cx="58" cy="180" r="18" class="red" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.4 KiB |
57
scripts/bump-build-push.sh
Executable file
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 "v$NEW_VERSION"
|
||||||
|
git tag "v$NEW_VERSION"
|
||||||
|
git push origin main
|
||||||
|
git push origin "v$NEW_VERSION"
|
||||||
|
|
||||||
|
echo "🎉 Release v$NEW_VERSION complete."
|
|
@ -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
|
|
29
src/App.tsx
29
src/App.tsx
|
@ -1,6 +1,5 @@
|
||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||||
import HomePage from './app/feed/pages/HomePage.tsx'
|
import HomePage from './app/feed/pages/HomePage.tsx'
|
||||||
import PostPage from './app/feed/pages/PostPage.tsx'
|
|
||||||
import SignupPage from './app/auth/pages/SignupPage.tsx'
|
import SignupPage from './app/auth/pages/SignupPage.tsx'
|
||||||
import LoginPage from './app/auth/pages/LoginPage.tsx'
|
import LoginPage from './app/auth/pages/LoginPage.tsx'
|
||||||
import LogoutPage from './app/auth/pages/LogoutPage.tsx'
|
import LogoutPage from './app/auth/pages/LogoutPage.tsx'
|
||||||
|
@ -8,36 +7,13 @@ import UnauthorizedHandler from './app/auth/components/UnauthorizedHandler.tsx'
|
||||||
import AdminPage from './app/admin/pages/AdminPage.tsx'
|
import AdminPage from './app/admin/pages/AdminPage.tsx'
|
||||||
import SignupCodesManagementPage from './app/admin/pages/subpages/SignupCodesManagementPage.tsx'
|
import SignupCodesManagementPage from './app/admin/pages/subpages/SignupCodesManagementPage.tsx'
|
||||||
import { useRefreshSessionLoop } from './useRefreshSessionLoop.ts'
|
import { useRefreshSessionLoop } from './useRefreshSessionLoop.ts'
|
||||||
import { setGlobal } from './app/femtoApp.ts'
|
import { initApp } from './initApp.ts'
|
||||||
import { PostsService } from './app/feed/posts/postsService.ts'
|
|
||||||
import { MediaService } from './app/media/mediaService.ts'
|
|
||||||
import { AuthService } from './app/auth/authService.ts'
|
|
||||||
import { initClient } from './app/api/client.ts'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { useUserStore } from './app/user/user.ts'
|
|
||||||
import { getUserFromCookie } from './app/auth/getUserFromCookie.ts'
|
|
||||||
|
|
||||||
setGlobal('version', import.meta.env.VITE_FEMTO_VERSION)
|
const { postService, mediaService, authService } = initApp()
|
||||||
|
|
||||||
const client = initClient()
|
|
||||||
const postService = new PostsService(client)
|
|
||||||
const mediaService = new MediaService(client)
|
|
||||||
const authService = new AuthService(client)
|
|
||||||
|
|
||||||
setGlobal('postsService', postService)
|
|
||||||
setGlobal('authService', authService)
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const setUser = useUserStore((state) => state.setUser)
|
|
||||||
|
|
||||||
useRefreshSessionLoop(authService)
|
useRefreshSessionLoop(authService)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const user = getUserFromCookie()
|
|
||||||
console.debug('got user cookie', user)
|
|
||||||
setUser(user)
|
|
||||||
}, [setUser])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<UnauthorizedHandler>
|
<UnauthorizedHandler>
|
||||||
|
@ -46,7 +22,6 @@ export default function App() {
|
||||||
path={'/'}
|
path={'/'}
|
||||||
element={<HomePage postsService={postService} mediaService={mediaService} />}
|
element={<HomePage postsService={postService} mediaService={mediaService} />}
|
||||||
/>
|
/>
|
||||||
<Route path={'/p/:postId'} element={<PostPage postsService={postService} />} />
|
|
||||||
<Route path="/login" element={<LoginPage authService={authService} />} />
|
<Route path="/login" element={<LoginPage authService={authService} />} />
|
||||||
<Route path="/logout" element={<LogoutPage authService={authService} />} />
|
<Route path="/logout" element={<LogoutPage authService={authService} />} />
|
||||||
<Route path="/signup/:code?" element={<SignupPage authService={authService} />} />
|
<Route path="/signup/:code?" element={<SignupPage authService={authService} />} />
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { AuthService } from '../../../auth/authService.ts'
|
import { AuthService } from '../../../auth/authService.ts'
|
||||||
import { useEffect, useState, useRef, MouseEvent, useCallback } from 'react'
|
import { useEffect, useState, useRef, MouseEvent } from 'react'
|
||||||
import { SignupCode } from '../../../auth/signupCode.ts'
|
import { SignupCode } from '../../../auth/signupCode.ts'
|
||||||
import { Temporal } from '@js-temporal/polyfill'
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
import Button from '../../../../components/buttons/Button.tsx'
|
import Button from '../../../../components/buttons/Button.tsx'
|
||||||
|
@ -12,24 +12,25 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa
|
||||||
const [codes, setCodes] = useState<SignupCode[]>([])
|
const [codes, setCodes] = useState<SignupCode[]>([])
|
||||||
const [code, setCode] = useState('')
|
const [code, setCode] = useState('')
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null)
|
const dialogRef = useRef<HTMLDialogElement>(null)
|
||||||
const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null)
|
const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null)
|
||||||
const [activeCode, setActiveCode] = useState<string | null>(null)
|
const [activeCode, setActiveCode] = useState<string | null>(null)
|
||||||
|
|
||||||
const fetchCodes = useCallback(async () => {
|
const fetchCodes = async () => {
|
||||||
try {
|
try {
|
||||||
setCodes(await authService.listSignupCodes())
|
setCodes(await authService.listSignupCodes())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch signup codes:', err)
|
console.error('Failed to fetch signup codes:', err)
|
||||||
}
|
}
|
||||||
}, [authService])
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeoutId = setTimeout(fetchCodes)
|
const timeoutId = setTimeout(fetchCodes)
|
||||||
return () => clearTimeout(timeoutId)
|
return () => clearTimeout(timeoutId)
|
||||||
}, [authService, fetchCodes])
|
}, [authService])
|
||||||
|
|
||||||
const handleCreateCode = async (e: React.FormEvent) => {
|
const handleCreateCode = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
@ -37,11 +38,12 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authService.createSignupCode(code, name)
|
await authService.createSignupCode(code, email, name)
|
||||||
setCode('')
|
setCode('')
|
||||||
setName('')
|
setName('')
|
||||||
|
setEmail('')
|
||||||
dialogRef.current?.close()
|
dialogRef.current?.close()
|
||||||
fetchCodes()
|
fetchCodes() // Refresh the table
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to create signup code')
|
setError(err instanceof Error ? err.message : 'Failed to create signup code')
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -114,6 +116,7 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left">Code</th>
|
<th className="text-left">Code</th>
|
||||||
|
<th className="text-left">Email</th>
|
||||||
<th className="text-left">Redeemed By</th>
|
<th className="text-left">Redeemed By</th>
|
||||||
<th className="text-left">Expires On</th>
|
<th className="text-left">Expires On</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -131,6 +134,7 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa
|
||||||
{code.code}
|
{code.code}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
<td>{code.email}</td>
|
||||||
<td>{code.redeemedBy || 'Not redeemed'}</td>
|
<td>{code.redeemedBy || 'Not redeemed'}</td>
|
||||||
<td>{formatDate(code.expiresOn)}</td>
|
<td>{formatDate(code.expiresOn)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -187,6 +191,19 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2">
|
<div className="flex justify-end space-x-2">
|
||||||
<Button type="button" onClick={closeDialog} secondary>
|
<Button type="button" onClick={closeDialog} secondary>
|
||||||
Cancel
|
Cancel
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
import { paths } from './schema.ts'
|
import { paths } from './schema.ts'
|
||||||
import createClient, { Middleware } from 'openapi-fetch'
|
import createClient, { Middleware } from 'openapi-fetch'
|
||||||
import { dispatchMessage } from '../messageBus/messageBus.ts'
|
import { dispatchMessage } from '../messageBus/messageBus.ts'
|
||||||
import { useUserStore } from '../user/user.ts'
|
|
||||||
import { getUserFromCookie } from '../auth/getUserFromCookie.ts'
|
|
||||||
|
|
||||||
export const initClient = () => {
|
export const initClient = () => {
|
||||||
const client = createClient<paths>({ baseUrl: import.meta.env.VITE_API_URL })
|
const client = createClient<paths>({ baseUrl: import.meta.env.VITE_API_URL })
|
||||||
|
@ -11,10 +9,6 @@ export const initClient = () => {
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
dispatchMessage('auth:unauthorized', null)
|
dispatchMessage('auth:unauthorized', null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = getUserFromCookie()
|
|
||||||
console.debug('got user cookie', user)
|
|
||||||
useUserStore.getState().setUser(user)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ export interface paths {
|
||||||
get: {
|
get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
After?: string
|
From?: string
|
||||||
Amount?: number
|
Amount?: number
|
||||||
AuthorId?: string
|
AuthorId?: string
|
||||||
Author?: string
|
Author?: string
|
||||||
|
@ -75,30 +75,7 @@ export interface paths {
|
||||||
path?: never
|
path?: never
|
||||||
cookie?: never
|
cookie?: never
|
||||||
}
|
}
|
||||||
get: {
|
get?: never
|
||||||
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
|
put?: never
|
||||||
post?: never
|
post?: never
|
||||||
delete: {
|
delete: {
|
||||||
|
@ -192,47 +169,6 @@ export interface paths {
|
||||||
patch?: never
|
patch?: never
|
||||||
trace?: never
|
trace?: never
|
||||||
}
|
}
|
||||||
'/posts/{postId}/comments': {
|
|
||||||
parameters: {
|
|
||||||
query?: never
|
|
||||||
header?: never
|
|
||||||
path?: never
|
|
||||||
cookie?: never
|
|
||||||
}
|
|
||||||
get?: never
|
|
||||||
put?: never
|
|
||||||
post: {
|
|
||||||
parameters: {
|
|
||||||
query?: never
|
|
||||||
header?: never
|
|
||||||
path: {
|
|
||||||
postId: string
|
|
||||||
}
|
|
||||||
cookie?: never
|
|
||||||
}
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
'application/json': components['schemas']['AddPostCommentRequest']
|
|
||||||
'text/json': components['schemas']['AddPostCommentRequest']
|
|
||||||
'application/*+json': components['schemas']['AddPostCommentRequest']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
responses: {
|
|
||||||
/** @description OK */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown
|
|
||||||
}
|
|
||||||
content?: never
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete?: never
|
|
||||||
options?: never
|
|
||||||
head?: never
|
|
||||||
patch?: never
|
|
||||||
trace?: never
|
|
||||||
}
|
|
||||||
'/media': {
|
'/media': {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never
|
query?: never
|
||||||
|
@ -252,8 +188,7 @@ export interface paths {
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
'multipart/form-data': {
|
'multipart/form-data': {
|
||||||
/** Format: binary */
|
file?: components['schemas']['IFormFile']
|
||||||
file?: string
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -398,78 +333,6 @@ export interface paths {
|
||||||
patch?: never
|
patch?: never
|
||||||
trace?: never
|
trace?: never
|
||||||
}
|
}
|
||||||
'/auth/change-password': {
|
|
||||||
parameters: {
|
|
||||||
query?: never
|
|
||||||
header?: never
|
|
||||||
path?: never
|
|
||||||
cookie?: never
|
|
||||||
}
|
|
||||||
get?: never
|
|
||||||
put?: never
|
|
||||||
post: {
|
|
||||||
parameters: {
|
|
||||||
query?: never
|
|
||||||
header?: never
|
|
||||||
path?: never
|
|
||||||
cookie?: never
|
|
||||||
}
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
'application/json': components['schemas']['ChangePasswordRequestBody']
|
|
||||||
'text/json': components['schemas']['ChangePasswordRequestBody']
|
|
||||||
'application/*+json': components['schemas']['ChangePasswordRequestBody']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
responses: {
|
|
||||||
/** @description OK */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown
|
|
||||||
}
|
|
||||||
content?: never
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete?: never
|
|
||||||
options?: never
|
|
||||||
head?: never
|
|
||||||
patch?: never
|
|
||||||
trace?: never
|
|
||||||
}
|
|
||||||
'/auth/delete-current-session': {
|
|
||||||
parameters: {
|
|
||||||
query?: never
|
|
||||||
header?: never
|
|
||||||
path?: never
|
|
||||||
cookie?: never
|
|
||||||
}
|
|
||||||
get?: never
|
|
||||||
put?: never
|
|
||||||
post: {
|
|
||||||
parameters: {
|
|
||||||
query?: never
|
|
||||||
header?: never
|
|
||||||
path?: never
|
|
||||||
cookie?: never
|
|
||||||
}
|
|
||||||
requestBody?: never
|
|
||||||
responses: {
|
|
||||||
/** @description OK */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown
|
|
||||||
}
|
|
||||||
content?: never
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete?: never
|
|
||||||
options?: never
|
|
||||||
head?: never
|
|
||||||
patch?: never
|
|
||||||
trace?: never
|
|
||||||
}
|
|
||||||
'/auth/session': {
|
'/auth/session': {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never
|
query?: never
|
||||||
|
@ -527,9 +390,9 @@ export interface paths {
|
||||||
[name: string]: unknown
|
[name: string]: unknown
|
||||||
}
|
}
|
||||||
content: {
|
content: {
|
||||||
'text/plain': components['schemas']['GetUserInfoResult']
|
'text/plain': components['schemas']['RefreshUserResult']
|
||||||
'application/json': components['schemas']['GetUserInfoResult']
|
'application/json': components['schemas']['RefreshUserResult']
|
||||||
'text/json': components['schemas']['GetUserInfoResult']
|
'text/json': components['schemas']['RefreshUserResult']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -602,99 +465,13 @@ export interface paths {
|
||||||
patch?: never
|
patch?: never
|
||||||
trace?: never
|
trace?: never
|
||||||
}
|
}
|
||||||
'/auth/create-signup-code': {
|
|
||||||
parameters: {
|
|
||||||
query?: never
|
|
||||||
header?: never
|
|
||||||
path?: never
|
|
||||||
cookie?: never
|
|
||||||
}
|
|
||||||
get?: never
|
|
||||||
put?: never
|
|
||||||
post: {
|
|
||||||
parameters: {
|
|
||||||
query?: never
|
|
||||||
header?: never
|
|
||||||
path?: never
|
|
||||||
cookie?: never
|
|
||||||
}
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
'application/json': components['schemas']['CreateSignupCodeRequest']
|
|
||||||
'text/json': components['schemas']['CreateSignupCodeRequest']
|
|
||||||
'application/*+json': components['schemas']['CreateSignupCodeRequest']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
responses: {
|
|
||||||
/** @description OK */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown
|
|
||||||
}
|
|
||||||
content?: never
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete?: never
|
|
||||||
options?: never
|
|
||||||
head?: never
|
|
||||||
patch?: never
|
|
||||||
trace?: never
|
|
||||||
}
|
|
||||||
'/auth/list-signup-codes': {
|
|
||||||
parameters: {
|
|
||||||
query?: never
|
|
||||||
header?: never
|
|
||||||
path?: never
|
|
||||||
cookie?: never
|
|
||||||
}
|
|
||||||
get: {
|
|
||||||
parameters: {
|
|
||||||
query?: never
|
|
||||||
header?: never
|
|
||||||
path?: never
|
|
||||||
cookie?: never
|
|
||||||
}
|
|
||||||
requestBody?: never
|
|
||||||
responses: {
|
|
||||||
/** @description OK */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown
|
|
||||||
}
|
|
||||||
content: {
|
|
||||||
'text/plain': components['schemas']['ListSignupCodesResult']
|
|
||||||
'application/json': components['schemas']['ListSignupCodesResult']
|
|
||||||
'text/json': components['schemas']['ListSignupCodesResult']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
put?: never
|
|
||||||
post?: never
|
|
||||||
delete?: never
|
|
||||||
options?: never
|
|
||||||
head?: never
|
|
||||||
patch?: never
|
|
||||||
trace?: never
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>
|
export type webhooks = Record<string, never>
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
AddPostCommentRequest: {
|
|
||||||
/** Format: uuid */
|
|
||||||
authorId: string
|
|
||||||
content: string
|
|
||||||
}
|
|
||||||
AddPostReactionRequest: {
|
AddPostReactionRequest: {
|
||||||
emoji: string
|
emoji: string
|
||||||
}
|
}
|
||||||
ChangePasswordRequestBody: {
|
|
||||||
/** Format: uuid */
|
|
||||||
userId: string
|
|
||||||
newPassword: string
|
|
||||||
}
|
|
||||||
CreatePostRequest: {
|
CreatePostRequest: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
authorId: string
|
authorId: string
|
||||||
|
@ -724,25 +501,20 @@ export interface components {
|
||||||
DeletePostReactionRequest: {
|
DeletePostReactionRequest: {
|
||||||
emoji: string
|
emoji: string
|
||||||
}
|
}
|
||||||
GetPostResponse: {
|
/** Format: binary */
|
||||||
post: components['schemas']['PostDto']
|
IFormFile: string
|
||||||
}
|
|
||||||
GetUserInfoResult: {
|
|
||||||
/** Format: uuid */
|
|
||||||
userId: string
|
|
||||||
username: string
|
|
||||||
isSuperUser: boolean
|
|
||||||
}
|
|
||||||
ListSignupCodesResult: {
|
ListSignupCodesResult: {
|
||||||
signupCodes: components['schemas']['SignupCodeDto'][]
|
signupCodes: components['schemas']['SignupCodeDto'][]
|
||||||
}
|
}
|
||||||
LoadPostsResponse: {
|
LoadPostsResponse: {
|
||||||
posts: components['schemas']['PostDto'][]
|
posts: components['schemas']['PostDto'][]
|
||||||
|
/** Format: uuid */
|
||||||
|
next: string | null
|
||||||
}
|
}
|
||||||
LoginRequest: {
|
LoginRequest: {
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
rememberMe: boolean
|
rememberMe: boolean | null
|
||||||
}
|
}
|
||||||
LoginResponse: {
|
LoginResponse: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
|
@ -755,12 +527,6 @@ export interface components {
|
||||||
authorId: string
|
authorId: string
|
||||||
username: string
|
username: string
|
||||||
}
|
}
|
||||||
PostCommentDto: {
|
|
||||||
author: string
|
|
||||||
content: string
|
|
||||||
/** Format: date-time */
|
|
||||||
postedOn: string
|
|
||||||
}
|
|
||||||
PostDto: {
|
PostDto: {
|
||||||
author: components['schemas']['PostAuthorDto']
|
author: components['schemas']['PostAuthorDto']
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
|
@ -771,7 +537,6 @@ export interface components {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string
|
createdAt: string
|
||||||
possibleReactions: string[]
|
possibleReactions: string[]
|
||||||
comments: components['schemas']['PostCommentDto'][]
|
|
||||||
}
|
}
|
||||||
PostMediaDto: {
|
PostMediaDto: {
|
||||||
/** Format: uri */
|
/** Format: uri */
|
||||||
|
@ -783,15 +548,21 @@ export interface components {
|
||||||
}
|
}
|
||||||
PostReactionDto: {
|
PostReactionDto: {
|
||||||
emoji: string
|
emoji: string
|
||||||
authorName: string
|
/** Format: int32 */
|
||||||
/** Format: date-time */
|
count: number
|
||||||
reactedOn: string
|
didReact: boolean
|
||||||
|
}
|
||||||
|
RefreshUserResult: {
|
||||||
|
/** Format: uuid */
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
isSuperUser: boolean
|
||||||
}
|
}
|
||||||
RegisterRequest: {
|
RegisterRequest: {
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
signupCode: string
|
signupCode: string
|
||||||
rememberMe: boolean
|
rememberMe: boolean | null
|
||||||
}
|
}
|
||||||
RegisterResponse: {
|
RegisterResponse: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
|
|
|
@ -19,12 +19,7 @@ export class AuthService {
|
||||||
dispatchMessage('auth:logged-in', null)
|
dispatchMessage('auth:logged-in', null)
|
||||||
}
|
}
|
||||||
|
|
||||||
async signup(
|
async signup(username: string, password: string, signupCode: string, rememberMe: boolean = false) {
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
signupCode: string,
|
|
||||||
rememberMe: boolean = false,
|
|
||||||
) {
|
|
||||||
const res = await this.client.POST('/auth/register', {
|
const res = await this.client.POST('/auth/register', {
|
||||||
body: { username, password, signupCode, email: null, rememberMe },
|
body: { username, password, signupCode, email: null, rememberMe },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
@ -44,9 +39,9 @@ export class AuthService {
|
||||||
dispatchMessage('auth:logged-out', null)
|
dispatchMessage('auth:logged-out', null)
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSignupCode(code: string, name: string) {
|
async createSignupCode(code: string, email: string, name: string) {
|
||||||
const res = await this.client.POST('/auth/signup-codes', {
|
const res = await this.client.POST('/auth/signup-codes', {
|
||||||
body: { code, email: '', name },
|
body: { code, email, name },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
|
import { useUser } from '../../user/user.ts'
|
||||||
import NavButton from '../../../components/buttons/NavButton.tsx'
|
import NavButton from '../../../components/buttons/NavButton.tsx'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { useTranslations } from '../../i18n/translations.ts'
|
|
||||||
import { useUserStore } from '../../user/user.ts'
|
|
||||||
|
|
||||||
export default function AuthNavButtons() {
|
export default function AuthNavButtons() {
|
||||||
const { t } = useTranslations()
|
const user = useUser()
|
||||||
const user = useUserStore((state) => state.user)
|
|
||||||
|
|
||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
|
|
||||||
|
@ -17,15 +15,15 @@ export default function AuthNavButtons() {
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NavButton to="/logout">{t('nav.logout')}</NavButton>
|
<NavButton to="/logout">logout</NavButton>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
const search = redirectQuery.toString()
|
const search = redirectQuery.toString()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NavButton to={{ pathname: '/login', search }}>{t('nav.login')}</NavButton>
|
<NavButton to={{ pathname: '/login', search }}>login</NavButton>
|
||||||
<NavButton to={{ pathname: '/signup', search }}>{t('nav.register')}</NavButton>
|
<NavButton to={{ pathname: '/signup', search }}>register</NavButton>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
|
import { useUser } from '../../user/user.ts'
|
||||||
import { useNavigate, Outlet } from 'react-router-dom'
|
import { useNavigate, Outlet } from 'react-router-dom'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useUserStore } from '../../user/user.ts'
|
|
||||||
|
|
||||||
export default function Protected() {
|
export default function Protected() {
|
||||||
const user = useUserStore((state) => state.user)
|
const user = useUser()
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { PropsWithChildren, useEffect, useRef } from 'react'
|
import { PropsWithChildren, useEffect, useRef } from 'react'
|
||||||
import { AuthService } from '../authService.ts'
|
import { AuthService } from '../authService.ts'
|
||||||
import { useUserStore } from '../../user/user.ts'
|
import { useUser } from '../../user/user.ts'
|
||||||
|
|
||||||
interface RefreshUserProps {
|
interface RefreshUserProps {
|
||||||
authService: AuthService
|
authService: AuthService
|
||||||
|
@ -10,7 +10,7 @@ export default function RefreshUser({
|
||||||
authService,
|
authService,
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<RefreshUserProps>) {
|
}: PropsWithChildren<RefreshUserProps>) {
|
||||||
const user = useUserStore((state) => state.user)
|
const user = useUser()
|
||||||
const didRefresh = useRef(false)
|
const didRefresh = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -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,19 +4,16 @@ import TextInput from '../../../components/inputs/TextInput.tsx'
|
||||||
import Button from '../../../components/buttons/Button.tsx'
|
import Button from '../../../components/buttons/Button.tsx'
|
||||||
import { AuthService } from '../authService.ts'
|
import { AuthService } from '../authService.ts'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useUserStore } from '../../user/user.ts'
|
import { useUser } from '../../user/user.ts'
|
||||||
import NavBar from '../../../components/NavBar.tsx'
|
import NavBar from '../../../components/NavBar.tsx'
|
||||||
import NavButton from '../../../components/buttons/NavButton.tsx'
|
import NavButton from '../../../components/buttons/NavButton.tsx'
|
||||||
import LinkButton from '../../../components/buttons/LinkButton.tsx'
|
import LinkButton from '../../../components/buttons/LinkButton.tsx'
|
||||||
import { useTranslations } from '../../i18n/translations.ts'
|
|
||||||
|
|
||||||
interface LoginPageProps {
|
interface LoginPageProps {
|
||||||
authService: AuthService
|
authService: AuthService
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginPage({ authService }: LoginPageProps) {
|
export default function LoginPage({ authService }: LoginPageProps) {
|
||||||
const { t } = useTranslations()
|
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
@ -26,7 +23,7 @@ export default function LoginPage({ authService }: LoginPageProps) {
|
||||||
const passwordInputRef = useRef<HTMLInputElement | null>(null)
|
const passwordInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const user = useUserStore((state) => state.user)
|
const user = useUser()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
|
@ -65,7 +62,7 @@ export default function LoginPage({ authService }: LoginPageProps) {
|
||||||
<SingleColumnLayout
|
<SingleColumnLayout
|
||||||
navbar={
|
navbar={
|
||||||
<NavBar>
|
<NavBar>
|
||||||
<NavButton to={'/'}>{t('nav.home')}</NavButton>
|
<NavButton to={'/'}>home</NavButton>
|
||||||
</NavBar>
|
</NavBar>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
@ -74,7 +71,7 @@ export default function LoginPage({ authService }: LoginPageProps) {
|
||||||
<form className="flex flex-col gap-4 max-w-md" onSubmit={onSubmit}>
|
<form className="flex flex-col gap-4 max-w-md" onSubmit={onSubmit}>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label htmlFor="username" className="text-sm text-gray-600">
|
<label htmlFor="username" className="text-sm text-gray-600">
|
||||||
{t('auth.username.label')}
|
username
|
||||||
</label>
|
</label>
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={usernameInputRef}
|
ref={usernameInputRef}
|
||||||
|
@ -87,7 +84,7 @@ export default function LoginPage({ authService }: LoginPageProps) {
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label htmlFor="password" className="text-sm text-gray-600">
|
<label htmlFor="password" className="text-sm text-gray-600">
|
||||||
{t('auth.password.label')}
|
password
|
||||||
</label>
|
</label>
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={passwordInputRef}
|
ref={passwordInputRef}
|
||||||
|
@ -95,11 +92,11 @@ export default function LoginPage({ authService }: LoginPageProps) {
|
||||||
id="password"
|
id="password"
|
||||||
value={password}
|
value={password}
|
||||||
onInput={setPassword}
|
onInput={setPassword}
|
||||||
className={'mb-4'}
|
className={'mb-3'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 ">
|
<div className="flex items-center gap-2 mt-2 hidden">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="rememberMe"
|
id="rememberMe"
|
||||||
|
@ -108,19 +105,19 @@ export default function LoginPage({ authService }: LoginPageProps) {
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="rememberMe" className="text-sm text-gray-600">
|
<label htmlFor="rememberMe" className="text-sm text-gray-600">
|
||||||
{t('auth.remember_me.label')}
|
DONT log me out >:(
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button className="mt-4" disabled={isSubmitting} type="submit">
|
<Button className="mt-4" disabled={isSubmitting} type="submit">
|
||||||
{isSubmitting ? t('misc.loading') : t('auth.login.cta')}
|
{isSubmitting ? 'wait...' : 'make login pls'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<LinkButton secondary to={{ pathname: '/signup', search: window.location.search }}>
|
<LinkButton secondary to={{ pathname: '/signup', search: window.location.search }}>
|
||||||
{t('auth.login.register_instead')}
|
register instead?
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
|
|
||||||
<span className={'text-xs h-3 text-red-500'}>{error}</span>
|
<span className="text-xs h-3 text-red-500">{error}</span>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { AuthService } from '../authService.ts'
|
import { AuthService } from '../authService.ts'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useUserStore } from '../../user/user.ts'
|
import { useUser } from '../../user/user.ts'
|
||||||
|
|
||||||
interface LogoutPageProps {
|
interface LogoutPageProps {
|
||||||
authService: AuthService
|
authService: AuthService
|
||||||
|
@ -9,7 +9,7 @@ interface LogoutPageProps {
|
||||||
|
|
||||||
export default function LogoutPage({ authService }: LogoutPageProps) {
|
export default function LogoutPage({ authService }: LogoutPageProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const user = useUserStore((state) => state.user)
|
const user = useUser()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|
|
@ -3,12 +3,12 @@ import { useEffect, useRef, useState, FormEvent, useCallback, Ref } from 'react'
|
||||||
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
|
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
|
||||||
import TextInput from '../../../components/inputs/TextInput.tsx'
|
import TextInput from '../../../components/inputs/TextInput.tsx'
|
||||||
import Button from '../../../components/buttons/Button.tsx'
|
import Button from '../../../components/buttons/Button.tsx'
|
||||||
|
import AnchorButton from '../../../components/buttons/AnchorButton.tsx'
|
||||||
import { invalid, valid, Validation } from '../../../utils/validation.ts'
|
import { invalid, valid, Validation } from '../../../utils/validation.ts'
|
||||||
import { AuthService } from '../authService.ts'
|
import { AuthService } from '../authService.ts'
|
||||||
import LinkButton from '../../../components/buttons/LinkButton.tsx'
|
import LinkButton from '../../../components/buttons/LinkButton.tsx'
|
||||||
import NavBar from '../../../components/NavBar.tsx'
|
import NavBar from '../../../components/NavBar.tsx'
|
||||||
import NavButton from '../../../components/buttons/NavButton.tsx'
|
import NavButton from '../../../components/buttons/NavButton.tsx'
|
||||||
import { useTranslations } from '../../i18n/translations.ts'
|
|
||||||
|
|
||||||
const SignupCodeKey = 'signupCode'
|
const SignupCodeKey = 'signupCode'
|
||||||
|
|
||||||
|
@ -17,7 +17,6 @@ interface SignupPageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SignupPage({ authService }: SignupPageProps) {
|
export default function SignupPage({ authService }: SignupPageProps) {
|
||||||
const { t } = useTranslations()
|
|
||||||
const { code } = useParams()
|
const { code } = useParams()
|
||||||
const [signupCode, setSignupCode] = useState<string | null>(null)
|
const [signupCode, setSignupCode] = useState<string | null>(null)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
@ -32,6 +31,8 @@ export default function SignupPage({ authService }: SignupPageProps) {
|
||||||
const userNameInputRef = useRef<HTMLInputElement | null>(null)
|
const userNameInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const passwordInputRef = useRef<HTMLInputElement | null>(null)
|
const passwordInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const dialogRef = useRef<HTMLDialogElement | null>(null)
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -46,6 +47,10 @@ export default function SignupPage({ authService }: SignupPageProps) {
|
||||||
theSignupCode = localStorage.getItem(SignupCodeKey)
|
theSignupCode = localStorage.getItem(SignupCodeKey)
|
||||||
setSignupCode(theSignupCode)
|
setSignupCode(theSignupCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!theSignupCode) {
|
||||||
|
dialogRef.current?.showModal()
|
||||||
|
}
|
||||||
}, [code, signupCode])
|
}, [code, signupCode])
|
||||||
|
|
||||||
useEffect(() => {}, [signupCode])
|
useEffect(() => {}, [signupCode])
|
||||||
|
@ -89,7 +94,7 @@ export default function SignupPage({ authService }: SignupPageProps) {
|
||||||
<SingleColumnLayout
|
<SingleColumnLayout
|
||||||
navbar={
|
navbar={
|
||||||
<NavBar>
|
<NavBar>
|
||||||
<NavButton to={'/'}>{t('nav.home')}</NavButton>
|
<NavButton to={'/'}>home</NavButton>
|
||||||
</NavBar>
|
</NavBar>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
@ -98,7 +103,6 @@ export default function SignupPage({ authService }: SignupPageProps) {
|
||||||
<form className="flex flex-col gap-4 max-w-md" onSubmit={onSubmit}>
|
<form className="flex flex-col gap-4 max-w-md" onSubmit={onSubmit}>
|
||||||
<FormInput
|
<FormInput
|
||||||
id="username"
|
id="username"
|
||||||
label={t('auth.username.label')}
|
|
||||||
value={username}
|
value={username}
|
||||||
onInput={setUsername}
|
onInput={setUsername}
|
||||||
error={usernameError}
|
error={usernameError}
|
||||||
|
@ -107,14 +111,13 @@ export default function SignupPage({ authService }: SignupPageProps) {
|
||||||
|
|
||||||
<FormInput
|
<FormInput
|
||||||
id="password"
|
id="password"
|
||||||
label={t('auth.password.label')}
|
|
||||||
value={password}
|
value={password}
|
||||||
onInput={setPassword}
|
onInput={setPassword}
|
||||||
error={passwordError}
|
error={passwordError}
|
||||||
type="password"
|
type="password"
|
||||||
ref={passwordInputRef}
|
ref={passwordInputRef}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="rememberMe"
|
id="rememberMe"
|
||||||
|
@ -123,7 +126,7 @@ export default function SignupPage({ authService }: SignupPageProps) {
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="rememberMe" className="text-sm text-gray-600">
|
<label htmlFor="rememberMe" className="text-sm text-gray-600">
|
||||||
{t('auth.remember_me.label')}
|
DONT log me out >:(
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
@ -131,23 +134,44 @@ export default function SignupPage({ authService }: SignupPageProps) {
|
||||||
disabled={isSubmitting || !!usernameError || !!passwordError}
|
disabled={isSubmitting || !!usernameError || !!passwordError}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{isSubmitting ? t('misc.loading') : t('auth.register.cta')}
|
{isSubmitting ? 'wait...' : 'give me an account pls'}
|
||||||
</Button>
|
</Button>
|
||||||
<LinkButton secondary to={'/login'}>
|
<LinkButton secondary to={'/login'}>
|
||||||
{t('auth.register.login_instead')}
|
login instead?
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
|
|
||||||
<span className="text-xs h-3 text-red-500">{error}</span>
|
<span className="text-xs h-3 text-red-500">{error}</span>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
id="go-away-dialog"
|
||||||
|
ref={dialogRef}
|
||||||
|
className="p-6 rounded-lg shadow-lg m-auto outline-none"
|
||||||
|
>
|
||||||
|
<div className="text-gray-600 flex flex-col gap-2">
|
||||||
|
<h1 className={`font-bold text-lg`}>STOP !!!</h1>
|
||||||
|
<p>You need an invitation to sign up</p>
|
||||||
|
<p>
|
||||||
|
I'm surprised you even found your way here without one and honestly I'd prefer it if you
|
||||||
|
would leave
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you <span className="italic">do</span> want to create an account, you should know who
|
||||||
|
to contact
|
||||||
|
</p>
|
||||||
|
<AnchorButton className={`mt-4`} href="https://en.wikipedia.org/wiki/Special:Random">
|
||||||
|
I'm sorry I'll go somewhere else :(
|
||||||
|
</AnchorButton>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
</SingleColumnLayout>
|
</SingleColumnLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormInputProps {
|
interface FormInputProps {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
|
||||||
value: string
|
value: string
|
||||||
onInput: (value: string) => void
|
onInput: (value: string) => void
|
||||||
error: string | null
|
error: string | null
|
||||||
|
@ -155,11 +179,11 @@ interface FormInputProps {
|
||||||
ref: Ref<HTMLInputElement>
|
ref: Ref<HTMLInputElement>
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormInput({ id, label, value, onInput, error, type = 'text', ref }: FormInputProps) {
|
function FormInput({ id, value, onInput, error, type = 'text', ref }: FormInputProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label htmlFor={id} className="text-sm text-gray-600">
|
<label htmlFor={id} className="text-sm text-gray-600">
|
||||||
{label}
|
{id}
|
||||||
</label>
|
</label>
|
||||||
<TextInput ref={ref} type={type} id={id} value={value} onInput={onInput} />
|
<TextInput ref={ref} type={type} id={id} value={value} onInput={onInput} />
|
||||||
<div className="text-xs h-3 text-red-500">{error}</div>
|
<div className="text-xs h-3 text-red-500">{error}</div>
|
||||||
|
|
|
@ -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,13 @@
|
||||||
import { PostMedia, PostReaction } from '../posts/posts.ts'
|
import { Post, PostMedia } from '../posts/posts.ts'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import { PostInfo } from '../posts/usePostViewModel.ts'
|
|
||||||
import { useUserStore } from '../../user/user.ts'
|
|
||||||
|
|
||||||
interface PostItemProps {
|
interface PostItemProps {
|
||||||
post: PostInfo
|
post: Post
|
||||||
reactions: PostReaction[]
|
|
||||||
addReaction: (emoji: string) => void
|
addReaction: (emoji: string) => void
|
||||||
clearReaction: (emoji: string) => void
|
clearReaction: (emoji: string) => void
|
||||||
hideViewButton?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PostItem({
|
export default function PostItem({ post, addReaction, clearReaction }: PostItemProps) {
|
||||||
post,
|
|
||||||
reactions,
|
|
||||||
addReaction,
|
|
||||||
clearReaction,
|
|
||||||
hideViewButton = false,
|
|
||||||
}: PostItemProps) {
|
|
||||||
const formattedDate = post.createdAt.toLocaleString('en-US', {
|
const formattedDate = post.createdAt.toLocaleString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
|
@ -42,14 +31,6 @@ export default function PostItem({
|
||||||
<article className={`w-full p-4 ${opacity} transition-opacity duration-500`} key={post.postId}>
|
<article className={`w-full p-4 ${opacity} transition-opacity duration-500`} key={post.postId}>
|
||||||
<div className="text-sm text-gray-500 mb-3">
|
<div className="text-sm text-gray-500 mb-3">
|
||||||
<span className="text-gray-400 mr-2">@{post.authorName}</span>• {formattedDate}
|
<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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-gray-800 mb-4 whitespace-pre-wrap">{post.content}</div>
|
<div className="text-gray-800 mb-4 whitespace-pre-wrap">{post.content}</div>
|
||||||
|
@ -62,30 +43,26 @@ export default function PostItem({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PostReactions
|
<PostReactions post={post} addReaction={addReaction} clearReaction={clearReaction} />
|
||||||
post={post}
|
|
||||||
reactions={reactions}
|
|
||||||
addReaction={addReaction}
|
|
||||||
clearReaction={clearReaction}
|
|
||||||
/>
|
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PostReactionsProps {
|
interface PostReactionsProps {
|
||||||
post: PostInfo
|
post: Post
|
||||||
reactions: PostReaction[]
|
|
||||||
addReaction: (emoji: string) => void
|
addReaction: (emoji: string) => void
|
||||||
clearReaction: (emoji: string) => void
|
clearReaction: (emoji: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function PostReactions({ post, reactions, addReaction, clearReaction }: PostReactionsProps) {
|
function PostReactions({ post, addReaction, clearReaction }: PostReactionsProps) {
|
||||||
const username = useUserStore((state) => state.user?.username)
|
const reactionMap = new Map(post.reactions.map((r) => [r.emoji, r]))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2 mt-3 justify-end">
|
<div className="flex flex-wrap gap-2 mt-3 justify-end">
|
||||||
{post.possibleReactions.map((emoji) => {
|
{post.possibleReactions.map((emoji) => {
|
||||||
const count = reactions.filter((r) => r.emoji === emoji).length
|
const reaction = reactionMap.get(emoji)
|
||||||
const didReact = reactions.some((r) => r.emoji == emoji && r.authorName == username)
|
const count = reaction?.count ?? 0
|
||||||
|
const didReact = reaction?.didReact ?? false
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
if (didReact) {
|
if (didReact) {
|
||||||
clearReaction(emoji)
|
clearReaction(emoji)
|
||||||
|
@ -122,7 +99,7 @@ function PostReactionButton({ emoji, didReact, onClick, count }: PostReactionBut
|
||||||
<button
|
<button
|
||||||
key={emoji}
|
key={emoji}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`flex items-center px-2 py-1 rounded-full border cursor-pointer ${
|
className={`flex items-center px-2 py-1 rounded-full border ${
|
||||||
didReact ? 'bg-gray-100 border-gray-400' : 'bg-white border-gray-200'
|
didReact ? 'bg-gray-100 border-gray-400' : 'bg-white border-gray-200'
|
||||||
} hover:bg-gray-100 transition-colors`}
|
} hover:bg-gray-100 transition-colors`}
|
||||||
>
|
>
|
||||||
|
@ -150,6 +127,7 @@ function PostMediaItem({ media }: PostMediaProps) {
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
src={url.toString()}
|
src={url.toString()}
|
||||||
|
alt="todo sry :("
|
||||||
className="w-full h-auto"
|
className="w-full h-auto"
|
||||||
loading="lazy"
|
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',
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,17 +1,17 @@
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { PostsService } from '../posts/postsService.ts'
|
import { PostsService } from '../posts/postsService.ts'
|
||||||
import { useUserStore } from '../../user/user.ts'
|
import { useUser } from '../../user/user.ts'
|
||||||
import { MediaService } from '../../media/mediaService.ts'
|
import { MediaService } from '../../media/mediaService.ts'
|
||||||
import NewPostWidget from '../components/NewPostWidget.tsx'
|
import NewPostWidget from '../../../components/NewPostWidget.tsx'
|
||||||
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
|
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
|
||||||
import NavBar from '../../../components/NavBar.tsx'
|
import NavBar from '../../../components/NavBar.tsx'
|
||||||
import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx'
|
import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx'
|
||||||
import { useSaveSignupCodeToLocalStorage } from '../../../hooks/useSaveSignupCodeToLocalStorage.ts'
|
import { useSaveSignupCodeToLocalStorage } from '../../../hooks/useSaveSignupCodeToLocalStorage.ts'
|
||||||
|
import { Post } from '../posts/posts.ts'
|
||||||
|
import { produce, WritableDraft } from 'immer'
|
||||||
import PostItem from '../components/PostItem.tsx'
|
import PostItem from '../components/PostItem.tsx'
|
||||||
import { useIntersectionLoad } from '../../../hooks/useIntersectionLoad.ts'
|
import { useIntersectionLoad } from '../../../hooks/useIntersectionLoad.ts'
|
||||||
import { delay } from '../../../utils/delay.ts'
|
import { delay } from '../../../utils/delay.ts'
|
||||||
import { usePostViewModel } from '../posts/usePostViewModel.ts'
|
|
||||||
import { Temporal } from '@js-temporal/polyfill'
|
|
||||||
|
|
||||||
interface HomePageProps {
|
interface HomePageProps {
|
||||||
postsService: PostsService
|
postsService: PostsService
|
||||||
|
@ -21,12 +21,11 @@ interface HomePageProps {
|
||||||
const PageSize = 20
|
const PageSize = 20
|
||||||
|
|
||||||
export default function HomePage({ postsService, mediaService }: HomePageProps) {
|
export default function HomePage({ postsService, mediaService }: HomePageProps) {
|
||||||
const user = useUserStore((state) => state.user)
|
const user = useUser()
|
||||||
useSaveSignupCodeToLocalStorage()
|
useSaveSignupCodeToLocalStorage()
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
const { posts, addPosts, addReaction, removeReaction, reactions } = usePostViewModel()
|
const [posts, setPosts] = useState<Post[]>([])
|
||||||
|
|
||||||
const [hasMore, setHasMore] = useState(true)
|
const [hasMore, setHasMore] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
@ -38,14 +37,14 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
|
||||||
loading.current = true
|
loading.current = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [{ posts }] = await Promise.all([
|
const [{ posts, next }] = await Promise.all([
|
||||||
postsService.loadPublicFeed(cursor.current, PageSize),
|
postsService.loadPublicFeed(cursor.current, PageSize),
|
||||||
delay(500),
|
delay(500),
|
||||||
])
|
])
|
||||||
|
|
||||||
setHasMore(posts.length >= PageSize)
|
setHasMore(posts.length >= PageSize)
|
||||||
cursor.current = posts.at(-1)?.postId ?? null
|
cursor.current = next
|
||||||
addPosts(posts)
|
setPosts((prev) => [...prev, ...posts])
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError((e as Error).message)
|
setError((e as Error).message)
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -74,7 +73,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const post = await postsService.createNew(user.id, content, media, isPublic)
|
const post = await postsService.createNew(user.id, content, media, isPublic)
|
||||||
addPosts([post])
|
setPosts((pages) => [post, ...pages])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create post:', error)
|
console.error('Failed to create post:', error)
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -87,13 +86,37 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
|
||||||
const onAddReaction = async (postId: string, emoji: string) => {
|
const onAddReaction = async (postId: string, emoji: string) => {
|
||||||
await postsService.addReaction(postId, emoji)
|
await postsService.addReaction(postId, emoji)
|
||||||
|
|
||||||
addReaction(postId, emoji, user!.username, Temporal.Now.instant())
|
setPosts((prev) =>
|
||||||
|
produce(prev, (draft: WritableDraft<Post[]>) => {
|
||||||
|
const post = draft.find((p) => p.postId === postId)
|
||||||
|
if (!post) return
|
||||||
|
|
||||||
|
const theReaction = post.reactions.find((r) => r.emoji === emoji)
|
||||||
|
if (theReaction) {
|
||||||
|
theReaction.count++
|
||||||
|
theReaction.didReact = true
|
||||||
|
} else {
|
||||||
|
post.reactions.push({ emoji, count: 1, didReact: true })
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClearReaction = async (postId: string, emoji: string) => {
|
const onClearReaction = async (postId: string, emoji: string) => {
|
||||||
await postsService.removeReaction(postId, emoji)
|
await postsService.removeReaction(postId, emoji)
|
||||||
|
|
||||||
removeReaction(postId, emoji, user!.username)
|
setPosts((prev) =>
|
||||||
|
produce(prev, (draft: WritableDraft<Post[]>) => {
|
||||||
|
const post = draft.find((p) => p.postId === postId)
|
||||||
|
if (!post) return
|
||||||
|
|
||||||
|
const theReaction = post.reactions.find((r) => r.emoji === emoji)
|
||||||
|
if (theReaction) {
|
||||||
|
theReaction.count = Math.max(theReaction.count - 1, 0)
|
||||||
|
theReaction.didReact = false
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
@ -116,7 +139,6 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
|
||||||
<PostItem
|
<PostItem
|
||||||
key={post.postId}
|
key={post.postId}
|
||||||
post={post}
|
post={post}
|
||||||
reactions={reactions[post.postId] ?? []}
|
|
||||||
addReaction={(emoji) => onAddReaction(post.postId, emoji)}
|
addReaction={(emoji) => onAddReaction(post.postId, emoji)}
|
||||||
clearReaction={(emoji) => onClearReaction(post.postId, emoji)}
|
clearReaction={(emoji) => onClearReaction(post.postId, emoji)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -2,16 +2,10 @@ import { Temporal } from '@js-temporal/polyfill'
|
||||||
import { components } from '../../api/schema.ts'
|
import { components } from '../../api/schema.ts'
|
||||||
import { immerable } from 'immer'
|
import { immerable } from 'immer'
|
||||||
|
|
||||||
export interface PostReaction {
|
export interface EmojiReaction {
|
||||||
emoji: string
|
emoji: string
|
||||||
authorName: string
|
count: number
|
||||||
reactedOn: Temporal.Instant
|
didReact: boolean
|
||||||
}
|
|
||||||
|
|
||||||
export interface PostComment {
|
|
||||||
author: string
|
|
||||||
content: string
|
|
||||||
postedOn: Temporal.Instant
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Post {
|
export class Post {
|
||||||
|
@ -22,9 +16,8 @@ export class Post {
|
||||||
public readonly media: PostMedia[]
|
public readonly media: PostMedia[]
|
||||||
public readonly createdAt: Temporal.Instant
|
public readonly createdAt: Temporal.Instant
|
||||||
public readonly authorName: string
|
public readonly authorName: string
|
||||||
public readonly reactions: PostReaction[]
|
public readonly reactions: EmojiReaction[]
|
||||||
public readonly possibleReactions: string[]
|
public readonly possibleReactions: string[]
|
||||||
public readonly comments: PostComment[]
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
postId: string,
|
postId: string,
|
||||||
|
@ -32,9 +25,8 @@ export class Post {
|
||||||
media: PostMedia[],
|
media: PostMedia[],
|
||||||
createdAt: string | Temporal.Instant,
|
createdAt: string | Temporal.Instant,
|
||||||
authorName: string,
|
authorName: string,
|
||||||
reactions: PostReaction[],
|
reactions: EmojiReaction[] = [],
|
||||||
possibleReactions: string[],
|
possibleReactions: string[] = [],
|
||||||
comments: PostComment[],
|
|
||||||
) {
|
) {
|
||||||
this.postId = postId
|
this.postId = postId
|
||||||
this.content = content
|
this.content = content
|
||||||
|
@ -43,7 +35,6 @@ export class Post {
|
||||||
this.authorName = authorName
|
this.authorName = authorName
|
||||||
this.reactions = reactions
|
this.reactions = reactions
|
||||||
this.possibleReactions = possibleReactions
|
this.possibleReactions = possibleReactions
|
||||||
this.comments = comments
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static fromDto(dto: components['schemas']['PostDto']): Post {
|
public static fromDto(dto: components['schemas']['PostDto']): Post {
|
||||||
|
@ -53,9 +44,12 @@ export class Post {
|
||||||
dto.media.map((m) => new PostMediaImpl(new URL(m.url), m.width, m.height)),
|
dto.media.map((m) => new PostMediaImpl(new URL(m.url), m.width, m.height)),
|
||||||
Temporal.Instant.from(dto.createdAt),
|
Temporal.Instant.from(dto.createdAt),
|
||||||
dto.author.username,
|
dto.author.username,
|
||||||
dto.reactions.map((r) => ({ ...r, reactedOn: Temporal.Instant.from(r.reactedOn) })),
|
dto.reactions.map((r) => ({
|
||||||
|
emoji: r.emoji,
|
||||||
|
count: r.count,
|
||||||
|
didReact: r.didReact,
|
||||||
|
})),
|
||||||
dto.possibleReactions,
|
dto.possibleReactions,
|
||||||
dto.comments.map((c) => ({ ...c, postedOn: Temporal.Instant.from(c.postedOn) })),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { Post } from './posts.ts'
|
import { Post } from './posts.ts'
|
||||||
import { ApiClient } from '../../api/client.ts'
|
import { ApiClient } from '../../api/client.ts'
|
||||||
import { useUserStore } from '../../user/user.ts'
|
|
||||||
|
|
||||||
export class PostsService {
|
export class PostsService {
|
||||||
constructor(private readonly client: ApiClient) {}
|
constructor(private readonly client: ApiClient) {}
|
||||||
|
@ -30,34 +29,22 @@ export class PostsService {
|
||||||
return Post.fromDto(response.data.post)
|
return Post.fromDto(response.data.post)
|
||||||
}
|
}
|
||||||
|
|
||||||
async load(postId: string): Promise<Post | null> {
|
async loadPublicFeed(
|
||||||
const response = await this.client.GET('/posts/{postId}', {
|
cursor: string | null,
|
||||||
params: {
|
amount: number | null,
|
||||||
path: { postId },
|
): Promise<{ posts: Post[]; next: string | null }> {
|
||||||
},
|
|
||||||
credentials: 'include',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.data?.post) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return Post.fromDto(response.data.post)
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadPublicFeed(cursor: string | null, amount: number | null): Promise<{ posts: Post[] }> {
|
|
||||||
const response = await this.client.GET('/posts', {
|
const response = await this.client.GET('/posts', {
|
||||||
params: {
|
params: {
|
||||||
query: { After: cursor ?? undefined, Amount: amount ?? undefined },
|
query: { From: cursor ?? undefined, Amount: amount ?? undefined },
|
||||||
},
|
},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
return { posts: [] }
|
return { posts: [], next: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { posts: response.data.posts.map(Post.fromDto) }
|
return { posts: response.data.posts.map(Post.fromDto), next: response.data.next }
|
||||||
}
|
}
|
||||||
|
|
||||||
async addReaction(postId: string, emoji: string): Promise<void> {
|
async addReaction(postId: string, emoji: string): Promise<void> {
|
||||||
|
@ -79,17 +66,6 @@ export class PostsService {
|
||||||
credentials: 'include',
|
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',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreatePostMedia {
|
interface CreatePostMedia {
|
||||||
|
|
|
@ -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,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,4 +1,8 @@
|
||||||
import { create } from 'zustand'
|
import { addMessageListener, dispatchMessage } from '../messageBus/messageBus.ts'
|
||||||
|
import { getCookie } from '../auth/cookies.ts'
|
||||||
|
import { useMessageListener } from '../../hooks/useMessageListener.ts'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { setGlobal } from '../femtoApp.ts'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
|
@ -11,12 +15,38 @@ export enum Role {
|
||||||
SuperUser = 1,
|
SuperUser = 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserState {
|
let globalUser: User | null
|
||||||
user: User | null
|
|
||||||
setUser: (user: User | null) => void
|
export function initUser() {
|
||||||
|
updateUser()
|
||||||
|
|
||||||
|
addMessageListener('auth:logged-in', updateUser)
|
||||||
|
addMessageListener('auth:registered', updateUser)
|
||||||
|
addMessageListener('auth:logged-out', updateUser)
|
||||||
|
addMessageListener('auth:refreshed', updateUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUserStore = create<UserState>()((set) => ({
|
function updateUser() {
|
||||||
user: null,
|
globalUser = getUserFromCookie()
|
||||||
setUser: (user: User | null) => set({ user }),
|
setGlobal('user', globalUser)
|
||||||
}))
|
dispatchMessage('user:updated', globalUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUser(): User | null {
|
||||||
|
const [user, setUser] = useState(globalUser)
|
||||||
|
|
||||||
|
useMessageListener('user:updated', (u) => {
|
||||||
|
setUser(u)
|
||||||
|
})
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserFromCookie(): User | null {
|
||||||
|
const userCookie = getCookie('user')
|
||||||
|
|
||||||
|
if (!userCookie) return null
|
||||||
|
|
||||||
|
// TODO validate but it should be fine
|
||||||
|
return JSON.parse(decodeURIComponent(userCookie)) as User
|
||||||
|
}
|
||||||
|
|
|
@ -1,35 +1,16 @@
|
||||||
import { PropsWithChildren, ReactNode } from 'react'
|
import { PropsWithChildren } from 'react'
|
||||||
import { Role, useUserStore } from '../app/user/user.ts'
|
import { Role, useUser } from '../app/user/user.ts'
|
||||||
import NavButton from './buttons/NavButton.tsx'
|
import NavButton from './buttons/NavButton.tsx'
|
||||||
|
|
||||||
type NavBarProps = {
|
type NavBarProps = unknown
|
||||||
leftChildren?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NavBar({ children }: PropsWithChildren<NavBarProps>) {
|
export default function NavBar({ children }: PropsWithChildren<NavBarProps>) {
|
||||||
const user = useUserStore((state) => state.user)
|
const user = useUser()
|
||||||
const isSuperUser = user?.roles.includes(Role.SuperUser)
|
const isSuperUser = user?.roles.includes(Role.SuperUser)
|
||||||
return (
|
return (
|
||||||
<nav className={`w-full flex flex-row justify-between px-4 md:px-8 py-3`}>
|
<nav className={`w-full flex flex-row justify-end gap-4 px-4 md:px-8 py-3`}>
|
||||||
<div className={`flex flex-row justify-start gap-4`}></div>
|
|
||||||
<div className={`flex flex-row justify-end gap-4`}>
|
|
||||||
{children}
|
{children}
|
||||||
{isSuperUser && <NavButton to={'/admin/codes'}>admin</NavButton>}
|
{isSuperUser && <NavButton to={'/admin/codes'}>admin</NavButton>}
|
||||||
<SourceCodeLink />
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SourceCodeLink() {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
className={`size-6`}
|
|
||||||
href="https://git.botris.dev/botris.social"
|
|
||||||
target="_blank"
|
|
||||||
title={'source code'}
|
|
||||||
>
|
|
||||||
<img style={{ color: 'red' }} src="/forgejo-logo-primary.svg" alt="Forgejo Logo" />
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import FancyTextEditor, { TextInputKeyDownEvent } from '../../../components/inputs/FancyTextEditor.tsx'
|
import FancyTextEditor, { TextInputKeyDownEvent } from './inputs/FancyTextEditor.tsx'
|
||||||
import Button from '../../../components/buttons/Button.tsx'
|
import Button from './buttons/Button.tsx'
|
||||||
import { openFileDialog } from '../../../utils/openFileDialog.ts'
|
import { openFileDialog } from '../utils/openFileDialog.ts'
|
||||||
import makePica from 'pica'
|
import makePica from 'pica'
|
||||||
import { useTranslations } from '../../i18n/translations.ts'
|
|
||||||
|
|
||||||
interface NewPostWidgetProps {
|
interface NewPostWidgetProps {
|
||||||
onSubmit: (
|
onSubmit: (
|
||||||
|
@ -23,7 +22,6 @@ interface Attachment {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPostWidgetProps) {
|
export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPostWidgetProps) {
|
||||||
const { t } = useTranslations()
|
|
||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||||
const [isPublic, setIsPublic] = useState(false)
|
const [isPublic, setIsPublic] = useState(false)
|
||||||
|
@ -74,7 +72,7 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
|
||||||
onInput={onContentInput}
|
onInput={onContentInput}
|
||||||
onKeyDown={onInputKeyDown}
|
onKeyDown={onInputKeyDown}
|
||||||
className="mb-3"
|
className="mb-3"
|
||||||
placeholder={t('post.editor.placeholder')}
|
placeholder="write something..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
|
@ -95,7 +93,7 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
|
||||||
<div className="flex justify-between items-center pt-2">
|
<div className="flex justify-between items-center pt-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button secondary onClick={onAddMediaClicked}>
|
<Button secondary onClick={onAddMediaClicked}>
|
||||||
{t('post.add_media.cta')}
|
+ add media
|
||||||
</Button>
|
</Button>
|
||||||
<label className="flex items-center gap-1 cursor-pointer">
|
<label className="flex items-center gap-1 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
|
@ -105,14 +103,14 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="form-checkbox h-4 w-4 text-blue-600"
|
className="form-checkbox h-4 w-4 text-blue-600"
|
||||||
/>
|
/>
|
||||||
<span className="text-primary-500">{t('post.public.label')}</span>
|
<span className="text-primary-500">public</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isSubmitting || (content.trim() === '' && attachments.length === 0)}
|
disabled={isSubmitting || (content.trim() === '' && attachments.length === 0)}
|
||||||
>
|
>
|
||||||
{t('post.submit.cta')}
|
post
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
22
src/initApp.ts
Normal file
22
src/initApp.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { initUser } from './app/user/user.ts'
|
||||||
|
import { setGlobal } from './app/femtoApp.ts'
|
||||||
|
import { PostsService } from './app/feed/posts/postsService.ts'
|
||||||
|
import { MediaService } from './app/media/mediaService.ts'
|
||||||
|
import { AuthService } from './app/auth/authService.ts'
|
||||||
|
import { initClient } from './app/api/client.ts'
|
||||||
|
|
||||||
|
export function initApp() {
|
||||||
|
setGlobal('version', import.meta.env.VITE_FEMTO_VERSION)
|
||||||
|
initUser()
|
||||||
|
|
||||||
|
const client = initClient()
|
||||||
|
|
||||||
|
const postService = new PostsService(client)
|
||||||
|
const mediaService = new MediaService(client)
|
||||||
|
const authService = new AuthService(client)
|
||||||
|
|
||||||
|
setGlobal('postsService', postService)
|
||||||
|
setGlobal('authService', authService)
|
||||||
|
|
||||||
|
return { postService, mediaService, authService }
|
||||||
|
}
|
|
@ -1,10 +1,10 @@
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import { useUser } from './app/user/user.ts'
|
||||||
import { AuthService } from './app/auth/authService.ts'
|
import { AuthService } from './app/auth/authService.ts'
|
||||||
import { useUserStore } from './app/user/user.ts'
|
|
||||||
|
|
||||||
// Starts a loop that pings the server to keep the session alive, while also getting any updates on the user profile
|
// Starts a loop that pings the server to keep the session alive, while also getting any updates on the user profile
|
||||||
export function useRefreshSessionLoop(authService: AuthService) {
|
export function useRefreshSessionLoop(authService: AuthService) {
|
||||||
const user = useUserStore((state) => state.user)
|
const user = useUser()
|
||||||
const userId = user?.id ?? null
|
const userId = user?.id ?? null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -2574,8 +2574,3 @@ zod@^3.23.8, zod@^3.24.2:
|
||||||
version "3.24.3"
|
version "3.24.3"
|
||||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.3.tgz#1f40f750a05e477396da64438e0e1c0995dafd87"
|
resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.3.tgz#1f40f750a05e477396da64438e0e1c0995dafd87"
|
||||||
integrity sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==
|
integrity sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==
|
||||||
|
|
||||||
zustand@^5.0.7:
|
|
||||||
version "5.0.7"
|
|
||||||
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.7.tgz#e325364e82c992a84bf386d8445aa7f180c450dc"
|
|
||||||
integrity sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue