diff --git a/package.json b/package.json index 173479b..ada1684 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { "name": "femto-webapp", "private": true, - "version": "1.22.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/bump-build-push.sh", + "build:deploy": "bash scripts/publish.sh", "generate:schema": "node scripts/generate-schema.mjs" }, "dependencies": { @@ -20,7 +20,8 @@ "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", 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/scripts/bump-build-push.sh b/scripts/bump-build-push.sh deleted file mode 100755 index ae679c6..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 "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/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 3694fdf..9063592 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ 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 SignupPage from './app/auth/pages/SignupPage.tsx' import LoginPage from './app/auth/pages/LoginPage.tsx' import LogoutPage from './app/auth/pages/LogoutPage.tsx' @@ -7,13 +8,36 @@ 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 { initApp } from './initApp.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' -const { postService, mediaService, authService } = initApp() +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 ( @@ -22,6 +46,7 @@ export default function App() { path={'/'} element={} /> + } /> } /> } /> } /> diff --git a/src/app/admin/pages/subpages/SignupCodesManagementPage.tsx b/src/app/admin/pages/subpages/SignupCodesManagementPage.tsx index 4dfdfcb..0075c73 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 } from 'react' +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' @@ -12,25 +12,24 @@ 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 = async () => { + 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]) + }, [authService, fetchCodes]) const handleCreateCode = async (e: React.FormEvent) => { e.preventDefault() @@ -38,12 +37,11 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa setError(null) try { - await authService.createSignupCode(code, email, name) + await authService.createSignupCode(code, name) setCode('') setName('') - setEmail('') dialogRef.current?.close() - fetchCodes() // Refresh the table + fetchCodes() } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create signup code') } finally { @@ -116,7 +114,6 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa Code - Email Redeemed By Expires On @@ -134,7 +131,6 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa {code.code} - {code.email} {code.redeemedBy || 'Not redeemed'} {formatDate(code.expiresOn)} @@ -191,19 +187,6 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa /> -
- - setEmail(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-md" - /> -
-
- 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 dea8a72..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/user.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 ab6b376..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,6 +17,7 @@ interface SignupPageProps { } export default function SignupPage({ authService }: SignupPageProps) { + const { t } = useTranslations() const { code } = useParams() const [signupCode, setSignupCode] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) @@ -31,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(() => { @@ -47,10 +46,6 @@ export default function SignupPage({ authService }: SignupPageProps) { theSignupCode = localStorage.getItem(SignupCodeKey) setSignupCode(theSignupCode) } - - if (!theSignupCode) { - dialogRef.current?.showModal() - } }, [code, signupCode]) useEffect(() => {}, [signupCode]) @@ -94,7 +89,7 @@ export default function SignupPage({ authService }: SignupPageProps) { - home + {t('nav.home')} } > @@ -103,6 +98,7 @@ export default function SignupPage({ authService }: SignupPageProps) {
-
+
- 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 @@ -179,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/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 92% rename from src/components/NewPostWidget.tsx rename to src/app/feed/components/NewPostWidget.tsx index ea3496a..e0bd87a 100644 --- a/src/components/NewPostWidget.tsx +++ b/src/app/feed/components/NewPostWidget.tsx @@ -1,8 +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: ( @@ -22,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) @@ -72,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 && ( @@ -93,7 +95,7 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
diff --git a/src/app/feed/components/PostItem.tsx b/src/app/feed/components/PostItem.tsx index a0a541c..db766f6 100644 --- a/src/app/feed/components/PostItem.tsx +++ b/src/app/feed/components/PostItem.tsx @@ -1,13 +1,24 @@ -import { Post, PostMedia } from '../posts/posts.ts' +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, addReaction, clearReaction }: PostItemProps) { +export default function PostItem({ + post, + reactions, + addReaction, + clearReaction, + hideViewButton = false, +}: PostItemProps) { const formattedDate = post.createdAt.toLocaleString('en-US', { year: 'numeric', month: 'short', @@ -31,6 +42,14 @@ export default function PostItem({ post, addReaction, clearReaction }: PostItemP
@{post.authorName}• {formattedDate} + {!hideViewButton && ( + <> + {' • '} + + View + + + )}
{post.content}
@@ -43,26 +62,30 @@ export default function PostItem({ post, addReaction, clearReaction }: PostItemP )} - +
) } interface PostReactionsProps { - post: Post + post: PostInfo + reactions: PostReaction[] addReaction: (emoji: string) => void clearReaction: (emoji: string) => void } -function PostReactions({ post, addReaction, clearReaction }: PostReactionsProps) { - const reactionMap = new Map(post.reactions.map((r) => [r.emoji, r])) - +function PostReactions({ post, reactions, addReaction, clearReaction }: PostReactionsProps) { + const username = useUserStore((state) => state.user?.username) return (
{post.possibleReactions.map((emoji) => { - const reaction = reactionMap.get(emoji) - const count = reaction?.count ?? 0 - const didReact = reaction?.didReact ?? false + 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) @@ -99,7 +122,7 @@ function PostReactionButton({ emoji, didReact, onClick, count }: PostReactionBut