diff --git a/.env b/.env new file mode 100644 index 0000000..922031a --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:5181 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 50c8dda..a547bf3 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,3 @@ dist-ssr *.njsproj *.sln *.sw? - -.env diff --git a/index.html b/index.html index 7b84b23..6ced263 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,6 @@ social media website -
diff --git a/package.json b/package.json index ada1684..8f9a477 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { "name": "femto-webapp", "private": true, - "version": "1.26.6", + "version": "1.7.0", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "build:deploy": "bash scripts/publish.sh", + "build:deploy": "bash scripts/bump-build-push.sh", "generate:schema": "node scripts/generate-schema.mjs" }, "dependencies": { @@ -16,17 +16,13 @@ "@tailwindcss/vite": "^4.1.5", "immer": "^10.1.1", "openapi-fetch": "^0.14.0", - "pica": "^9.0.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.6.0", - "tailwindcss": "^4.1.5", - "zustand": "^5.0.7" + "tailwindcss": "^4.1.5" }, "devDependencies": { "@eslint/js": "^9.22.0", - "@types/node": "^22.15.19", - "@types/pica": "^9.0.5", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@types/react-router-dom": "^5.3.3", diff --git a/public/forgejo-logo-primary.svg b/public/forgejo-logo-primary.svg deleted file mode 100644 index 7f64c1a..0000000 --- a/public/forgejo-logo-primary.svg +++ /dev/null @@ -1,40 +0,0 @@ - - - - - Forgejo logo - Caesar Schinas - - - - - - - - - - - - - diff --git a/scripts/bump-build-push.sh b/scripts/bump-build-push.sh new file mode 100755 index 0000000..529376f --- /dev/null +++ b/scripts/bump-build-push.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +set -euo pipefail + +# CONFIGURATION +REGISTRY="docker.botris.dev" +USERNAME="johnbotris" +IMAGE_NAME="femto-webapp" + +# Add this before the docker build line +export VITE_API_URL="https://femto-api.botris.social" + + +# Step 0: Ensure clean working directory +if [[ -n $(git status --porcelain) ]]; then + echo "❌ Uncommitted changes detected. Please commit or stash them before running this script." + exit 1 +fi + +# Step 1: Store current version to revert if needed +OLD_VERSION=$(node -p "require('./package.json').version") +echo "🔍 Current version: $OLD_VERSION" + +# Step 2: Bump version without Git tag/commit +echo "🚀 Bumping minor version..." +yarn version --minor --no-git-tag-version +NEW_VERSION=$(node -p "require('./package.json').version") +echo "📦 New version: $NEW_VERSION" + +# Step 3: Attempt Docker build +echo "🔧 Building Docker image..." + +if ! docker build --build-arg VITE_API_URL="$VITE_API_URL" -t $IMAGE_NAME .; then + echo "❌ Docker build failed. Reverting version bump..." + git checkout -- package.json yarn.lock + exit 1 +fi + +# Step 4: Tag and push Docker image +FULL_IMAGE="$REGISTRY/$USERNAME/$IMAGE_NAME" +echo "🏷️ Tagging Docker image..." +docker tag $IMAGE_NAME $FULL_IMAGE:$NEW_VERSION +docker tag $IMAGE_NAME $FULL_IMAGE:latest + +echo "📤 Pushing Docker images..." +docker push $FULL_IMAGE:$NEW_VERSION +docker push $FULL_IMAGE:latest + +# Step 5: Commit version bump & tag +echo "✅ Committing and tagging version bump..." +git add package.json yarn.lock +git commit -m "chore(release): v$NEW_VERSION" +git tag "v$NEW_VERSION" +git push origin main +git push origin "v$NEW_VERSION" + +echo "🎉 Release v$NEW_VERSION complete." diff --git a/scripts/generate-schema.mjs b/scripts/generate-schema.mjs index edc8972..4381bbf 100644 --- a/scripts/generate-schema.mjs +++ b/scripts/generate-schema.mjs @@ -14,7 +14,9 @@ export async function generateApiSchema(openapiUrl, outputFilePath, pathToPretti const request = new Request(openapiUrl) const response = await fetch(request) const json = await response.text() - const ast = await openapiTS(json, {}) + const ast = await openapiTS(json, { + pathParamsAsTypes: true, + }) const prettierConfig = await resolveConfig(pathToPrettierRc, { useCache: true, }) diff --git a/scripts/get-local-api-url.js b/scripts/get-local-api-url.js new file mode 100644 index 0000000..96f17b8 --- /dev/null +++ b/scripts/get-local-api-url.js @@ -0,0 +1,28 @@ +import os from 'node:os' + +/** + * Get the private IP addr of the dev machine to use as the API url. + * This is preferred to using localhost or 0.0.0.0 because it allows + * us to use the dev client from other devices (i.e. phones) + * @returns {string} + */ +export function getLocalApiUrl() { + const addresses = Object.values(os.networkInterfaces()) + .flat() + .filter((addr) => !addr.internal) + .filter((addr) => addr.family === 'IPv4') + .map((addr) => addr.address) + + let address = addresses.find((addr) => addr.startsWith('192.168')) ?? addresses.at(0) + + if (address === undefined) { + console.warn("Couldn't identify the local address for the server. falling back to localhost") + address = 'localhost' + } + + if (addresses.length > 1) { + console.warn(`chose API URL ${address} from possible choices: ${addresses.join(', ')}`) + } + + return `http://${address}:7295` +} diff --git a/scripts/publish.sh b/scripts/publish.sh deleted file mode 100755 index 68a75e4..0000000 --- a/scripts/publish.sh +++ /dev/null @@ -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 diff --git a/src/App.tsx b/src/App.tsx index 9063592..74cc867 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,42 +1,20 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom' import HomePage from './app/feed/pages/HomePage.tsx' -import PostPage from './app/feed/pages/PostPage.tsx' +import { PostsService } from './app/feed/posts/postsService.ts' +import AuthorPage from './app/feed/pages/AuthorPage.tsx' +import { MediaService } from './app/media/mediaService.ts' import SignupPage from './app/auth/pages/SignupPage.tsx' import LoginPage from './app/auth/pages/LoginPage.tsx' +import { AuthService } from './app/auth/authService.ts' import LogoutPage from './app/auth/pages/LogoutPage.tsx' import UnauthorizedHandler from './app/auth/components/UnauthorizedHandler.tsx' import AdminPage from './app/admin/pages/AdminPage.tsx' import SignupCodesManagementPage from './app/admin/pages/subpages/SignupCodesManagementPage.tsx' -import { useRefreshSessionLoop } from './useRefreshSessionLoop.ts' -import { setGlobal } from './app/femtoApp.ts' -import { PostsService } from './app/feed/posts/postsService.ts' -import { MediaService } from './app/media/mediaService.ts' -import { AuthService } from './app/auth/authService.ts' -import { initClient } from './app/api/client.ts' -import { useEffect } from 'react' -import { useUserStore } from './app/user/user.ts' -import { getUserFromCookie } from './app/auth/getUserFromCookie.ts' -setGlobal('version', import.meta.env.VITE_FEMTO_VERSION) - -const client = initClient() -const postService = new PostsService(client) -const mediaService = new MediaService(client) -const authService = new AuthService(client) - -setGlobal('postsService', postService) -setGlobal('authService', authService) - -export default function App() { - const setUser = useUserStore((state) => state.setUser) - - useRefreshSessionLoop(authService) - - useEffect(() => { - const user = getUserFromCookie() - console.debug('got user cookie', user) - setUser(user) - }, [setUser]) +function App() { + const postService = new PostsService() + const mediaService = new MediaService() + const authService = new AuthService() return ( @@ -46,7 +24,7 @@ export default function App() { path={'/'} element={} /> - } /> + } /> } /> } /> } /> @@ -61,3 +39,5 @@ export default function App() { ) } + +export default App diff --git a/src/app/admin/pages/subpages/SignupCodesManagementPage.tsx b/src/app/admin/pages/subpages/SignupCodesManagementPage.tsx index 0075c73..693ebd6 100644 --- a/src/app/admin/pages/subpages/SignupCodesManagementPage.tsx +++ b/src/app/admin/pages/subpages/SignupCodesManagementPage.tsx @@ -1,5 +1,5 @@ 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 { Temporal } from '@js-temporal/polyfill' import Button from '../../../../components/buttons/Button.tsx' @@ -12,24 +12,25 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa const [codes, setCodes] = useState([]) const [code, setCode] = useState('') const [name, setName] = useState('') + const [email, setEmail] = 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 () => { + const fetchCodes = 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]) + }, [authService]) const handleCreateCode = async (e: React.FormEvent) => { e.preventDefault() @@ -37,11 +38,12 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa setError(null) try { - await authService.createSignupCode(code, name) + await authService.createSignupCode(code, email, name) setCode('') setName('') + setEmail('') dialogRef.current?.close() - fetchCodes() + fetchCodes() // Refresh the table } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create signup code') } finally { @@ -81,18 +83,23 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa e.preventDefault() const host = window.location.origin const url = `${host}?c=${code}` - navigator.clipboard.writeText(url).catch((err) => { - console.error('Failed to copy:', err) - }) + navigator.clipboard.writeText(url) + .then(() => { + // Optional: Show a success message or notification + console.log('Copied to clipboard:', 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, + setTooltipPosition({ + x: rect.left + rect.width / 2, + y: rect.top - 10 }) setActiveCode(code) } @@ -114,6 +121,7 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa Code + Email Redeemed By Expires On @@ -122,7 +130,7 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa {codes.map((code) => ( - + {code.email} {code.redeemedBy || 'Not redeemed'} {formatDate(code.expiresOn)} @@ -187,6 +196,20 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa /> +
+ + setEmail(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md" + required + /> +
+
- {t('auth.login.register_instead')} + register instead? - {error} + {error}
diff --git a/src/app/auth/pages/LogoutPage.tsx b/src/app/auth/pages/LogoutPage.tsx index dfceb5c..8ae4ef4 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 { useUserStore } from '../../user/user.ts' +import { useUser } from '../../user/userStore.ts' interface LogoutPageProps { authService: AuthService @@ -9,7 +9,7 @@ interface LogoutPageProps { export default function LogoutPage({ authService }: LogoutPageProps) { const navigate = useNavigate() - const user = useUserStore((state) => state.user) + const { user } = useUser() useEffect(() => { if (!user) { diff --git a/src/app/auth/pages/SignupPage.tsx b/src/app/auth/pages/SignupPage.tsx index 40cbb37..ba3b994 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,11 +17,9 @@ 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) @@ -32,6 +30,8 @@ export default function SignupPage({ authService }: SignupPageProps) { const userNameInputRef = useRef(null) const passwordInputRef = useRef(null) + const dialogRef = useRef(null) + const navigate = useNavigate() useEffect(() => { @@ -46,9 +46,15 @@ export default function SignupPage({ authService }: SignupPageProps) { theSignupCode = localStorage.getItem(SignupCodeKey) setSignupCode(theSignupCode) } + + if (!theSignupCode) { + dialogRef.current?.showModal() + } }, [code, signupCode]) - useEffect(() => {}, [signupCode]) + useEffect(() => { + console.debug('signup code', signupCode) + }, [signupCode]) const onSubmit = async (e: FormEvent) => { e.preventDefault() @@ -75,7 +81,7 @@ export default function SignupPage({ authService }: SignupPageProps) { setIsSubmitting(true) try { - await authService.signup(username, password, signupCode, rememberMe) + await authService.signup(username, password, signupCode) navigate('/') } catch (e: unknown) { const err = e as Error @@ -89,7 +95,7 @@ export default function SignupPage({ authService }: SignupPageProps) { - {t('nav.home')} + home } > @@ -98,7 +104,6 @@ export default function SignupPage({ authService }: SignupPageProps) {
-
- setRememberMe(e.target.checked)} - className="h-4 w-4" - /> - -
- {t('auth.register.login_instead')} + 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 @@ -155,11 +168,11 @@ interface FormInputProps { ref: Ref } -function FormInput({ id, label, value, onInput, error, type = 'text', ref }: FormInputProps) { +function FormInput({ id, value, onInput, error, type = 'text', ref }: FormInputProps) { return (
{error}
diff --git a/src/app/feed/components/FeedView.ts b/src/app/feed/components/FeedView.ts new file mode 100644 index 0000000..b79acb9 --- /dev/null +++ b/src/app/feed/components/FeedView.ts @@ -0,0 +1,36 @@ +import { useCallback, useRef, useState } from 'react' +import { Post } from '../posts/posts.ts' + +const PageSize = 20 + +export function useFeedViewModel( + loadMore: (cursor: string | null, amount: number) => Promise, +) { + 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 new file mode 100644 index 0000000..d35256f --- /dev/null +++ b/src/app/feed/components/FeedView.tsx @@ -0,0 +1,29 @@ +import { useRef } from 'react' +import { useIntersectionLoad } from '../../../hooks/useIntersectionLoad.ts' +import { Post } from '../posts/posts.ts' +import PostItem from './PostItem.tsx' + +interface FeedViewProps { + pages: Post[][] + onLoadMore: () => Promise +} + +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 deleted file mode 100644 index 3d2e4ea..0000000 --- a/src/app/feed/components/NewCommentWidget.tsx +++ /dev/null @@ -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 ( -
- - -
- -
-
- ) -} diff --git a/src/app/feed/components/PostItem.tsx b/src/app/feed/components/PostItem.tsx index db766f6..2a346d8 100644 --- a/src/app/feed/components/PostItem.tsx +++ b/src/app/feed/components/PostItem.tsx @@ -1,24 +1,12 @@ -import { PostMedia, PostReaction } from '../posts/posts.ts' -import { useEffect, useState } from 'react' +import { Post, PostMedia } from '../posts/posts.ts' import { Link } from 'react-router-dom' -import { PostInfo } from '../posts/usePostViewModel.ts' -import { useUserStore } from '../../user/user.ts' +import { useEffect, useState } from 'react' interface PostItemProps { - post: PostInfo - reactions: PostReaction[] - addReaction: (emoji: string) => void - clearReaction: (emoji: string) => void - hideViewButton?: boolean + post: Post } -export default function PostItem({ - post, - reactions, - addReaction, - clearReaction, - hideViewButton = false, -}: PostItemProps) { +export default function PostItem({ post }: PostItemProps) { const formattedDate = post.createdAt.toLocaleString('en-US', { year: 'numeric', month: 'short', @@ -41,15 +29,10 @@ export default function PostItem({ return (
- @{post.authorName}• {formattedDate} - {!hideViewButton && ( - <> - {' • '} - - View - - - )} + + @{post.authorName} + + • {formattedDate}
{post.content}
@@ -61,95 +44,24 @@ export default function PostItem({ ))}
)} - - ) } -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 = new URL(media.url.toString()) - - if (location.protocol === 'https:' && url.protocol !== 'https:') { - url.protocol = 'https:' - } - + const url = media.url.toString() const width = media.width ?? undefined const height = media.height ?? undefined return ( todo sry :( diff --git a/src/app/feed/components/PostTimeline.tsx b/src/app/feed/components/PostTimeline.tsx deleted file mode 100644 index c9653f1..0000000 --- a/src/app/feed/components/PostTimeline.tsx +++ /dev/null @@ -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: ( - - ), - })), - ...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 new file mode 100644 index 0000000..d937b88 --- /dev/null +++ b/src/app/feed/pages/AuthorPage.tsx @@ -0,0 +1,39 @@ +import { useCallback } from 'react' +import FeedView from '../components/FeedView.tsx' +import { PostsService } from '../posts/postsService.ts' +import { useParams } from 'react-router-dom' +import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx' +import NavBar from '../../../components/NavBar.tsx' +import { useFeedViewModel } from '../components/FeedView.ts' +import NavButton from '../../../components/buttons/NavButton.tsx' +import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx' + +interface AuthorPageParams { + postsService: PostsService +} + +export default function AuthorPage({ postsService }: AuthorPageParams) { + const { username } = useParams() + + const fetchPosts = useCallback( + async (cursor: string | null, amount: number | null) => { + return postsService.loadByAuthor(username!, cursor, amount) + }, + [postsService, username], + ) + + const { pages, loadNextPage } = useFeedViewModel(fetchPosts) + + return ( + + home + + + } + > + + + ) +} diff --git a/src/app/feed/pages/HomePage.tsx b/src/app/feed/pages/HomePage.tsx index b505d83..5ba5d27 100644 --- a/src/app/feed/pages/HomePage.tsx +++ b/src/app/feed/pages/HomePage.tsx @@ -1,104 +1,71 @@ -import { useRef, useState } from 'react' +import { useCallback, useState } from 'react' +import FeedView from '../components/FeedView.tsx' import { PostsService } from '../posts/postsService.ts' -import { useUserStore } from '../../user/user.ts' +import { useUser } from '../../user/userStore.ts' import { MediaService } from '../../media/mediaService.ts' -import NewPostWidget from '../components/NewPostWidget.tsx' +import NewPostWidget from '../../../components/NewPostWidget.tsx' +import { useFeedViewModel } from '../components/FeedView.ts' +import { Post } from '../posts/posts.ts' +import { Temporal } from '@js-temporal/polyfill' import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx' import NavBar from '../../../components/NavBar.tsx' import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx' import { useSaveSignupCodeToLocalStorage } from '../../../hooks/useSaveSignupCodeToLocalStorage.ts' -import PostItem from '../components/PostItem.tsx' -import { useIntersectionLoad } from '../../../hooks/useIntersectionLoad.ts' -import { delay } from '../../../utils/delay.ts' -import { usePostViewModel } from '../posts/usePostViewModel.ts' -import { Temporal } from '@js-temporal/polyfill' interface HomePageProps { postsService: PostsService mediaService: MediaService } -const PageSize = 20 - export default function HomePage({ postsService, mediaService }: HomePageProps) { - const user = useUserStore((state) => state.user) + const { user } = useUser() useSaveSignupCodeToLocalStorage() const [isSubmitting, setIsSubmitting] = useState(false) - const { posts, addPosts, addReaction, removeReaction, reactions } = usePostViewModel() + const fetchPosts = useCallback( + async (cursor: string | null, amount: number | null) => { + return postsService.loadPublicFeed(cursor, amount) + }, + [postsService], + ) - const [hasMore, setHasMore] = useState(true) - const [error, setError] = useState(null) + const { pages, setPages, loadNextPage } = useFeedViewModel(fetchPosts) - const cursor = useRef(null) - const loading = useRef(false) + const onCreatePost = useCallback( + async ( + content: string, + files: { file: File; width: number; height: number }[], + isPublic: boolean, + ) => { + setIsSubmitting(true) + if (user == null) throw new Error('Not logged in') + try { + const media = await Promise.all( + files.map(async ({ file, width, height }) => { + const { mediaId, url } = await mediaService.uploadFile(file) - const loadNextPage = async () => { - if (loading.current || !hasMore || error) return - loading.current = true - - try { - const [{ posts }] = await Promise.all([ - postsService.loadPublicFeed(cursor.current, PageSize), - delay(500), - ]) - - setHasMore(posts.length >= PageSize) - cursor.current = posts.at(-1)?.postId ?? null - addPosts(posts) - } catch (e: unknown) { - setError((e as Error).message) - } finally { - loading.current = false - } - } - - const onCreatePost = async ( - content: string, - files: { file: File; width: number; height: number }[], - isPublic: boolean, - ) => { - setIsSubmitting(true) - if (user == null) throw new Error('Not logged in') - try { - const media = await Promise.all( - files.map(async ({ file, width, height }) => { - const { mediaId, url } = await mediaService.uploadImage(file) - - return { - mediaId, - url, - width, - height, - } - }), - ) - const post = await postsService.createNew(user.id, content, media, isPublic) - addPosts([post]) - } catch (error) { - console.error('Failed to create post:', error) - } finally { - setIsSubmitting(false) - } - } + return { + mediaId, + url, + width, + height, + } + }), + ) + const postId = await postsService.createNew(user.userId, content, media, isPublic) + const post = new Post(postId, content, media, Temporal.Now.instant(), user.username) + setPages((pages) => [[post], ...pages]) + } catch (error) { + console.error('Failed to create post:', error) + } finally { + setIsSubmitting(false) + } + }, + [mediaService, postsService, setPages, user], + ) const isLoggedIn = user != null - const onAddReaction = async (postId: string, emoji: string) => { - await postsService.addReaction(postId, emoji) - - addReaction(postId, emoji, user!.username, Temporal.Now.instant()) - } - - const onClearReaction = async (postId: string, emoji: string) => { - await postsService.removeReaction(postId, emoji) - - removeReaction(postId, emoji, user!.username) - } - - const sentinelRef = useRef(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 deleted file mode 100644 index b275b68..0000000 --- a/src/app/feed/pages/PostPage.tsx +++ /dev/null @@ -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 ( - - {t('nav.home')} - - - } - > -
- {post && ( -
- - - -
- )} -
-
- ) -} diff --git a/src/app/feed/posts/posts.ts b/src/app/feed/posts/posts.ts index 837e2b8..77d0a5f 100644 --- a/src/app/feed/posts/posts.ts +++ b/src/app/feed/posts/posts.ts @@ -1,30 +1,12 @@ import { Temporal } from '@js-temporal/polyfill' import { components } from '../../api/schema.ts' -import { immerable } from 'immer' - -export interface PostReaction { - emoji: string - authorName: string - reactedOn: Temporal.Instant -} - -export interface PostComment { - author: string - content: string - postedOn: Temporal.Instant -} export class Post { - [immerable] = true - public readonly postId: string public readonly content: string public readonly media: PostMedia[] public readonly createdAt: Temporal.Instant public readonly authorName: string - public readonly reactions: PostReaction[] - public readonly possibleReactions: string[] - public readonly comments: PostComment[] constructor( postId: string, @@ -32,18 +14,12 @@ export class Post { media: PostMedia[], createdAt: string | Temporal.Instant, authorName: string, - reactions: PostReaction[], - possibleReactions: string[], - comments: PostComment[], ) { this.postId = postId this.content = content this.media = media this.createdAt = Temporal.Instant.from(createdAt) this.authorName = authorName - this.reactions = reactions - this.possibleReactions = possibleReactions - this.comments = comments } public static fromDto(dto: components['schemas']['PostDto']): Post { @@ -53,9 +29,6 @@ export class Post { dto.media.map((m) => new PostMediaImpl(new URL(m.url), m.width, m.height)), Temporal.Instant.from(dto.createdAt), dto.author.username, - dto.reactions.map((r) => ({ ...r, reactedOn: Temporal.Instant.from(r.reactedOn) })), - dto.possibleReactions, - dto.comments.map((c) => ({ ...c, postedOn: Temporal.Instant.from(c.postedOn) })), ) } } diff --git a/src/app/feed/posts/postsService.ts b/src/app/feed/posts/postsService.ts index 72e55f7..f7da251 100644 --- a/src/app/feed/posts/postsService.ts +++ b/src/app/feed/posts/postsService.ts @@ -1,17 +1,16 @@ import { Post } from './posts.ts' -import { ApiClient } from '../../api/client.ts' -import { useUserStore } from '../../user/user.ts' +import client from '../../api/client.ts' export class PostsService { - constructor(private readonly client: ApiClient) {} + constructor() {} async createNew( authorId: string, content: string, media: CreatePostMedia[], isPublic: boolean, - ): Promise { - const response = await this.client.POST('/posts', { + ): Promise { + const response = await client.POST('/posts', { body: { authorId, content, @@ -27,68 +26,39 @@ export class PostsService { throw new Error('Failed to create post') } - return Post.fromDto(response.data.post) + return response.data.postId } - async load(postId: string): Promise { - const response = await this.client.GET('/posts/{postId}', { - params: { - path: { postId }, - }, + async loadPublicFeed(cursor: string | null, amount: number | null): Promise { + const response = await client.GET('/posts', { + query: { cursor, amount }, credentials: 'include', }) - if (!response.data?.post) { - return null + if (!response.data) { + return [] } - return Post.fromDto(response.data.post) + return response.data?.posts.map((post) => Post.fromDto(post)) } - async loadPublicFeed(cursor: string | null, amount: number | null): Promise<{ posts: Post[] }> { - const response = await this.client.GET('/posts', { + async loadByAuthor( + username: string, + cursor: string | null, + amount: number | null, + ): Promise { + const response = await client.GET('/posts', { params: { - query: { After: cursor ?? undefined, Amount: amount ?? undefined }, + query: { From: cursor ?? undefined, Amount: amount ?? undefined, Author: username }, }, credentials: 'include', }) if (!response.data) { - return { posts: [] } + return [] } - return { posts: response.data.posts.map(Post.fromDto) } - } - - async addReaction(postId: string, emoji: string): Promise { - 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', - }) + return response.data?.posts.map((post) => Post.fromDto(post)) } } diff --git a/src/app/feed/posts/usePostViewModel.ts b/src/app/feed/posts/usePostViewModel.ts deleted file mode 100644 index 2fb06bd..0000000 --- a/src/app/feed/posts/usePostViewModel.ts +++ /dev/null @@ -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 - -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 deleted file mode 100644 index cc9593a..0000000 --- a/src/app/femtoApp.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 519fc55..0000000 --- a/src/app/i18n/translations.ts +++ /dev/null @@ -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: (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 deleted file mode 100644 index 6cc6cb5..0000000 --- a/src/app/i18n/translations/en.json +++ /dev/null @@ -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..." -} \ No newline at end of file diff --git a/src/app/media/mediaService.ts b/src/app/media/mediaService.ts index c9b6f2e..c63c627 100644 --- a/src/app/media/mediaService.ts +++ b/src/app/media/mediaService.ts @@ -1,12 +1,12 @@ -import { ApiClient } from '../api/client.ts' +import client from '../api/client.ts' export class MediaService { - constructor(private readonly client: ApiClient) {} - async uploadImage(file: File): Promise<{ mediaId: string; url: URL }> { + constructor() {} + async uploadFile(file: File): Promise<{ mediaId: string; url: URL }> { const body = new FormData() body.append('file', file) - const response = await this.client.POST('/media', { + const response = await client.POST('/media', { // @ts-expect-error this endpoint takes multipart/form-data which means passing a FormData as the body // maybe openapi-fetch only wants to handle JSON? who knows body, diff --git a/src/app/messageBus/messageTypes.ts b/src/app/messageBus/messageTypes.ts index 2338618..8d15f6c 100644 --- a/src/app/messageBus/messageTypes.ts +++ b/src/app/messageBus/messageTypes.ts @@ -1,10 +1,8 @@ -import { User } from '../user/user.ts' +import { User } from '../user/userStore.ts' export interface MessageTypes { - 'auth:logged-in': null - 'auth:registered': null + 'auth:logged-in': User + 'auth:registered': User '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 deleted file mode 100644 index 8ce3446..0000000 --- a/src/app/user/user.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { create } from 'zustand' - -export interface User { - id: string - username: string - roles: Role[] -} - -export enum Role { - User = 0, - SuperUser = 1, -} - -interface UserState { - user: User | null - setUser: (user: User | null) => void -} - -export const useUserStore = create()((set) => ({ - user: null, - setUser: (user: User | null) => set({ user }), -})) diff --git a/src/app/user/userStore.ts b/src/app/user/userStore.ts new file mode 100644 index 0000000..49107fc --- /dev/null +++ b/src/app/user/userStore.ts @@ -0,0 +1,38 @@ +import { createStore, Store, useStore } from '../../utils/store.ts' +import { addMessageListener } from '../messageBus/messageBus.ts' + +export interface User { + userId: string + username: string + isSuperUser: boolean +} + +export type UserStore = Store + +const UserKey = 'user' + +export const userStore = createStore(loadStoredUser()) + +userStore.subscribe((user) => { + localStorage.setItem(UserKey, JSON.stringify(user)) +}) + +const setUser = (u: User | null) => userStore.setState(u) +addMessageListener('auth:logged-in', setUser) +addMessageListener('auth:registered', setUser) +addMessageListener('auth:logged-out', setUser) + +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 89691c9..5dc99dd 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -1,35 +1,15 @@ -import { PropsWithChildren, ReactNode } from 'react' -import { Role, useUserStore } from '../app/user/user.ts' +import { PropsWithChildren } from 'react' +import { useUser } from '../app/user/userStore.ts' import NavButton from './buttons/NavButton.tsx' -type NavBarProps = { - leftChildren?: ReactNode -} +type NavBarProps = unknown export default function NavBar({ children }: PropsWithChildren) { - const user = useUserStore((state) => state.user) - const isSuperUser = user?.roles.includes(Role.SuperUser) + const { user } = useUser() return ( -
@@ -124,13 +121,11 @@ 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: getRandomId(), + id: crypto.randomUUID(), file, objectUrl, width, @@ -149,95 +144,3 @@ function getImageFileDimensions(objectURL: string): Promise<{ width: number; hei img.src = objectURL }) } - -const pica = makePica() - -async function optimizeImageSize( - file: File, - { - targetMaxWidth = 1920, - targetMaxHeight = 1080, - targetSizeBytes = 500 * 1024, - outputType = 'image/jpeg', - quality = 0.9, - }: { - targetMaxWidth?: number - targetMaxHeight?: number - targetSizeBytes?: number - outputType?: string - quality?: number - } = {}, -): Promise { - 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/components/inputs/FancyTextEditor.tsx b/src/components/inputs/FancyTextEditor.tsx index d72c391..f23c28d 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,7 +24,10 @@ export default function FancyTextEditor({ const divRef = useRef(null) const [hasFocus, setHasFocus] = useState(false) - const trimmedValue = value.trim() + // the contenteditable likes to slip in newlines at the bottom of our innerText + // which makes it bad to check for empty string because it might be "\n" + // so we just trim it upfront and then fogeddaboudit + const value = _value.trim() // The funky mechanics here are to stop the cursor from jumping back the start. // It probably will have the cursor jump to the start if anything changes programmatically, @@ -35,12 +38,12 @@ export default function FancyTextEditor({ return } - if (!trimmedValue && !hasFocus) { + if (!value && !hasFocus) { div.innerText = placeholder - } else if (div.innerText.trim() !== trimmedValue) { - div.innerText = trimmedValue + } else if (div.innerText !== value) { + div.innerText = value } - }, [hasFocus, placeholder, trimmedValue]) + }, [hasFocus, placeholder, value]) useEffect(() => { const div = divRef.current! diff --git a/src/hooks/useIntersectionLoad.ts b/src/hooks/useIntersectionLoad.ts index 024ace5..8464e2f 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 deleted file mode 100644 index 2053977..0000000 --- a/src/hooks/useOnMounted.ts +++ /dev/null @@ -1,17 +0,0 @@ -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/types.d.ts b/src/types.d.ts index c2cd42e..ad3865e 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,5 +1,3 @@ -import { User } from './app/user/user.ts' - export interface ProblemDetails { detail: string title: string @@ -7,18 +5,3 @@ export interface ProblemDetails { 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 deleted file mode 100644 index 5a0bf1c..0000000 --- a/src/useRefreshSessionLoop.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useEffect } from 'react' -import { AuthService } from './app/auth/authService.ts' -import { useUserStore } from './app/user/user.ts' - -// Starts a loop that pings the server to keep the session alive, while also getting any updates on the user profile -export function useRefreshSessionLoop(authService: AuthService) { - const user = useUserStore((state) => state.user) - const userId = user?.id ?? null - - useEffect(() => { - if (userId == null) { - return - } - - const timeouts: Timeout[] = [] - - timeouts.push( - setTimeout(async function refreshUser() { - await authService.refreshUser(userId) - - timeouts.push(setTimeout(refreshUser, 60_000)) - }), - ) - - return () => { - timeouts.forEach(clearTimeout) - } - }, [authService, userId]) -} diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts deleted file mode 100644 index 9f74991..0000000 --- a/src/utils/debounce.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 9c70749..0000000 --- a/src/utils/delay.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} diff --git a/src/utils/groupByAndMap.ts b/src/utils/groupByAndMap.ts deleted file mode 100644 index 7f30995..0000000 --- a/src/utils/groupByAndMap.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 ae99ef7..5db61dc 100644 --- a/src/utils/store.ts +++ b/src/utils/store.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' export interface Store { getState: () => T @@ -37,21 +37,7 @@ export function createStore(initialState: T): Store export function useStore(store: Store) { const [selectedState, setSelectedState] = useState(() => store.getState()) - useEffect(() => { - const unsubscribe = store.subscribe((newState) => setSelectedState(newState)) + useEffect(() => store.subscribe((newState) => setSelectedState(newState)), [store]) - return () => { - unsubscribe() - } - }, [store]) - - const setState = useCallback( - (nextState: T | ((prevState: T) => T)) => { - setSelectedState(nextState) - store.setState(nextState) - }, - [store], - ) - - return [selectedState, setState] as const + return [selectedState, setSelectedState] as const } diff --git a/vite.config.ts b/vite.config.ts index 1ea03c3..c4069b7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,15 +1,8 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' -import fs from 'node:fs' // https://vite.dev/config/ -export default defineConfig(() => { - const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf-8')) - - process.env.VITE_FEMTO_VERSION = packageJson.version - - return { - plugins: [react(), tailwindcss()], - } +export default defineConfig({ + plugins: [react(), tailwindcss()], }) diff --git a/yarn.lock b/yarn.lock index efeeb13..c5454e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -778,18 +778,6 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== -"@types/node@^22.15.19": - version "22.15.19" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.15.19.tgz#ba9f321675243af0456d607fa82a4865931e0cef" - integrity sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw== - dependencies: - undici-types "~6.21.0" - -"@types/pica@^9.0.5": - version "9.0.5" - resolved "https://registry.yarnpkg.com/@types/pica/-/pica-9.0.5.tgz#a526b51d45b7cb70423b7af0223ab9afd151a26e" - integrity sha512-OSd4905yxFNtRanHuyyQAfC9AkxiYcbhlzP606Gl6rFcYRgq4vdLCZuYKokLQBihgrkNzyPkoeykvJDWcPjaCw== - "@types/react-dom@^19.0.4": version "19.1.3" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.3.tgz#3f0c60804441bf34d19f8dd0d44405c0c0e21bfa" @@ -1579,11 +1567,6 @@ globals@^16.0.0: resolved "https://registry.yarnpkg.com/globals/-/globals-16.0.0.tgz#3d7684652c5c4fbd086ec82f9448214da49382d8" integrity sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A== -glur@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/glur/-/glur-1.1.2.tgz#f20ea36db103bfc292343921f1f91e83c3467689" - integrity sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA== - gopd@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" @@ -1932,14 +1915,6 @@ ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -multimath@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/multimath/-/multimath-2.0.0.tgz#0d37acf67c328f30e3d8c6b0d3209e6082710302" - integrity sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g== - dependencies: - glur "^1.1.2" - object-assign "^4.1.1" - nanoid@^3.3.8: version "3.3.11" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" @@ -1960,7 +1935,7 @@ node-releases@^2.0.19: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== -object-assign@^4, object-assign@^4.1.1: +object-assign@^4: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== @@ -2070,16 +2045,6 @@ path-to-regexp@^8.0.0: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz#73990cc29e57a3ff2a0d914095156df5db79e8b4" integrity sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ== -pica@^9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/pica/-/pica-9.0.1.tgz#9ba5a5e81fc09dca9800abef9fb8388434b18b2f" - integrity sha512-v0U4vY6Z3ztz9b4jBIhCD3WYoecGXCQeCsYep+sXRefViL+mVVoTL+wqzdPeE+GpBFsRUtQZb6dltvAt2UkMtQ== - dependencies: - glur "^1.1.2" - multimath "^2.0.0" - object-assign "^4.1.1" - webworkify "^1.5.0" - picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" @@ -2474,11 +2439,6 @@ typescript@~5.7.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.3.tgz#919b44a7dbb8583a9b856d162be24a54bf80073e" integrity sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw== -undici-types@~6.21.0: - version "6.21.0" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" - integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== - unpipe@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" @@ -2523,11 +2483,6 @@ vite@^6.3.1: optionalDependencies: fsevents "~2.3.3" -webworkify@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/webworkify/-/webworkify-1.5.0.tgz#734ad87a774de6ebdd546e1d3e027da5b8f4a42c" - integrity sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g== - which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -2574,8 +2529,3 @@ zod@^3.23.8, zod@^3.24.2: version "3.24.3" resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.3.tgz#1f40f750a05e477396da64438e0e1c0995dafd87" integrity sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg== - -zustand@^5.0.7: - version "5.0.7" - resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.7.tgz#e325364e82c992a84bf386d8445aa7f180c450dc" - integrity sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==