diff --git a/.env b/.env deleted file mode 100644 index e69de29..0000000 diff --git a/.gitignore b/.gitignore index a547bf3..50c8dda 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? + +.env diff --git a/Dockerfile b/Dockerfile index 751039c..fc24bad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,15 @@ -# Stage 1: Build the Vite React app +# Stage 1: Dependencies install (cached if lockfile unchanged) +FROM node:22-alpine AS deps + +WORKDIR /app + +# Only copy dependency-related files to leverage cache +COPY package.json yarn.lock ./ + +# Install dependencies +RUN yarn install --frozen-lockfile + +# Stage 2: Build the app FROM node:22-alpine AS builder ARG VITE_API_URL @@ -6,15 +17,25 @@ ENV VITE_API_URL=$VITE_API_URL WORKDIR /app -# Install dependencies -COPY package.json /app -COPY yarn.lock /app -RUN yarn install +# Copy deps from previous stage to cache node_modules +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/package.json ./package.json +COPY --from=deps /app/yarn.lock ./yarn.lock -COPY . . +# Copy rest of app +COPY src/ ./src +COPY public/ ./public +COPY .env.production ./.env.production +COPY index.html ./index.html +COPY tsconfig.json ./tsconfig.json +COPY tsconfig.app.json ./tsconfig.app.json +COPY tsconfig.node.json ./tsconfig.node.json +COPY vite.config.ts ./vite.config.ts + +# Build the Vite app RUN yarn build -# Stage 2: Serve with Caddy +# Stage 3: Serve with Caddy FROM caddy:alpine # Copy built app to the web root diff --git a/index.html b/index.html index e4b78ea..7b84b23 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,10 @@ - + - Vite + React + TS + social media website +
diff --git a/package.json b/package.json index 792ff01..ada1684 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "femto-webapp", "private": true, - "version": "1.2.0", + "version": "1.26.6", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", + "build:deploy": "bash scripts/publish.sh", "generate:schema": "node scripts/generate-schema.mjs" }, "dependencies": { @@ -15,13 +16,17 @@ "@tailwindcss/vite": "^4.1.5", "immer": "^10.1.1", "openapi-fetch": "^0.14.0", + "pica": "^9.0.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.6.0", - "tailwindcss": "^4.1.5" + "tailwindcss": "^4.1.5", + "zustand": "^5.0.7" }, "devDependencies": { "@eslint/js": "^9.22.0", + "@types/node": "^22.15.19", + "@types/pica": "^9.0.5", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@types/react-router-dom": "^5.3.3", diff --git a/public/forgejo-logo-primary.svg b/public/forgejo-logo-primary.svg new file mode 100644 index 0000000..7f64c1a --- /dev/null +++ b/public/forgejo-logo-primary.svg @@ -0,0 +1,40 @@ + + + + + Forgejo logo + Caesar Schinas + + + + + + + + + + + + + diff --git a/public/icon_64.png b/public/icon_64.png new file mode 100644 index 0000000..bb614dc Binary files /dev/null and b/public/icon_64.png differ diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/scripts/bump-build-push.sh b/scripts/bump-build-push.sh deleted file mode 100755 index 529376f..0000000 --- a/scripts/bump-build-push.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -# CONFIGURATION -REGISTRY="docker.botris.dev" -USERNAME="johnbotris" -IMAGE_NAME="femto-webapp" - -# Add this before the docker build line -export VITE_API_URL="https://femto-api.botris.social" - - -# Step 0: Ensure clean working directory -if [[ -n $(git status --porcelain) ]]; then - echo "❌ Uncommitted changes detected. Please commit or stash them before running this script." - exit 1 -fi - -# Step 1: Store current version to revert if needed -OLD_VERSION=$(node -p "require('./package.json').version") -echo "🔍 Current version: $OLD_VERSION" - -# Step 2: Bump version without Git tag/commit -echo "🚀 Bumping minor version..." -yarn version --minor --no-git-tag-version -NEW_VERSION=$(node -p "require('./package.json').version") -echo "📦 New version: $NEW_VERSION" - -# Step 3: Attempt Docker build -echo "🔧 Building Docker image..." - -if ! docker build --build-arg VITE_API_URL="$VITE_API_URL" -t $IMAGE_NAME .; then - echo "❌ Docker build failed. Reverting version bump..." - git checkout -- package.json yarn.lock - exit 1 -fi - -# Step 4: Tag and push Docker image -FULL_IMAGE="$REGISTRY/$USERNAME/$IMAGE_NAME" -echo "🏷️ Tagging Docker image..." -docker tag $IMAGE_NAME $FULL_IMAGE:$NEW_VERSION -docker tag $IMAGE_NAME $FULL_IMAGE:latest - -echo "📤 Pushing Docker images..." -docker push $FULL_IMAGE:$NEW_VERSION -docker push $FULL_IMAGE:latest - -# Step 5: Commit version bump & tag -echo "✅ Committing and tagging version bump..." -git add package.json yarn.lock -git commit -m "chore(release): v$NEW_VERSION" -git tag "v$NEW_VERSION" -git push origin main -git push origin "v$NEW_VERSION" - -echo "🎉 Release v$NEW_VERSION complete." diff --git a/scripts/generate-schema.mjs b/scripts/generate-schema.mjs index 4381bbf..edc8972 100644 --- a/scripts/generate-schema.mjs +++ b/scripts/generate-schema.mjs @@ -14,9 +14,7 @@ export async function generateApiSchema(openapiUrl, outputFilePath, pathToPretti const request = new Request(openapiUrl) const response = await fetch(request) const json = await response.text() - const ast = await openapiTS(json, { - pathParamsAsTypes: true, - }) + const ast = await openapiTS(json, {}) const prettierConfig = await resolveConfig(pathToPrettierRc, { useCache: true, }) diff --git a/scripts/get-local-api-url.js b/scripts/get-local-api-url.js deleted file mode 100644 index 96f17b8..0000000 --- a/scripts/get-local-api-url.js +++ /dev/null @@ -1,28 +0,0 @@ -import os from 'node:os' - -/** - * Get the private IP addr of the dev machine to use as the API url. - * This is preferred to using localhost or 0.0.0.0 because it allows - * us to use the dev client from other devices (i.e. phones) - * @returns {string} - */ -export function getLocalApiUrl() { - const addresses = Object.values(os.networkInterfaces()) - .flat() - .filter((addr) => !addr.internal) - .filter((addr) => addr.family === 'IPv4') - .map((addr) => addr.address) - - let address = addresses.find((addr) => addr.startsWith('192.168')) ?? addresses.at(0) - - if (address === undefined) { - console.warn("Couldn't identify the local address for the server. falling back to localhost") - address = 'localhost' - } - - if (addresses.length > 1) { - console.warn(`chose API URL ${address} from possible choices: ${addresses.join(', ')}`) - } - - return `http://${address}:7295` -} diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100755 index 0000000..68a75e4 --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +set -euo pipefail + +# Function to display help text +show_help() { + echo "Usage: $0 [OPTIONS]" + echo + echo "Description:" + echo " This script automates the process of bumping the version, building a Docker image," + echo " pushing it to the registry, and optionally deploying to production." + echo + echo "Options:" + echo " -h,--help Display this help message and exit" + echo " -d,--deploy Deploy to production after building and pushing" + echo " --major Bump the major version (x.0.0)" + echo " --minor Bump the minor version (0.x.0)" + echo " --patch Bump the patch version (0.0.x) [default]" + echo + echo "Examples:" + echo " $0 # Bump patch version, build and push" + echo " $0 --minor # Bump minor version, build and push" + echo " $0 --major -d # Bump major version, build, push and deploy" + echo " $0 --patch --deploy # Bump patch version, build, push and deploy" + echo +} + +# Parse command line arguments +DEPLOY=false +VERSION_TYPE="patch" # Default to patch version bump + +for arg in "$@"; do + case "$arg" in + -h|--help) show_help; exit 0 ;; + -d|--deploy) DEPLOY=true ;; + --major) VERSION_TYPE="major" ;; + --minor) VERSION_TYPE="minor" ;; + --patch) VERSION_TYPE="patch" ;; + *) echo "Unknown option: $arg"; echo "Usage: $0 [-h|--help] [-d|--deploy] [--major|--minor|--patch]"; exit 1 ;; + esac +done + +# CONFIGURATION +REGISTRY="docker.botris.dev" +USERNAME="johnbotris" +IMAGE_NAME="femto-webapp" + +# Add this before the docker build line +export VITE_API_URL="https://api.botris.social" + +# Step 0: Ensure clean working directory +if [[ -n $(git status --porcelain) ]]; then + echo "❌ Uncommitted changes detected. Please commit or stash them before running this script." + exit 1 +fi + +# Step 1: Store current version to revert if needed +OLD_VERSION=$(node -p "require('./package.json').version") +echo "🔍 Current version: $OLD_VERSION" + +# Step 2: Bump version without Git tag/commit +echo "🚀 Bumping $VERSION_TYPE version..." +yarn version --$VERSION_TYPE --no-git-tag-version +NEW_VERSION=$(node -p "require('./package.json').version") +echo "📦 New version: $NEW_VERSION" + +# Step 3: Attempt Docker build +echo "🔧 Building Docker image..." + +if ! docker build --build-arg VITE_API_URL="$VITE_API_URL" -t $IMAGE_NAME .; then + echo "❌ Docker build failed. Reverting version bump..." + git checkout -- package.json yarn.lock + exit 1 +fi + +# Step 4: Tag and push Docker image +FULL_IMAGE="$REGISTRY/$USERNAME/$IMAGE_NAME" +echo "🏷️ Tagging Docker image..." +docker tag $IMAGE_NAME $FULL_IMAGE:$NEW_VERSION +docker tag $IMAGE_NAME $FULL_IMAGE:latest + +echo "📤 Pushing Docker images..." +docker push $FULL_IMAGE:$NEW_VERSION +docker push $FULL_IMAGE:latest + +# Step 5: Commit version bump & tag +echo "✅ Committing and tagging version bump..." +git add package.json yarn.lock +git commit -m "v$NEW_VERSION" +git tag "v$NEW_VERSION" +git push origin main +git push origin "v$NEW_VERSION" + +echo "🎉 Release v$NEW_VERSION complete." + +# Step 6: Deploy if flag is set +if [ "$DEPLOY" = true ]; then + echo "🚀 Deploying to production..." + ssh john@botris.social 'bash /home/john/docker/femto/update.sh' + echo "✅ Deployment complete." +fi diff --git a/src/App.tsx b/src/App.tsx index 8b27c8b..9063592 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,20 +1,42 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom' import HomePage from './app/feed/pages/HomePage.tsx' -import { PostsService } from './app/feed/posts/postsService.ts' -import AuthorPage from './app/feed/pages/AuthorPage.tsx' -import { MediaService } from './app/media/mediaService.ts' +import PostPage from './app/feed/pages/PostPage.tsx' import SignupPage from './app/auth/pages/SignupPage.tsx' import LoginPage from './app/auth/pages/LoginPage.tsx' -import { AuthService } from './app/auth/authService.ts' -import { useUser } from './app/user/userStore.ts' import LogoutPage from './app/auth/pages/LogoutPage.tsx' import UnauthorizedHandler from './app/auth/components/UnauthorizedHandler.tsx' +import AdminPage from './app/admin/pages/AdminPage.tsx' +import SignupCodesManagementPage from './app/admin/pages/subpages/SignupCodesManagementPage.tsx' +import { useRefreshSessionLoop } from './useRefreshSessionLoop.ts' +import { setGlobal } from './app/femtoApp.ts' +import { PostsService } from './app/feed/posts/postsService.ts' +import { MediaService } from './app/media/mediaService.ts' +import { AuthService } from './app/auth/authService.ts' +import { initClient } from './app/api/client.ts' +import { useEffect } from 'react' +import { useUserStore } from './app/user/user.ts' +import { getUserFromCookie } from './app/auth/getUserFromCookie.ts' -function App() { - const { user } = useUser() - const postService = new PostsService() - const mediaService = new MediaService() - const authService = new AuthService(user) +setGlobal('version', import.meta.env.VITE_FEMTO_VERSION) + +const client = initClient() +const postService = new PostsService(client) +const mediaService = new MediaService(client) +const authService = new AuthService(client) + +setGlobal('postsService', postService) +setGlobal('authService', authService) + +export default function App() { + const setUser = useUserStore((state) => state.setUser) + + useRefreshSessionLoop(authService) + + useEffect(() => { + const user = getUserFromCookie() + console.debug('got user cookie', user) + setUser(user) + }, [setUser]) return ( @@ -24,14 +46,18 @@ function App() { path={'/'} element={} /> - } /> + } /> } /> } /> } /> + }> + } + /> + ) } - -export default App diff --git a/src/app/admin/pages/AdminPage.tsx b/src/app/admin/pages/AdminPage.tsx new file mode 100644 index 0000000..18fa310 --- /dev/null +++ b/src/app/admin/pages/AdminPage.tsx @@ -0,0 +1,24 @@ +import NavBar from '../../../components/NavBar' +import NavButton from '../../../components/buttons/NavButton' +import { Outlet } from 'react-router-dom' + +export default function AdminPage() { + return ( +
+ + home + + +
+ +
+ +
+
+
+ ) +} diff --git a/src/app/admin/pages/subpages/SignupCodesManagementPage.tsx b/src/app/admin/pages/subpages/SignupCodesManagementPage.tsx new file mode 100644 index 0000000..0075c73 --- /dev/null +++ b/src/app/admin/pages/subpages/SignupCodesManagementPage.tsx @@ -0,0 +1,218 @@ +import { AuthService } from '../../../auth/authService.ts' +import { useEffect, useState, useRef, MouseEvent, useCallback } from 'react' +import { SignupCode } from '../../../auth/signupCode.ts' +import { Temporal } from '@js-temporal/polyfill' +import Button from '../../../../components/buttons/Button.tsx' + +interface SignupCodesManagementPageProps { + authService: AuthService +} + +export default function SignupCodesManagementPage({ authService }: SignupCodesManagementPageProps) { + const [codes, setCodes] = useState([]) + const [code, setCode] = useState('') + const [name, setName] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const dialogRef = useRef(null) + const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null) + const [activeCode, setActiveCode] = useState(null) + + const fetchCodes = useCallback(async () => { + try { + setCodes(await authService.listSignupCodes()) + } catch (err) { + console.error('Failed to fetch signup codes:', err) + } + }, [authService]) + + useEffect(() => { + const timeoutId = setTimeout(fetchCodes) + return () => clearTimeout(timeoutId) + }, [authService, fetchCodes]) + + const handleCreateCode = async (e: React.FormEvent) => { + e.preventDefault() + setIsLoading(true) + setError(null) + + try { + await authService.createSignupCode(code, name) + setCode('') + setName('') + dialogRef.current?.close() + fetchCodes() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create signup code') + } finally { + setIsLoading(false) + } + } + + const openDialog = () => { + dialogRef.current?.showModal() + } + + const closeDialog = () => { + dialogRef.current?.close() + setError(null) + } + + const formatDate = (date: Temporal.Instant | null) => { + if (!date) return 'Never' + try { + const jsDate = new Date(date.epochMilliseconds) + + // Format as: "Jan 1, 2023, 12:00 PM" + return jsDate.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + } catch (err: unknown) { + console.error(err) + return date.toString() + } + } + + const copyCodeToClipboard = (code: string, e: MouseEvent) => { + e.preventDefault() + const host = window.location.origin + const url = `${host}?c=${code}` + navigator.clipboard.writeText(url).catch((err) => { + console.error('Failed to copy:', err) + }) + setTooltipPosition(null) + setActiveCode(null) + } + + const showTooltip = (code: string, e: MouseEvent) => { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() + setTooltipPosition({ + x: rect.left + rect.width / 2, + y: rect.top - 10, + }) + setActiveCode(code) + } + + const hideTooltip = () => { + setTooltipPosition(null) + setActiveCode(null) + } + + return ( + <> +
+

Signup Codes

+ +
+ +
+ + + + + + + + + + {codes.map((code) => ( + + + + + + ))} + {codes.length === 0 && ( + + + + )} + +
CodeRedeemed ByExpires On
+ + {code.redeemedBy || 'Not redeemed'}{formatDate(code.expiresOn)}
+ No signup codes found +
+
+ + +

Create New Signup Code

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setCode(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md" + required + /> +
+ +
+ + setName(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md" + required + /> +
+ +
+ + +
+
+
+ + {tooltipPosition && activeCode && ( + + Copy to clipboard + + )} + + ) +} diff --git a/src/app/api/client.ts b/src/app/api/client.ts index 095af42..eebacdf 100644 --- a/src/app/api/client.ts +++ b/src/app/api/client.ts @@ -1,18 +1,25 @@ import { paths } from './schema.ts' import createClient, { Middleware } from 'openapi-fetch' import { dispatchMessage } from '../messageBus/messageBus.ts' +import { useUserStore } from '../user/user.ts' +import { getUserFromCookie } from '../auth/getUserFromCookie.ts' -const client = createClient({ baseUrl: `${location.protocol}//${location.hostname}:5181` }) +export const initClient = () => { + const client = createClient({ baseUrl: import.meta.env.VITE_API_URL }) + const UnauthorizedHandlerMiddleware: Middleware = { + async onResponse({ response }) { + if (response.status === 401) { + dispatchMessage('auth:unauthorized', null) + } -const UnauthorizedHandlerMiddleware: Middleware = { - async onResponse({ response }) { - if (response.status === 401) { - dispatchMessage('auth:unauthorized', null) - } - }, + const user = getUserFromCookie() + console.debug('got user cookie', user) + useUserStore.getState().setUser(user) + }, + } + + client.use(UnauthorizedHandlerMiddleware) + return client } -client.use(UnauthorizedHandlerMiddleware) - -// todo inject this if necessary -export default client +export type ApiClient = ReturnType diff --git a/src/app/api/schema.ts b/src/app/api/schema.ts index 6023ed1..6af29b3 100644 --- a/src/app/api/schema.ts +++ b/src/app/api/schema.ts @@ -9,7 +9,7 @@ export interface paths { get: { parameters: { query?: { - From?: string + After?: string Amount?: number AuthorId?: string Author?: string @@ -26,9 +26,9 @@ export interface paths { [name: string]: unknown } content: { - 'text/plain': components['schemas']['GetAllPublicPostsResponse'] - 'application/json': components['schemas']['GetAllPublicPostsResponse'] - 'text/json': components['schemas']['GetAllPublicPostsResponse'] + 'text/plain': components['schemas']['LoadPostsResponse'] + 'application/json': components['schemas']['LoadPostsResponse'] + 'text/json': components['schemas']['LoadPostsResponse'] } } } @@ -68,6 +68,171 @@ export interface paths { patch?: never trace?: never } + '/posts/{postId}': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get: { + parameters: { + query?: never + header?: never + path: { + postId: string + } + cookie?: never + } + requestBody?: never + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'text/plain': components['schemas']['GetPostResponse'] + 'application/json': components['schemas']['GetPostResponse'] + 'text/json': components['schemas']['GetPostResponse'] + } + } + } + } + put?: never + post?: never + delete: { + parameters: { + query?: never + header?: never + path: { + postId: string + } + cookie?: never + } + requestBody?: never + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + options?: never + head?: never + patch?: never + trace?: never + } + '/posts/{postId}/reactions': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + post: { + parameters: { + query?: never + header?: never + path: { + postId: string + } + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['AddPostReactionRequest'] + 'text/json': components['schemas']['AddPostReactionRequest'] + 'application/*+json': components['schemas']['AddPostReactionRequest'] + } + } + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + delete: { + parameters: { + query?: never + header?: never + path: { + postId: string + } + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['DeletePostReactionRequest'] + 'text/json': components['schemas']['DeletePostReactionRequest'] + 'application/*+json': components['schemas']['DeletePostReactionRequest'] + } + } + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + options?: never + head?: never + patch?: never + trace?: never + } + '/posts/{postId}/comments': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + post: { + parameters: { + query?: never + header?: never + path: { + postId: string + } + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['AddPostCommentRequest'] + 'text/json': components['schemas']['AddPostCommentRequest'] + 'application/*+json': components['schemas']['AddPostCommentRequest'] + } + } + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/media': { parameters: { query?: never @@ -112,7 +277,7 @@ export interface paths { patch?: never trace?: never } - [path: `/media/${string}`]: { + '/media/{id}': { parameters: { query?: never header?: never @@ -233,6 +398,78 @@ export interface paths { patch?: never trace?: never } + '/auth/change-password': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + post: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['ChangePasswordRequestBody'] + 'text/json': components['schemas']['ChangePasswordRequestBody'] + 'application/*+json': components['schemas']['ChangePasswordRequestBody'] + } + } + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/auth/delete-current-session': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + post: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/auth/session': { parameters: { query?: never @@ -266,10 +503,198 @@ export interface paths { patch?: never trace?: never } + '/auth/user/{userId}': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get: { + parameters: { + query?: never + header?: never + path: { + userId: string + } + cookie?: never + } + requestBody?: never + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'text/plain': components['schemas']['GetUserInfoResult'] + 'application/json': components['schemas']['GetUserInfoResult'] + 'text/json': components['schemas']['GetUserInfoResult'] + } + } + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/auth/signup-codes': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'text/plain': components['schemas']['ListSignupCodesResult'] + 'application/json': components['schemas']['ListSignupCodesResult'] + 'text/json': components['schemas']['ListSignupCodesResult'] + } + } + } + } + put?: never + post: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['CreateSignupCodeRequest'] + 'text/json': components['schemas']['CreateSignupCodeRequest'] + 'application/*+json': components['schemas']['CreateSignupCodeRequest'] + } + } + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/auth/create-signup-code': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + post: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['CreateSignupCodeRequest'] + 'text/json': components['schemas']['CreateSignupCodeRequest'] + 'application/*+json': components['schemas']['CreateSignupCodeRequest'] + } + } + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/auth/list-signup-codes': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'text/plain': components['schemas']['ListSignupCodesResult'] + 'application/json': components['schemas']['ListSignupCodesResult'] + 'text/json': components['schemas']['ListSignupCodesResult'] + } + } + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } } export type webhooks = Record export interface components { schemas: { + AddPostCommentRequest: { + /** Format: uuid */ + authorId: string + content: string + } + AddPostReactionRequest: { + emoji: string + } + ChangePasswordRequestBody: { + /** Format: uuid */ + userId: string + newPassword: string + } CreatePostRequest: { /** Format: uuid */ authorId: string @@ -289,36 +714,64 @@ export interface components { height: number | null } CreatePostResponse: { - /** Format: uuid */ - postId: string + post: components['schemas']['PostDto'] } - GetAllPublicPostsResponse: { - posts: components['schemas']['PostDto'][] + CreateSignupCodeRequest: { + code: string + email: string + name: string + } + DeletePostReactionRequest: { + emoji: string + } + GetPostResponse: { + post: components['schemas']['PostDto'] + } + GetUserInfoResult: { /** Format: uuid */ - next: string | null + userId: string + username: string + isSuperUser: boolean + } + ListSignupCodesResult: { + signupCodes: components['schemas']['SignupCodeDto'][] + } + LoadPostsResponse: { + posts: components['schemas']['PostDto'][] } LoginRequest: { username: string password: string + rememberMe: boolean } LoginResponse: { /** Format: uuid */ userId: string username: string + isSuperUser: boolean } PostAuthorDto: { /** Format: uuid */ authorId: string username: string } + PostCommentDto: { + author: string + content: string + /** Format: date-time */ + postedOn: string + } PostDto: { author: components['schemas']['PostAuthorDto'] /** Format: uuid */ postId: string content: string media: components['schemas']['PostMediaDto'][] + reactions: components['schemas']['PostReactionDto'][] /** Format: date-time */ createdAt: string + possibleReactions: string[] + comments: components['schemas']['PostCommentDto'][] } PostMediaDto: { /** Format: uri */ @@ -328,16 +781,33 @@ export interface components { /** Format: int32 */ height: number | null } + PostReactionDto: { + emoji: string + authorName: string + /** Format: date-time */ + reactedOn: string + } RegisterRequest: { username: string password: string signupCode: string - email: string | null + rememberMe: boolean } RegisterResponse: { /** Format: uuid */ userId: string username: string + isSuperUser: boolean + } + SignupCodeDto: { + code: string + email: string + name: string + /** Format: uuid */ + redeemingUserId: string | null + redeemingUsername: string | null + /** Format: date-time */ + expiresOn: string | null } UploadMediaResponse: { /** Format: uuid */ diff --git a/src/app/auth/authService.ts b/src/app/auth/authService.ts index 3668822..ad1c9bc 100644 --- a/src/app/auth/authService.ts +++ b/src/app/auth/authService.ts @@ -1,17 +1,14 @@ -import { User } from '../user/userStore.ts' import { dispatchMessage } from '../messageBus/messageBus.ts' -import client from '../api/client.ts' +import { ProblemDetails } from '../../types' +import { SignupCode } from './signupCode.ts' +import { ApiClient } from '../api/client.ts' export class AuthService { - constructor(private readonly user: User | null) {} + constructor(private readonly client: ApiClient) {} - async login(username: string, password: string) { - if (this.user != null) { - throw new Error('already logged in') - } - - const res = await client.POST('/auth/login', { - body: { username, password }, + async login(username: string, password: string, rememberMe: boolean = false) { + const res = await this.client.POST('/auth/login', { + body: { username, password, rememberMe }, credentials: 'include', }) @@ -19,33 +16,67 @@ export class AuthService { throw new Error('invalid credentials') } - dispatchMessage('auth:logged-in', { ...res.data }) + dispatchMessage('auth:logged-in', null) } - async signup(username: string, password: string, signupCode: string) { - if (this.user != null) { - throw new Error('already logged in') - } - - const res = await client.POST('/auth/register', { - body: { username, password, signupCode, email: null }, + async signup( + username: string, + password: string, + signupCode: string, + rememberMe: boolean = false, + ) { + const res = await this.client.POST('/auth/register', { + body: { username, password, signupCode, email: null, rememberMe }, credentials: 'include', }) if (!res.data) { - throw new Error('invalid credentials') + console.error(res.error) + throw new Error((res.error as ProblemDetails)?.detail ?? 'invalid credentials') } - dispatchMessage('auth:registered', { ...res.data }) + dispatchMessage('auth:registered', null) } async logout() { - if (this.user == null) { - return - } - - await client.DELETE('/auth/session', { credentials: 'include' }) + await this.client.DELETE('/auth/session', { credentials: 'include' }) dispatchMessage('auth:logged-out', null) } + + async createSignupCode(code: string, name: string) { + const res = await this.client.POST('/auth/signup-codes', { + body: { code, email: '', name }, + credentials: 'include', + }) + + if (!res.data) { + console.error(res.error) + throw new Error('failed to create signup code') + } + } + + async listSignupCodes() { + const res = await this.client.GET('/auth/signup-codes', { + credentials: 'include', + }) + + if (!res.data) { + console.error(res.error) + throw new Error('error') + } + + return res.data.signupCodes.map(SignupCode.fromDto) + } + + async refreshUser(userId: string) { + await this.client.GET(`/auth/user/{userId}`, { + params: { + path: { userId }, + }, + credentials: 'include', + }) + + dispatchMessage('auth:refreshed', null) + } } diff --git a/src/app/auth/components/AuthNavButtons.tsx b/src/app/auth/components/AuthNavButtons.tsx index 33769c1..0c5141b 100644 --- a/src/app/auth/components/AuthNavButtons.tsx +++ b/src/app/auth/components/AuthNavButtons.tsx @@ -1,9 +1,11 @@ -import { useUser } from '../../user/userStore.ts' import NavButton from '../../../components/buttons/NavButton.tsx' import { useLocation } from 'react-router-dom' +import { useTranslations } from '../../i18n/translations.ts' +import { useUserStore } from '../../user/user.ts' export default function AuthNavButtons() { - const { user } = useUser() + const { t } = useTranslations() + const user = useUserStore((state) => state.user) const { pathname } = useLocation() @@ -15,15 +17,15 @@ export default function AuthNavButtons() { if (loggedIn) { return ( <> - logout + {t('nav.logout')} ) } else { const search = redirectQuery.toString() return ( <> - login - register + {t('nav.login')} + {t('nav.register')} ) } diff --git a/src/app/auth/components/Protected.tsx b/src/app/auth/components/Protected.tsx index ad72109..19d71bb 100644 --- a/src/app/auth/components/Protected.tsx +++ b/src/app/auth/components/Protected.tsx @@ -1,9 +1,9 @@ -import { useUser } from '../../user/userStore.ts' import { useNavigate, Outlet } from 'react-router-dom' import { useEffect } from 'react' +import { useUserStore } from '../../user/user.ts' export default function Protected() { - const { user } = useUser() + const user = useUserStore((state) => state.user) const navigate = useNavigate() diff --git a/src/app/auth/components/RefreshUser.tsx b/src/app/auth/components/RefreshUser.tsx new file mode 100644 index 0000000..7d1db08 --- /dev/null +++ b/src/app/auth/components/RefreshUser.tsx @@ -0,0 +1,27 @@ +import { PropsWithChildren, useEffect, useRef } from 'react' +import { AuthService } from '../authService.ts' +import { useUserStore } from '../../user/user.ts' + +interface RefreshUserProps { + authService: AuthService +} + +export default function RefreshUser({ + authService, + children, +}: PropsWithChildren) { + const user = useUserStore((state) => state.user) + const didRefresh = useRef(false) + + useEffect(() => { + const timeoutId = setTimeout(async () => { + if (didRefresh.current) return + if (user == null) return + didRefresh.current = true + await authService.refreshUser(user.id) + }) + return () => clearTimeout(timeoutId) + }, [authService, user]) + + return <>{children} +} diff --git a/src/app/auth/cookies.ts b/src/app/auth/cookies.ts new file mode 100644 index 0000000..ba861c7 --- /dev/null +++ b/src/app/auth/cookies.ts @@ -0,0 +1,16 @@ +export function getCookie(cookieName: string): string | undefined { + return getCookies().get(cookieName) +} + +export function getCookies(): Map { + return document.cookie + .split('; ') + .map((c) => { + const [name, value] = c.split('=') as [string, string] + return { name, value } + }) + .reduce((acc, c) => { + acc.set(c.name, c.value) + return acc + }, new Map()) +} diff --git a/src/app/auth/getUserFromCookie.ts b/src/app/auth/getUserFromCookie.ts new file mode 100644 index 0000000..00bd891 --- /dev/null +++ b/src/app/auth/getUserFromCookie.ts @@ -0,0 +1,11 @@ +import { User } from '../user/user.ts' +import { getCookie } from './cookies.ts' + +export function getUserFromCookie(): User | null { + const userCookie = getCookie('user') + + if (!userCookie) return null + + // TODO validate but it should be fine + return JSON.parse(decodeURIComponent(userCookie)) as User +} diff --git a/src/app/auth/pages/LoginPage.tsx b/src/app/auth/pages/LoginPage.tsx index 954a2cf..8b914ca 100644 --- a/src/app/auth/pages/LoginPage.tsx +++ b/src/app/auth/pages/LoginPage.tsx @@ -4,25 +4,29 @@ import TextInput from '../../../components/inputs/TextInput.tsx' import Button from '../../../components/buttons/Button.tsx' import { AuthService } from '../authService.ts' import { useNavigate } from 'react-router-dom' -import { useUser } from '../../user/userStore.ts' +import { useUserStore } from '../../user/user.ts' import NavBar from '../../../components/NavBar.tsx' import NavButton from '../../../components/buttons/NavButton.tsx' import LinkButton from '../../../components/buttons/LinkButton.tsx' +import { useTranslations } from '../../i18n/translations.ts' interface LoginPageProps { authService: AuthService } export default function LoginPage({ authService }: LoginPageProps) { + const { t } = useTranslations() + const [isSubmitting, setIsSubmitting] = useState(false) const [username, setUsername] = useState('') const [password, setPassword] = useState('') + const [rememberMe, setRememberMe] = useState(false) const [error, setError] = useState(null) const usernameInputRef = useRef(null) const passwordInputRef = useRef(null) const navigate = useNavigate() - const { user } = useUser() + const user = useUserStore((state) => state.user) useEffect(() => { if (user) { @@ -49,7 +53,7 @@ export default function LoginPage({ authService }: LoginPageProps) { setIsSubmitting(true) try { - await authService.login(username, password) + await authService.login(username, password, rememberMe) } catch (error: unknown) { setError(error instanceof Error ? error.message : 'something went terribly wrong') } finally { @@ -61,7 +65,7 @@ export default function LoginPage({ authService }: LoginPageProps) { - home + {t('nav.home')} } > @@ -70,7 +74,7 @@ export default function LoginPage({ authService }: LoginPageProps) {
+
+ setRememberMe(e.target.checked)} + className="h-4 w-4" + /> + +
+ - register instead? + {t('auth.login.register_instead')} - {error} + {error}
diff --git a/src/app/auth/pages/LogoutPage.tsx b/src/app/auth/pages/LogoutPage.tsx index 8ae4ef4..dfceb5c 100644 --- a/src/app/auth/pages/LogoutPage.tsx +++ b/src/app/auth/pages/LogoutPage.tsx @@ -1,7 +1,7 @@ import { useNavigate } from 'react-router-dom' import { AuthService } from '../authService.ts' import { useEffect } from 'react' -import { useUser } from '../../user/userStore.ts' +import { useUserStore } from '../../user/user.ts' interface LogoutPageProps { authService: AuthService @@ -9,7 +9,7 @@ interface LogoutPageProps { export default function LogoutPage({ authService }: LogoutPageProps) { const navigate = useNavigate() - const { user } = useUser() + const user = useUserStore((state) => state.user) useEffect(() => { if (!user) { diff --git a/src/app/auth/pages/SignupPage.tsx b/src/app/auth/pages/SignupPage.tsx index dbfc481..40cbb37 100644 --- a/src/app/auth/pages/SignupPage.tsx +++ b/src/app/auth/pages/SignupPage.tsx @@ -3,12 +3,12 @@ import { useEffect, useRef, useState, FormEvent, useCallback, Ref } from 'react' import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx' import TextInput from '../../../components/inputs/TextInput.tsx' import Button from '../../../components/buttons/Button.tsx' -import AnchorButton from '../../../components/buttons/AnchorButton.tsx' import { invalid, valid, Validation } from '../../../utils/validation.ts' import { AuthService } from '../authService.ts' import LinkButton from '../../../components/buttons/LinkButton.tsx' import NavBar from '../../../components/NavBar.tsx' import NavButton from '../../../components/buttons/NavButton.tsx' +import { useTranslations } from '../../i18n/translations.ts' const SignupCodeKey = 'signupCode' @@ -17,10 +17,12 @@ interface SignupPageProps { } export default function SignupPage({ authService }: SignupPageProps) { + const { t } = useTranslations() const { code } = useParams() const [signupCode, setSignupCode] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) - + const [rememberMe, setRememberMe] = useState(false) + const [error, setError] = useState('') const [username, setUsername, usernameError, validateUsername] = useValidatedInput(isValidUsername) @@ -30,8 +32,6 @@ export default function SignupPage({ authService }: SignupPageProps) { const userNameInputRef = useRef(null) const passwordInputRef = useRef(null) - const dialogRef = useRef(null) - const navigate = useNavigate() useEffect(() => { @@ -44,13 +44,12 @@ export default function SignupPage({ authService }: SignupPageProps) { localStorage.setItem(SignupCodeKey, theSignupCode) } else { theSignupCode = localStorage.getItem(SignupCodeKey) - } - - if (!theSignupCode) { - dialogRef.current?.showModal() + setSignupCode(theSignupCode) } }, [code, signupCode]) + useEffect(() => {}, [signupCode]) + const onSubmit = async (e: FormEvent) => { e.preventDefault() @@ -76,8 +75,11 @@ export default function SignupPage({ authService }: SignupPageProps) { setIsSubmitting(true) try { - await authService.signup(username, password, signupCode) + await authService.signup(username, password, signupCode, rememberMe) navigate('/') + } catch (e: unknown) { + const err = e as Error + setError(err.message) } finally { setIsSubmitting(false) } @@ -87,7 +89,7 @@ export default function SignupPage({ authService }: SignupPageProps) { - home + {t('nav.home')} } > @@ -96,6 +98,7 @@ export default function SignupPage({ authService }: SignupPageProps) {
+
+ setRememberMe(e.target.checked)} + className="h-4 w-4" + /> + +
- login instead? + {t('auth.register.login_instead')} + + {error} - - -
-

STOP !!!

-

You need an invitation to sign up

-

- I'm surprised you even found your way here without one and honestly I'd prefer it if you - would leave -

-

- If you do want to create an account, you should know who - to contact -

- - I'm sorry I'll go somewhere else :( - -
-
) } interface FormInputProps { id: string + label: string value: string onInput: (value: string) => void error: string | null @@ -158,11 +155,11 @@ interface FormInputProps { ref: Ref } -function FormInput({ id, value, onInput, error, type = 'text', ref }: FormInputProps) { +function FormInput({ id, label, value, onInput, error, type = 'text', ref }: FormInputProps) { return (
{error}
diff --git a/src/app/auth/signupCode.ts b/src/app/auth/signupCode.ts new file mode 100644 index 0000000..96801b5 --- /dev/null +++ b/src/app/auth/signupCode.ts @@ -0,0 +1,20 @@ +import { Temporal } from '@js-temporal/polyfill' +import { components } from '../api/schema.ts' + +export class SignupCode { + constructor( + public readonly code: string, + public readonly email: string, + public readonly redeemedBy: string | null, + public readonly expiresOn: Temporal.Instant | null, + ) {} + + static fromDto(dto: components['schemas']['SignupCodeDto']): SignupCode { + return new SignupCode( + dto.code, + dto.email, + dto.redeemingUsername, + dto.expiresOn ? Temporal.Instant.from(dto.expiresOn) : null, + ) + } +} diff --git a/src/app/feed/components/FeedView.ts b/src/app/feed/components/FeedView.ts deleted file mode 100644 index b79acb9..0000000 --- a/src/app/feed/components/FeedView.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useCallback, useRef, useState } from 'react' -import { Post } from '../posts/posts.ts' - -const PageSize = 20 - -export function useFeedViewModel( - loadMore: (cursor: string | null, amount: number) => Promise, -) { - const [pages, setPages] = useState([]) - const [hasMore, setHasMore] = useState(true) - const [error, setError] = useState(null) - - const cursor = useRef(null) - const loading = useRef(false) - - const loadNextPage = useCallback(async () => { - if (loading.current || !hasMore || error) return - loading.current = true - - try { - const delay = new Promise((resolve) => setTimeout(resolve, 500)) - const pagePromise = loadMore(cursor.current, PageSize) - const [page] = await Promise.all([pagePromise, delay]) - setHasMore(page.length >= PageSize) - cursor.current = page.at(-1)?.postId ?? null - setPages((prev) => [...prev, page]) - } catch (e: unknown) { - const err = e as Error - setError(err.message) - } finally { - loading.current = false - } - }, [loadMore, hasMore, error]) - - return { pages, setPages, loadNextPage, error } as const -} diff --git a/src/app/feed/components/FeedView.tsx b/src/app/feed/components/FeedView.tsx deleted file mode 100644 index d35256f..0000000 --- a/src/app/feed/components/FeedView.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useRef } from 'react' -import { useIntersectionLoad } from '../../../hooks/useIntersectionLoad.ts' -import { Post } from '../posts/posts.ts' -import PostItem from './PostItem.tsx' - -interface FeedViewProps { - pages: Post[][] - onLoadMore: () => Promise -} - -export default function FeedView({ pages, onLoadMore }: FeedViewProps) { - const sentinelRef = useRef(null) - const posts = pages.flat() - - useIntersectionLoad(onLoadMore, sentinelRef) - - return ( -
-
-
- {posts.map((post) => ( - - ))} -
-
-
-
- ) -} diff --git a/src/app/feed/components/NewCommentWidget.tsx b/src/app/feed/components/NewCommentWidget.tsx new file mode 100644 index 0000000..3d2e4ea --- /dev/null +++ b/src/app/feed/components/NewCommentWidget.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react' +import FancyTextEditor, { + TextInputKeyDownEvent, +} from '../../../components/inputs/FancyTextEditor.tsx' +import Button from '../../../components/buttons/Button.tsx' +import { useTranslations } from '../../i18n/translations.ts' + +interface NewCommentWidgetProps { + onSubmit: (content: string) => void + isSubmitting?: boolean +} + +export default function NewCommentWidget({ + onSubmit, + isSubmitting = false, +}: NewCommentWidgetProps) { + const { t } = useTranslations() + const [content, setContent] = useState('') + + const onContentInput = (value: string) => { + setContent(value) + } + + const handleSubmit = () => { + if (!content.trim()) { + return + } + + onSubmit(content) + + setContent('') + } + + const onInputKeyDown = (e: TextInputKeyDownEvent) => { + if (e.key === 'Enter' && e.ctrlKey) { + e.preventDefault() + handleSubmit() + } + } + + return ( +
+ + +
+ +
+
+ ) +} diff --git a/src/components/NewPostWidget.tsx b/src/app/feed/components/NewPostWidget.tsx similarity index 54% rename from src/components/NewPostWidget.tsx rename to src/app/feed/components/NewPostWidget.tsx index 4b5668d..e0bd87a 100644 --- a/src/components/NewPostWidget.tsx +++ b/src/app/feed/components/NewPostWidget.tsx @@ -1,7 +1,9 @@ import { useState } from 'react' -import FancyTextEditor, { TextInputKeyDownEvent } from './inputs/FancyTextEditor.tsx' -import Button from './buttons/Button.tsx' -import { openFileDialog } from '../utils/openFileDialog.ts' +import FancyTextEditor, { TextInputKeyDownEvent } from '../../../components/inputs/FancyTextEditor.tsx' +import Button from '../../../components/buttons/Button.tsx' +import { openFileDialog } from '../../../utils/openFileDialog.ts' +import makePica from 'pica' +import { useTranslations } from '../../i18n/translations.ts' interface NewPostWidgetProps { onSubmit: ( @@ -21,6 +23,7 @@ interface Attachment { } export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPostWidgetProps) { + const { t } = useTranslations() const [content, setContent] = useState('') const [attachments, setAttachments] = useState([]) const [isPublic, setIsPublic] = useState(false) @@ -71,7 +74,7 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos onInput={onContentInput} onKeyDown={onInputKeyDown} className="mb-3" - placeholder="write something..." + placeholder={t('post.editor.placeholder')} /> {attachments.length > 0 && ( @@ -92,7 +95,7 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
@@ -121,11 +124,13 @@ async function createAttachment(file: File): Promise { throw new Error('not an image') } + file = await optimizeImageSize(file) + const objectUrl = URL.createObjectURL(file) const { width, height } = await getImageFileDimensions(objectUrl) return { - id: crypto.randomUUID(), + id: getRandomId(), file, objectUrl, width, @@ -144,3 +149,95 @@ function getImageFileDimensions(objectURL: string): Promise<{ width: number; hei img.src = objectURL }) } + +const pica = makePica() + +async function optimizeImageSize( + file: File, + { + targetMaxWidth = 1920, + targetMaxHeight = 1080, + targetSizeBytes = 500 * 1024, + outputType = 'image/jpeg', + quality = 0.9, + }: { + targetMaxWidth?: number + targetMaxHeight?: number + targetSizeBytes?: number + outputType?: string + quality?: number + } = {}, +): Promise { + const img = document.createElement('img') + const url = URL.createObjectURL(file) + img.src = url + + await img.decode() + + console.debug('processing image', { + width: img.width, + height: img.height, + targetMaxWidth, + targetMaxHeight, + targetSizeBytes, + outputType, + quality, + }) + + const scale = Math.min(1, targetMaxWidth / img.width, targetMaxHeight / img.height) + const width = Math.floor(img.width * scale) + const height = Math.floor(img.height * scale) + const originalSize = file.size + + const srcCanvas = document.createElement('canvas') + srcCanvas.width = img.width + srcCanvas.height = img.height + srcCanvas.getContext('2d')!.drawImage(img, 0, 0) + + const dstCanvas = document.createElement('canvas') + dstCanvas.width = width + dstCanvas.height = height + + try { + // TODO resistFingerprinting in FF and other causes this to break. + // knowing this, i would still rather be able to post from other browsers for now + // and will hopefully find a better solution + await pica.resize(srcCanvas, dstCanvas) + } catch (e) { + console.error('cant resize image', e) + return file + } + + let blob = await pica.toBlob(dstCanvas, outputType, quality) + + while (blob.size > targetSizeBytes && quality > 0.1) { + quality -= 0.1 + blob = await pica.toBlob(dstCanvas, outputType, quality) + } + + console.debug( + `optimized image rendered at ${Math.round(quality * 100)}% quality to ${blob.size / 1000}KB from ${originalSize / 1000}KB`, + ) + + URL.revokeObjectURL(url) + + return new File([blob], file.name, { type: file.type }) +} + +function getRandomId() { + if (window.isSecureContext) { + return crypto.randomUUID() + } + + // Fallback using getRandomValues + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + + // Format according to RFC4122 version 4 + bytes[6] = (bytes[6]! & 0x0f) | 0x40 + bytes[8] = (bytes[8]! & 0x3f) | 0x80 + + const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')) + + return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}` +} diff --git a/src/app/feed/components/PostItem.tsx b/src/app/feed/components/PostItem.tsx index 2a346d8..db766f6 100644 --- a/src/app/feed/components/PostItem.tsx +++ b/src/app/feed/components/PostItem.tsx @@ -1,12 +1,24 @@ -import { Post, PostMedia } from '../posts/posts.ts' -import { Link } from 'react-router-dom' +import { PostMedia, PostReaction } from '../posts/posts.ts' import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { PostInfo } from '../posts/usePostViewModel.ts' +import { useUserStore } from '../../user/user.ts' interface PostItemProps { - post: Post + post: PostInfo + reactions: PostReaction[] + addReaction: (emoji: string) => void + clearReaction: (emoji: string) => void + hideViewButton?: boolean } -export default function PostItem({ post }: PostItemProps) { +export default function PostItem({ + post, + reactions, + addReaction, + clearReaction, + hideViewButton = false, +}: PostItemProps) { const formattedDate = post.createdAt.toLocaleString('en-US', { year: 'numeric', month: 'short', @@ -29,10 +41,15 @@ export default function PostItem({ post }: PostItemProps) { return (
- - @{post.authorName} - - • {formattedDate} + @{post.authorName}• {formattedDate} + {!hideViewButton && ( + <> + {' • '} + + View + + + )}
{post.content}
@@ -44,24 +61,95 @@ export default function PostItem({ post }: PostItemProps) { ))}
)} + + ) } +interface PostReactionsProps { + post: PostInfo + reactions: PostReaction[] + addReaction: (emoji: string) => void + clearReaction: (emoji: string) => void +} + +function PostReactions({ post, reactions, addReaction, clearReaction }: PostReactionsProps) { + const username = useUserStore((state) => state.user?.username) + return ( +
+ {post.possibleReactions.map((emoji) => { + const count = reactions.filter((r) => r.emoji === emoji).length + const didReact = reactions.some((r) => r.emoji == emoji && r.authorName == username) + const onClick = () => { + if (didReact) { + clearReaction(emoji) + } else { + addReaction(emoji) + } + } + + return ( + + ) + })} +
+ ) +} + +interface PostReactionButtonProps { + emoji: string + didReact: boolean + count: number + onClick: () => void +} + +function PostReactionButton({ emoji, didReact, onClick, count }: PostReactionButtonProps) { + const formattedCount = count < 100 ? count.toString() : `99+` + + return ( + + ) +} + interface PostMediaProps { media: PostMedia } function PostMediaItem({ media }: PostMediaProps) { - const url = media.url.toString() + const url = new URL(media.url.toString()) + + if (location.protocol === 'https:' && url.protocol !== 'https:') { + url.protocol = 'https:' + } + const width = media.width ?? undefined const height = media.height ?? undefined return ( todo sry :( diff --git a/src/app/feed/components/PostTimeline.tsx b/src/app/feed/components/PostTimeline.tsx new file mode 100644 index 0000000..c9653f1 --- /dev/null +++ b/src/app/feed/components/PostTimeline.tsx @@ -0,0 +1,67 @@ +import { PostComment, PostReaction } from '../posts/posts.ts' +import { Temporal } from '@js-temporal/polyfill' + +interface PostTimelineProps { + reactions: PostReaction[] + comments: PostComment[] +} + +export function PostTimeline({ reactions, comments }: PostTimelineProps) { + const items = [ + ...reactions.map((reaction) => ({ + timestamp: reaction.reactedOn, + component: ( + + ), + })), + ...comments.map((comment) => ({ + timestamp: comment.postedOn, + component: ( + + ), + })), + ].toSorted((a, b) => Temporal.Instant.compare(a.timestamp, b.timestamp)) + + return ( +
{items.map((item) => item.component)}
+ ) +} + +function ReactionItem({ reaction }: { reaction: PostReaction }) { + return ( +
+ {formatItemDate(reaction.reactedOn)} +
+ @{reaction.authorName}  + clicked  + {reaction.emoji} +
+
+ ) +} + +function CommentItem({ comment }: { comment: PostComment }) { + return ( +
+
{formatItemDate(comment.postedOn)}
+
+ @{comment.author}  +
+
{comment.content}
+
+ ) +} + +function formatItemDate(date: Temporal.Instant) { + return date.toLocaleString('en-AU', { + year: 'numeric', + month: 'numeric', + day: 'numeric', + }) +} diff --git a/src/app/feed/pages/AuthorPage.tsx b/src/app/feed/pages/AuthorPage.tsx deleted file mode 100644 index d937b88..0000000 --- a/src/app/feed/pages/AuthorPage.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useCallback } from 'react' -import FeedView from '../components/FeedView.tsx' -import { PostsService } from '../posts/postsService.ts' -import { useParams } from 'react-router-dom' -import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx' -import NavBar from '../../../components/NavBar.tsx' -import { useFeedViewModel } from '../components/FeedView.ts' -import NavButton from '../../../components/buttons/NavButton.tsx' -import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx' - -interface AuthorPageParams { - postsService: PostsService -} - -export default function AuthorPage({ postsService }: AuthorPageParams) { - const { username } = useParams() - - const fetchPosts = useCallback( - async (cursor: string | null, amount: number | null) => { - return postsService.loadByAuthor(username!, cursor, amount) - }, - [postsService, username], - ) - - const { pages, loadNextPage } = useFeedViewModel(fetchPosts) - - return ( - - home - - - } - > - - - ) -} diff --git a/src/app/feed/pages/HomePage.tsx b/src/app/feed/pages/HomePage.tsx index 910e1db..b505d83 100644 --- a/src/app/feed/pages/HomePage.tsx +++ b/src/app/feed/pages/HomePage.tsx @@ -1,65 +1,104 @@ -import { useCallback, useState } from 'react' -import FeedView from '../components/FeedView.tsx' +import { useRef, useState } from 'react' import { PostsService } from '../posts/postsService.ts' -import { useUser } from '../../user/userStore.ts' +import { useUserStore } from '../../user/user.ts' import { MediaService } from '../../media/mediaService.ts' -import NewPostWidget from '../../../components/NewPostWidget.tsx' -import { useFeedViewModel } from '../components/FeedView.ts' -import { Post } from '../posts/posts.ts' -import { Temporal } from '@js-temporal/polyfill' +import NewPostWidget from '../components/NewPostWidget.tsx' import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx' import NavBar from '../../../components/NavBar.tsx' import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx' +import { useSaveSignupCodeToLocalStorage } from '../../../hooks/useSaveSignupCodeToLocalStorage.ts' +import PostItem from '../components/PostItem.tsx' +import { useIntersectionLoad } from '../../../hooks/useIntersectionLoad.ts' +import { delay } from '../../../utils/delay.ts' +import { usePostViewModel } from '../posts/usePostViewModel.ts' +import { Temporal } from '@js-temporal/polyfill' interface HomePageProps { postsService: PostsService mediaService: MediaService } +const PageSize = 20 + export default function HomePage({ postsService, mediaService }: HomePageProps) { - const { user } = useUser() + const user = useUserStore((state) => state.user) + useSaveSignupCodeToLocalStorage() const [isSubmitting, setIsSubmitting] = useState(false) - const fetchPosts = useCallback( - async (cursor: string | null, amount: number | null) => { - return postsService.loadPublicFeed(cursor, amount) - }, - [postsService], - ) + const { posts, addPosts, addReaction, removeReaction, reactions } = usePostViewModel() - const { pages, setPages, loadNextPage } = useFeedViewModel(fetchPosts) + const [hasMore, setHasMore] = useState(true) + const [error, setError] = useState(null) - const onCreatePost = useCallback( - async (content: string, files: { file: File; width: number; height: number }[], isPublic: boolean) => { - setIsSubmitting(true) - if (user == null) throw new Error('Not logged in') - try { - const media = await Promise.all( - files.map(async ({ file, width, height }) => { - const { mediaId, url } = await mediaService.uploadFile(file) + const cursor = useRef(null) + const loading = useRef(false) - return { - mediaId, - url, - width, - height, - } - }), - ) - const postId = await postsService.createNew(user.userId, content, media, isPublic) - const post = new Post(postId, content, media, Temporal.Now.instant(), user.username) - setPages((pages) => [[post], ...pages]) - } catch (error) { - console.error('Failed to create post:', error) - } finally { - setIsSubmitting(false) - } - }, - [mediaService, postsService, setPages, user], - ) + const loadNextPage = async () => { + if (loading.current || !hasMore || error) return + loading.current = true + + try { + const [{ posts }] = await Promise.all([ + postsService.loadPublicFeed(cursor.current, PageSize), + delay(500), + ]) + + setHasMore(posts.length >= PageSize) + cursor.current = posts.at(-1)?.postId ?? null + addPosts(posts) + } catch (e: unknown) { + setError((e as Error).message) + } finally { + loading.current = false + } + } + + const onCreatePost = async ( + content: string, + files: { file: File; width: number; height: number }[], + isPublic: boolean, + ) => { + setIsSubmitting(true) + if (user == null) throw new Error('Not logged in') + try { + const media = await Promise.all( + files.map(async ({ file, width, height }) => { + const { mediaId, url } = await mediaService.uploadImage(file) + + return { + mediaId, + url, + width, + height, + } + }), + ) + const post = await postsService.createNew(user.id, content, media, isPublic) + addPosts([post]) + } catch (error) { + console.error('Failed to create post:', error) + } finally { + setIsSubmitting(false) + } + } const isLoggedIn = user != null + const onAddReaction = async (postId: string, emoji: string) => { + await postsService.addReaction(postId, emoji) + + addReaction(postId, emoji, user!.username, Temporal.Now.instant()) + } + + const onClearReaction = async (postId: string, emoji: string) => { + await postsService.removeReaction(postId, emoji) + + removeReaction(postId, emoji, user!.username) + } + + const sentinelRef = useRef(null) + useIntersectionLoad(loadNextPage, sentinelRef) + return (
{isLoggedIn && } - +
+
+
+ {posts.map((post) => ( + onAddReaction(post.postId, emoji)} + clearReaction={(emoji) => onClearReaction(post.postId, emoji)} + /> + ))} +
+
+
+
) diff --git a/src/app/feed/pages/PostPage.tsx b/src/app/feed/pages/PostPage.tsx new file mode 100644 index 0000000..b275b68 --- /dev/null +++ b/src/app/feed/pages/PostPage.tsx @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' +import { PostsService } from '../posts/postsService.ts' +import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx' +import NavBar from '../../../components/NavBar.tsx' +import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx' +import PostItem from '../components/PostItem.tsx' +import NavButton from '../../../components/buttons/NavButton.tsx' +import { useTranslations } from '../../i18n/translations.ts' +import { usePostViewModel } from '../posts/usePostViewModel.ts' +import { Temporal } from '@js-temporal/polyfill' +import { useUserStore } from '../../user/user.ts' +import { PostTimeline } from '../components/PostTimeline.tsx' +import NewCommentWidget from '../components/NewCommentWidget.tsx' + +interface PostPageProps { + postsService: PostsService +} + +export default function PostPage({ postsService }: PostPageProps) { + const { postId } = useParams<{ postId: string }>() + const { posts, setPosts, addReaction, reactions: _reactions, removeReaction } = usePostViewModel() + const { t } = useTranslations() + const username = useUserStore((state) => state.user?.username) + const post = posts.at(0) + const reactions = (post?.postId ? _reactions[post.postId] : []) ?? [] + + const loadPost = useCallback(() => { + if (!postId) return + postsService.load(postId).then((post) => setPosts(post ? [post] : [])) + }, [postId, postsService, setPosts]) + + useEffect(() => { + loadPost() + }, [loadPost]) + + const onAddReaction = async (emoji: string) => { + if (!username) return + if (!post) return + + await postsService.addReaction(post.postId, emoji) + + addReaction(post.postId, emoji, username, Temporal.Now.instant()) + } + + const onClearReaction = async (emoji: string) => { + if (!username) return + if (!post) return + + await postsService.removeReaction(post.postId, emoji) + removeReaction(post.postId, emoji, username) + } + + async function onSubmitComment(content: string) { + if (!postId) return + if (!content.trim()) return + + try { + setIsSubmittingComment(true) + await postsService.addComment(postId, content) + } finally { + setIsSubmittingComment(false) + } + + loadPost() + } + + const [isSubmittingComment, setIsSubmittingComment] = useState(false) + + return ( + + {t('nav.home')} + + + } + > +
+ {post && ( +
+ + + +
+ )} +
+
+ ) +} diff --git a/src/app/feed/posts/posts.ts b/src/app/feed/posts/posts.ts index 77d0a5f..837e2b8 100644 --- a/src/app/feed/posts/posts.ts +++ b/src/app/feed/posts/posts.ts @@ -1,12 +1,30 @@ import { Temporal } from '@js-temporal/polyfill' import { components } from '../../api/schema.ts' +import { immerable } from 'immer' + +export interface PostReaction { + emoji: string + authorName: string + reactedOn: Temporal.Instant +} + +export interface PostComment { + author: string + content: string + postedOn: Temporal.Instant +} export class Post { + [immerable] = true + public readonly postId: string public readonly content: string public readonly media: PostMedia[] public readonly createdAt: Temporal.Instant public readonly authorName: string + public readonly reactions: PostReaction[] + public readonly possibleReactions: string[] + public readonly comments: PostComment[] constructor( postId: string, @@ -14,12 +32,18 @@ export class Post { media: PostMedia[], createdAt: string | Temporal.Instant, authorName: string, + reactions: PostReaction[], + possibleReactions: string[], + comments: PostComment[], ) { this.postId = postId this.content = content this.media = media this.createdAt = Temporal.Instant.from(createdAt) this.authorName = authorName + this.reactions = reactions + this.possibleReactions = possibleReactions + this.comments = comments } public static fromDto(dto: components['schemas']['PostDto']): Post { @@ -29,6 +53,9 @@ export class Post { dto.media.map((m) => new PostMediaImpl(new URL(m.url), m.width, m.height)), Temporal.Instant.from(dto.createdAt), dto.author.username, + dto.reactions.map((r) => ({ ...r, reactedOn: Temporal.Instant.from(r.reactedOn) })), + dto.possibleReactions, + dto.comments.map((c) => ({ ...c, postedOn: Temporal.Instant.from(c.postedOn) })), ) } } diff --git a/src/app/feed/posts/postsService.ts b/src/app/feed/posts/postsService.ts index f7da251..72e55f7 100644 --- a/src/app/feed/posts/postsService.ts +++ b/src/app/feed/posts/postsService.ts @@ -1,16 +1,17 @@ import { Post } from './posts.ts' -import client from '../../api/client.ts' +import { ApiClient } from '../../api/client.ts' +import { useUserStore } from '../../user/user.ts' export class PostsService { - constructor() {} + constructor(private readonly client: ApiClient) {} async createNew( authorId: string, content: string, media: CreatePostMedia[], isPublic: boolean, - ): Promise { - const response = await client.POST('/posts', { + ): Promise { + const response = await this.client.POST('/posts', { body: { authorId, content, @@ -26,39 +27,68 @@ export class PostsService { throw new Error('Failed to create post') } - return response.data.postId + return Post.fromDto(response.data.post) } - async loadPublicFeed(cursor: string | null, amount: number | null): Promise { - const response = await client.GET('/posts', { - query: { cursor, amount }, + async load(postId: string): Promise { + const response = await this.client.GET('/posts/{postId}', { + params: { + path: { postId }, + }, credentials: 'include', }) - if (!response.data) { - return [] + if (!response.data?.post) { + return null } - return response.data?.posts.map((post) => Post.fromDto(post)) + return Post.fromDto(response.data.post) } - async loadByAuthor( - username: string, - cursor: string | null, - amount: number | null, - ): Promise { - const response = await client.GET('/posts', { + async loadPublicFeed(cursor: string | null, amount: number | null): Promise<{ posts: Post[] }> { + const response = await this.client.GET('/posts', { params: { - query: { From: cursor ?? undefined, Amount: amount ?? undefined, Author: username }, + query: { After: cursor ?? undefined, Amount: amount ?? undefined }, }, credentials: 'include', }) if (!response.data) { - return [] + return { posts: [] } } - return response.data?.posts.map((post) => Post.fromDto(post)) + return { posts: response.data.posts.map(Post.fromDto) } + } + + async addReaction(postId: string, emoji: string): Promise { + await this.client.POST('/posts/{postId}/reactions', { + params: { + path: { postId }, + }, + body: { emoji }, + credentials: 'include', + }) + } + + async removeReaction(postId: string, emoji: string): Promise { + await this.client.DELETE('/posts/{postId}/reactions', { + params: { + path: { postId }, + }, + body: { emoji }, + credentials: 'include', + }) + } + + async addComment(postId: string, content: string): Promise { + const authorId = useUserStore.getState().user?.id + if (!authorId) return + + await this.client.POST('/posts/{postId}/comments', { + params: { path: { postId } }, + body: { content, authorId }, + credentials: 'include', + }) } } diff --git a/src/app/feed/posts/usePostViewModel.ts b/src/app/feed/posts/usePostViewModel.ts new file mode 100644 index 0000000..2fb06bd --- /dev/null +++ b/src/app/feed/posts/usePostViewModel.ts @@ -0,0 +1,83 @@ +import { useCallback, useState } from 'react' +import { Post, PostComment, PostMedia, PostReaction } from './posts.ts' +import { Temporal } from '@js-temporal/polyfill' +import { produce } from 'immer' + +export interface PostInfo { + postId: string + authorName: string + content: string + createdAt: Temporal.Instant + media: PostMedia[] + possibleReactions: string[] + comments: PostComment[] +} + +type ReactionMap = Record + +export function usePostViewModel() { + const [posts, _setPosts] = useState([]) + const [reactions, setReactions] = useState({}) + + 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 } +} diff --git a/src/app/femtoApp.ts b/src/app/femtoApp.ts new file mode 100644 index 0000000..cc9593a --- /dev/null +++ b/src/app/femtoApp.ts @@ -0,0 +1,8 @@ +import { FemtoApp } from '../types' +import { produce } from 'immer' + +export function setGlobal(k: K, v: FemtoApp[K]) { + window.$femto = produce(window.$femto ?? {}, (draft) => { + draft[k] = v + }) +} diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts new file mode 100644 index 0000000..519fc55 --- /dev/null +++ b/src/app/i18n/translations.ts @@ -0,0 +1,41 @@ +import en from './translations/en.json' assert { type: 'json' } + +interface Translation { + 'auth.login.cta': string + 'auth.login.register_instead': string + 'auth.password.label': string + 'auth.register.cta': string + 'auth.register.login_instead': string + 'auth.remember_me.label': string + 'auth.username.label': string + + 'misc.loading': string + + 'nav.admin': string + 'nav.home': string + 'nav.login': string + 'nav.logout': string + 'nav.register': string + + 'post.add_media.cta': string + 'post.editor.placeholder': string + 'post.public.label': string + 'post.submit.cta': string +} + +export type TranslationKey = keyof Translation + +export interface UseTranslations { + t: (key: K) => Translation[K] +} + +export function useTranslations(): UseTranslations { + // TODO somehow handle other languages (reactively) + const texts = en as Translation + + function getText(key: K): Translation[K] { + return texts[key] ?? key + } + + return { t: getText } +} diff --git a/src/app/i18n/translations/en.json b/src/app/i18n/translations/en.json new file mode 100644 index 0000000..6cc6cb5 --- /dev/null +++ b/src/app/i18n/translations/en.json @@ -0,0 +1,19 @@ +{ + "nav.home": "home", + "nav.login": "login", + "nav.register": "register", + "nav.admin": "admin", + "auth.login.cta": "login", + "auth.login.register_instead": "register instead?", + "auth.register.cta": "signup", + "auth.register.login_instead": "login instead?", + "auth.username.label": "username", + "auth.password.label": "password", + "auth.remember_me.label": "stay logged in", + "misc.loading": "wait...", + "nav.logout": "logout", + "post.add_media.cta": "+ add media", + "post.public.label": "public", + "post.submit.cta": "post", + "post.editor.placeholder": "write something..." +} \ No newline at end of file diff --git a/src/app/media/mediaService.ts b/src/app/media/mediaService.ts index c63c627..c9b6f2e 100644 --- a/src/app/media/mediaService.ts +++ b/src/app/media/mediaService.ts @@ -1,12 +1,12 @@ -import client from '../api/client.ts' +import { ApiClient } from '../api/client.ts' export class MediaService { - constructor() {} - async uploadFile(file: File): Promise<{ mediaId: string; url: URL }> { + constructor(private readonly client: ApiClient) {} + async uploadImage(file: File): Promise<{ mediaId: string; url: URL }> { const body = new FormData() body.append('file', file) - const response = await client.POST('/media', { + const response = await this.client.POST('/media', { // @ts-expect-error this endpoint takes multipart/form-data which means passing a FormData as the body // maybe openapi-fetch only wants to handle JSON? who knows body, diff --git a/src/app/messageBus/messageTypes.ts b/src/app/messageBus/messageTypes.ts index 3ecfdcb..2338618 100644 --- a/src/app/messageBus/messageTypes.ts +++ b/src/app/messageBus/messageTypes.ts @@ -1,12 +1,10 @@ +import { User } from '../user/user.ts' + export interface MessageTypes { - 'auth:logged-in': { - userId: string - username: string - } - 'auth:registered': { - userId: string - username: string - } + 'auth:logged-in': null + 'auth:registered': null 'auth:logged-out': null 'auth:unauthorized': null + 'auth:refreshed': null + 'user:updated': User | null } diff --git a/src/app/user/user.ts b/src/app/user/user.ts new file mode 100644 index 0000000..8ce3446 --- /dev/null +++ b/src/app/user/user.ts @@ -0,0 +1,22 @@ +import { create } from 'zustand' + +export interface User { + id: string + username: string + roles: Role[] +} + +export enum Role { + User = 0, + SuperUser = 1, +} + +interface UserState { + user: User | null + setUser: (user: User | null) => void +} + +export const useUserStore = create()((set) => ({ + user: null, + setUser: (user: User | null) => set({ user }), +})) diff --git a/src/app/user/userStore.ts b/src/app/user/userStore.ts deleted file mode 100644 index 052de79..0000000 --- a/src/app/user/userStore.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { createStore, Store, useStore } from '../../utils/store.ts' -import { addMessageListener } from '../messageBus/messageBus.ts' - -export interface User { - userId: string - username: string -} - -export type UserStore = Store - -const UserKey = 'user' - -export const userStore = createStore(loadStoredUser()) - -userStore.subscribe((user) => { - localStorage.setItem(UserKey, JSON.stringify(user)) -}) - -addMessageListener('auth:logged-in', (e) => { - userStore.setState({ - userId: e.userId, - username: e.username, - }) -}) - -addMessageListener('auth:registered', (e) => { - userStore.setState({ - userId: e.userId, - username: e.username, - }) -}) - -addMessageListener('auth:logged-out', () => { - userStore.setState(null) -}) - -export const useUser = () => { - const [user] = useStore(userStore) - - return { user } -} - -function loadStoredUser(): User | null { - const json = localStorage.getItem(UserKey) - if (json) { - return JSON.parse(json) as User - } else { - return null - } -} diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index 265e525..89691c9 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -1,9 +1,35 @@ -import { PropsWithChildren } from 'react' +import { PropsWithChildren, ReactNode } from 'react' +import { Role, useUserStore } from '../app/user/user.ts' +import NavButton from './buttons/NavButton.tsx' -type NavBarProps = unknown +type NavBarProps = { + leftChildren?: ReactNode +} export default function NavBar({ children }: PropsWithChildren) { + const user = useUserStore((state) => state.user) + const isSuperUser = user?.roles.includes(Role.SuperUser) return ( - + + ) +} + +function SourceCodeLink() { + return ( + + Forgejo Logo + ) } diff --git a/src/components/buttons/NavButton.tsx b/src/components/buttons/NavButton.tsx index fd2fe67..1ae5373 100644 --- a/src/components/buttons/NavButton.tsx +++ b/src/components/buttons/NavButton.tsx @@ -2,6 +2,7 @@ import { PropsWithChildren } from 'react' import { Link } from 'react-router-dom' interface NavLinkButtonProps { to: string | To + className?: string } interface To { @@ -10,9 +11,13 @@ interface To { hash?: string } -export default function NavButton({ to, children }: PropsWithChildren) { +export default function NavButton({ + to, + className: extraClasses = '', + children, +}: PropsWithChildren) { return ( - + {children} ) diff --git a/src/components/inputs/FancyTextEditor.tsx b/src/components/inputs/FancyTextEditor.tsx index f23c28d..d72c391 100644 --- a/src/components/inputs/FancyTextEditor.tsx +++ b/src/components/inputs/FancyTextEditor.tsx @@ -15,7 +15,7 @@ export interface TextInputKeyDownEvent { } export default function FancyTextEditor({ - value: _value, + value: value, onInput, onKeyDown, className: extraClasses = '', @@ -24,10 +24,7 @@ export default function FancyTextEditor({ const divRef = useRef(null) const [hasFocus, setHasFocus] = useState(false) - // the contenteditable likes to slip in newlines at the bottom of our innerText - // which makes it bad to check for empty string because it might be "\n" - // so we just trim it upfront and then fogeddaboudit - const value = _value.trim() + const trimmedValue = value.trim() // The funky mechanics here are to stop the cursor from jumping back the start. // It probably will have the cursor jump to the start if anything changes programmatically, @@ -38,12 +35,12 @@ export default function FancyTextEditor({ return } - if (!value && !hasFocus) { + if (!trimmedValue && !hasFocus) { div.innerText = placeholder - } else if (div.innerText !== value) { - div.innerText = value + } else if (div.innerText.trim() !== trimmedValue) { + div.innerText = trimmedValue } - }, [hasFocus, placeholder, value]) + }, [hasFocus, placeholder, trimmedValue]) useEffect(() => { const div = divRef.current! diff --git a/src/hooks/useIntersectionLoad.ts b/src/hooks/useIntersectionLoad.ts index 8464e2f..024ace5 100644 --- a/src/hooks/useIntersectionLoad.ts +++ b/src/hooks/useIntersectionLoad.ts @@ -18,7 +18,7 @@ export function useIntersectionLoad( ) { const observerRef = useRef(null) const loading = useRef(false) - const timeoutRef = useRef(null) + const timeoutRef = useRef(null) useEffect(() => { const el = elementRef.current diff --git a/src/hooks/useOnMounted.ts b/src/hooks/useOnMounted.ts new file mode 100644 index 0000000..2053977 --- /dev/null +++ b/src/hooks/useOnMounted.ts @@ -0,0 +1,17 @@ +import { useEffect, useRef } from 'react' + +export function useOnMounted(callback: () => void | Promise) { + const isMounted = useRef(false) + + useEffect(() => { + if (isMounted.current) return + isMounted.current = true + + const timeoutId = setTimeout(callback) + + return () => { + isMounted.current = false + clearTimeout(timeoutId) + } + }, [callback]) +} diff --git a/src/hooks/useSaveSignupCodeToLocalStorage.ts b/src/hooks/useSaveSignupCodeToLocalStorage.ts new file mode 100644 index 0000000..1ad9872 --- /dev/null +++ b/src/hooks/useSaveSignupCodeToLocalStorage.ts @@ -0,0 +1,14 @@ +import { useSearchParams } from 'react-router-dom' +import { useEffect } from 'react' + +export function useSaveSignupCodeToLocalStorage() { + const [searchParams] = useSearchParams() + + const code = searchParams.get('c') + + useEffect(() => { + if (code) { + localStorage.setItem('signupCode', code) + } + }, [code]) +} diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..c2cd42e --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,24 @@ +import { User } from './app/user/user.ts' + +export interface ProblemDetails { + detail: string + title: string + status: number + type: string + traceId: string +} + +declare global { + interface Window { + $femto: FemtoApp + } + + type Timeout = ReturnType +} + +export interface FemtoApp { + version: string + user: User | null + authService: AuthService | null + postsService: PostsService | null +} diff --git a/src/useRefreshSessionLoop.ts b/src/useRefreshSessionLoop.ts new file mode 100644 index 0000000..5a0bf1c --- /dev/null +++ b/src/useRefreshSessionLoop.ts @@ -0,0 +1,29 @@ +import { useEffect } from 'react' +import { AuthService } from './app/auth/authService.ts' +import { useUserStore } from './app/user/user.ts' + +// Starts a loop that pings the server to keep the session alive, while also getting any updates on the user profile +export function useRefreshSessionLoop(authService: AuthService) { + const user = useUserStore((state) => state.user) + const userId = user?.id ?? null + + useEffect(() => { + if (userId == null) { + return + } + + const timeouts: Timeout[] = [] + + timeouts.push( + setTimeout(async function refreshUser() { + await authService.refreshUser(userId) + + timeouts.push(setTimeout(refreshUser, 60_000)) + }), + ) + + return () => { + timeouts.forEach(clearTimeout) + } + }, [authService, userId]) +} diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts new file mode 100644 index 0000000..9f74991 --- /dev/null +++ b/src/utils/debounce.ts @@ -0,0 +1,17 @@ +import { useCallback, useRef } from 'react' + +export function useDebounce( + fn: (...args: Args) => Promise, + delay: number, +) { + const timeout = useRef | null>(null) + + return useCallback( + (...args: Args) => { + if (timeout.current) clearTimeout(timeout.current) + + setTimeout(() => fn(...args), delay) + }, + [delay, fn], + ) +} diff --git a/src/utils/delay.ts b/src/utils/delay.ts new file mode 100644 index 0000000..9c70749 --- /dev/null +++ b/src/utils/delay.ts @@ -0,0 +1,3 @@ +export function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/utils/groupByAndMap.ts b/src/utils/groupByAndMap.ts new file mode 100644 index 0000000..7f30995 --- /dev/null +++ b/src/utils/groupByAndMap.ts @@ -0,0 +1,19 @@ +export function groupByAndMap( + items: T[], + groupBy: (item: T) => string, + map: (item: T) => U, +): Record { + const groupings: Record = {} + + for (const item of items) { + const key = groupBy(item) + + if (!groupings[key]) { + groupings[key] = [] + } + + groupings[key].push(map(item)) + } + + return groupings +} diff --git a/src/utils/store.ts b/src/utils/store.ts index 5db61dc..ae99ef7 100644 --- a/src/utils/store.ts +++ b/src/utils/store.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' export interface Store { getState: () => T @@ -37,7 +37,21 @@ export function createStore(initialState: T): Store export function useStore(store: Store) { const [selectedState, setSelectedState] = useState(() => store.getState()) - useEffect(() => store.subscribe((newState) => setSelectedState(newState)), [store]) + useEffect(() => { + const unsubscribe = store.subscribe((newState) => setSelectedState(newState)) - return [selectedState, setSelectedState] as const + return () => { + unsubscribe() + } + }, [store]) + + const setState = useCallback( + (nextState: T | ((prevState: T) => T)) => { + setSelectedState(nextState) + store.setState(nextState) + }, + [store], + ) + + return [selectedState, setState] as const } diff --git a/vite.config.ts b/vite.config.ts index c4069b7..1ea03c3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,15 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +import fs from 'node:fs' // https://vite.dev/config/ -export default defineConfig({ - plugins: [react(), tailwindcss()], +export default defineConfig(() => { + const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf-8')) + + process.env.VITE_FEMTO_VERSION = packageJson.version + + return { + plugins: [react(), tailwindcss()], + } }) diff --git a/yarn.lock b/yarn.lock index c5454e1..efeeb13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -778,6 +778,18 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== +"@types/node@^22.15.19": + version "22.15.19" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.15.19.tgz#ba9f321675243af0456d607fa82a4865931e0cef" + integrity sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw== + dependencies: + undici-types "~6.21.0" + +"@types/pica@^9.0.5": + version "9.0.5" + resolved "https://registry.yarnpkg.com/@types/pica/-/pica-9.0.5.tgz#a526b51d45b7cb70423b7af0223ab9afd151a26e" + integrity sha512-OSd4905yxFNtRanHuyyQAfC9AkxiYcbhlzP606Gl6rFcYRgq4vdLCZuYKokLQBihgrkNzyPkoeykvJDWcPjaCw== + "@types/react-dom@^19.0.4": version "19.1.3" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.3.tgz#3f0c60804441bf34d19f8dd0d44405c0c0e21bfa" @@ -1567,6 +1579,11 @@ globals@^16.0.0: resolved "https://registry.yarnpkg.com/globals/-/globals-16.0.0.tgz#3d7684652c5c4fbd086ec82f9448214da49382d8" integrity sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A== +glur@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/glur/-/glur-1.1.2.tgz#f20ea36db103bfc292343921f1f91e83c3467689" + integrity sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA== + gopd@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" @@ -1915,6 +1932,14 @@ ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +multimath@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/multimath/-/multimath-2.0.0.tgz#0d37acf67c328f30e3d8c6b0d3209e6082710302" + integrity sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g== + dependencies: + glur "^1.1.2" + object-assign "^4.1.1" + nanoid@^3.3.8: version "3.3.11" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" @@ -1935,7 +1960,7 @@ node-releases@^2.0.19: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== -object-assign@^4: +object-assign@^4, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== @@ -2045,6 +2070,16 @@ path-to-regexp@^8.0.0: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz#73990cc29e57a3ff2a0d914095156df5db79e8b4" integrity sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ== +pica@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/pica/-/pica-9.0.1.tgz#9ba5a5e81fc09dca9800abef9fb8388434b18b2f" + integrity sha512-v0U4vY6Z3ztz9b4jBIhCD3WYoecGXCQeCsYep+sXRefViL+mVVoTL+wqzdPeE+GpBFsRUtQZb6dltvAt2UkMtQ== + dependencies: + glur "^1.1.2" + multimath "^2.0.0" + object-assign "^4.1.1" + webworkify "^1.5.0" + picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" @@ -2439,6 +2474,11 @@ typescript@~5.7.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.3.tgz#919b44a7dbb8583a9b856d162be24a54bf80073e" integrity sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw== +undici-types@~6.21.0: + version "6.21.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" + integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== + unpipe@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" @@ -2483,6 +2523,11 @@ vite@^6.3.1: optionalDependencies: fsevents "~2.3.3" +webworkify@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/webworkify/-/webworkify-1.5.0.tgz#734ad87a774de6ebdd546e1d3e027da5b8f4a42c" + integrity sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g== + which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -2529,3 +2574,8 @@ zod@^3.23.8, zod@^3.24.2: version "3.24.3" resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.3.tgz#1f40f750a05e477396da64438e0e1c0995dafd87" integrity sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg== + +zustand@^5.0.7: + version "5.0.7" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.7.tgz#e325364e82c992a84bf386d8445aa7f180c450dc" + integrity sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==