From 16194b301b6aeeabd99b9e90886edf609b577979 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 21 May 2025 13:49:17 +0200 Subject: [PATCH 01/58] umami --- index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/index.html b/index.html index 6ced263..7b84b23 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,7 @@ social media website +
From 8457604da7bb6d916e39c8239aaa8de53776ef90 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 21 May 2025 13:50:01 +0200 Subject: [PATCH 02/58] v1.14.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a695a33..9bbf069 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "femto-webapp", "private": true, - "version": "1.13.0", + "version": "1.14.0", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", From a6022d31c669a27fceb151d25282ab73b4358780 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 26 May 2025 22:05:52 +0200 Subject: [PATCH 03/58] shrink imagies --- .env | 1 - .gitignore | 2 + package.json | 2 + scripts/get-local-api-url.js | 28 ----------- src/app/feed/pages/HomePage.tsx | 2 +- src/app/media/mediaService.ts | 2 +- src/components/NewPostWidget.tsx | 84 +++++++++++++++++++++++++++++++- vite.config.ts | 14 +++--- yarn.lock | 35 ++++++++++++- 9 files changed, 131 insertions(+), 39 deletions(-) delete mode 100644 .env delete mode 100644 scripts/get-local-api-url.js diff --git a/.env b/.env deleted file mode 100644 index 922031a..0000000 --- a/.env +++ /dev/null @@ -1 +0,0 @@ -VITE_API_URL=http://localhost:5181 \ No newline at end of file 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/package.json b/package.json index 9bbf069..63039cb 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@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", @@ -24,6 +25,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/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/src/app/feed/pages/HomePage.tsx b/src/app/feed/pages/HomePage.tsx index 976bf83..73b6d84 100644 --- a/src/app/feed/pages/HomePage.tsx +++ b/src/app/feed/pages/HomePage.tsx @@ -42,7 +42,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) try { const media = await Promise.all( files.map(async ({ file, width, height }) => { - const { mediaId, url } = await mediaService.uploadFile(file) + const { mediaId, url } = await mediaService.uploadImage(file) return { mediaId, diff --git a/src/app/media/mediaService.ts b/src/app/media/mediaService.ts index 9c83d2b..c9b6f2e 100644 --- a/src/app/media/mediaService.ts +++ b/src/app/media/mediaService.ts @@ -2,7 +2,7 @@ import { ApiClient } from '../api/client.ts' export class MediaService { constructor(private readonly client: ApiClient) {} - async uploadFile(file: File): Promise<{ mediaId: string; url: URL }> { + async uploadImage(file: File): Promise<{ mediaId: string; url: URL }> { const body = new FormData() body.append('file', file) diff --git a/src/components/NewPostWidget.tsx b/src/components/NewPostWidget.tsx index 4b5668d..0dd8968 100644 --- a/src/components/NewPostWidget.tsx +++ b/src/components/NewPostWidget.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import FancyTextEditor, { TextInputKeyDownEvent } from './inputs/FancyTextEditor.tsx' import Button from './buttons/Button.tsx' import { openFileDialog } from '../utils/openFileDialog.ts' +import makePica from 'pica' interface NewPostWidgetProps { onSubmit: ( @@ -121,11 +122,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 +147,82 @@ 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 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 + + await pica.resize(srcCanvas, dstCanvas) + + let blob = await pica.toBlob(dstCanvas, outputType, quality) + + while (blob.size > targetSizeBytes && quality > 0.1) { + quality = parseFloat((quality - 0.1).toFixed(2)) + blob = await pica.toBlob(dstCanvas, outputType, quality) + } + + 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/vite.config.ts b/vite.config.ts index 2833f82..1ea03c3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,11 +3,13 @@ import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import fs from 'node:fs' -const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf-8')) - -process.env.VITE_FEMTO_VERSION = packageJson.version - // 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 33f166d..02021f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -785,6 +785,11 @@ 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" @@ -1574,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" @@ -1922,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" @@ -1942,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== @@ -2052,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" @@ -2495,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" From 48f1b873a5bbc4bab3126f0b9d6f4293fd23407a Mon Sep 17 00:00:00 2001 From: john Date: Mon, 26 May 2025 22:10:11 +0200 Subject: [PATCH 04/58] v1.15.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 63039cb..10b3479 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "femto-webapp", "private": true, - "version": "1.14.0", + "version": "1.15.0", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", From f147933701073d0706bfedf20a120d989969a16c Mon Sep 17 00:00:00 2001 From: john Date: Mon, 26 May 2025 22:17:49 +0200 Subject: [PATCH 05/58] make optimization slightly less wonky? --- src/components/NewPostWidget.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/NewPostWidget.tsx b/src/components/NewPostWidget.tsx index 0dd8968..7146bc7 100644 --- a/src/components/NewPostWidget.tsx +++ b/src/components/NewPostWidget.tsx @@ -185,6 +185,7 @@ async function optimizeImageSize( 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 @@ -200,10 +201,14 @@ async function optimizeImageSize( let blob = await pica.toBlob(dstCanvas, outputType, quality) while (blob.size > targetSizeBytes && quality > 0.1) { - quality = parseFloat((quality - 0.1).toFixed(2)) + 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 }) From 578d348728ea399cdc350a7f718aab1057268d6c Mon Sep 17 00:00:00 2001 From: john Date: Mon, 26 May 2025 22:19:23 +0200 Subject: [PATCH 06/58] v1.16.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 10b3479..e9b6b9c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "femto-webapp", "private": true, - "version": "1.15.0", + "version": "1.16.0", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", From 48ea06294ea8fee905e42e6278751a8e7c5c7bde Mon Sep 17 00:00:00 2001 From: john Date: Mon, 26 May 2025 22:30:34 +0200 Subject: [PATCH 07/58] fix fancy text editor bug --- src/components/inputs/FancyTextEditor.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) 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! From 72389136a7cf44087bf16886c0036037030f365e Mon Sep 17 00:00:00 2001 From: john Date: Mon, 26 May 2025 22:49:15 +0200 Subject: [PATCH 08/58] wip emoji reactions --- src/app/feed/components/PostItem.tsx | 53 ++++++++++++++++++++++++++++ src/app/feed/posts/posts.ts | 45 +++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/src/app/feed/components/PostItem.tsx b/src/app/feed/components/PostItem.tsx index 582e05b..99a1dd6 100644 --- a/src/app/feed/components/PostItem.tsx +++ b/src/app/feed/components/PostItem.tsx @@ -44,10 +44,63 @@ export default function PostItem({ post }: PostItemProps) { ))} )} + + ) } +interface PostReactionsProps { + post: Post +} + +function PostReactions({ post }: PostReactionsProps) { + // State to track user's reactions + const [userReactions, setUserReactions] = useState>(new Set()) + + // Function to format reaction count + const formatCount = (count: number): string => { + if (count < 1000) return count.toString() + if (count < 10000) return `${(count / 1000).toFixed(1)}K` + return `${Math.floor(count / 1000)}K` + } + + // Function to handle reaction click + const handleReactionClick = (emoji: string) => { + setUserReactions((prev) => { + const newReactions = new Set(prev) + if (newReactions.has(emoji)) { + newReactions.delete(emoji) + } else { + newReactions.add(emoji) + } + return newReactions + }) + } + + return ( +
+ {post.reactions.map((reaction) => { + const isSelected = userReactions.has(reaction.emoji) + return ( + + ) + })} +
+ ) +} + interface PostMediaProps { media: PostMedia } diff --git a/src/app/feed/posts/posts.ts b/src/app/feed/posts/posts.ts index 77d0a5f..dee5c7b 100644 --- a/src/app/feed/posts/posts.ts +++ b/src/app/feed/posts/posts.ts @@ -1,12 +1,18 @@ import { Temporal } from '@js-temporal/polyfill' import { components } from '../../api/schema.ts' +export interface EmojiReaction { + emoji: string + count: number +} + export class Post { public readonly postId: string public readonly content: string public readonly media: PostMedia[] public readonly createdAt: Temporal.Instant public readonly authorName: string + public readonly reactions: EmojiReaction[] constructor( postId: string, @@ -14,12 +20,51 @@ export class Post { media: PostMedia[], createdAt: string | Temporal.Instant, authorName: string, + reactions: EmojiReaction[] = [], ) { this.postId = postId this.content = content this.media = media this.createdAt = Temporal.Instant.from(createdAt) this.authorName = authorName + this.reactions = reactions.length > 0 ? reactions : this.generateRandomReactions() + } + + private generateRandomReactions(): EmojiReaction[] { + // List of popular emojis + const emojis = [ + '👍', + '❤️', + '😂', + '🎉', + '🔥', + '👏', + '🙏', + '💯', + '🤔', + '😍', + '🥰', + '😮', + '😢', + '😡', + '🤩', + ] + + // Randomly select 5 unique emojis + const selectedEmojis: string[] = [] + while (selectedEmojis.length < 5) { + const randomIndex = Math.floor(Math.random() * emojis.length) + const emoji = emojis[randomIndex] + if (!selectedEmojis.includes(emoji!)) { + selectedEmojis.push(emoji!) + } + } + + // Create reaction objects with random counts + return selectedEmojis.map((emoji) => ({ + emoji, + count: Math.floor(Math.random() * 50), // Random count between 0 and 49 + })) } public static fromDto(dto: components['schemas']['PostDto']): Post { From 83835d374b111d4464eb72e5e4052a262fde1e5e Mon Sep 17 00:00:00 2001 From: john Date: Wed, 28 May 2025 19:56:30 +0200 Subject: [PATCH 09/58] use reactions from post --- src/app/api/schema.ts | 65 +++++++++++++++++++++++----- src/app/feed/components/PostItem.tsx | 36 +++++++-------- src/app/feed/posts/posts.ts | 49 +++++---------------- 3 files changed, 80 insertions(+), 70 deletions(-) diff --git a/src/app/api/schema.ts b/src/app/api/schema.ts index f476eb0..2e88b48 100644 --- a/src/app/api/schema.ts +++ b/src/app/api/schema.ts @@ -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,41 @@ export interface paths { patch?: never trace?: never } + '/posts/{postId}': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + 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 + } '/media': { parameters: { query?: never @@ -87,7 +122,8 @@ export interface paths { requestBody: { content: { 'multipart/form-data': { - file?: components['schemas']['IFormFile'] + /** Format: binary */ + file?: string } } } @@ -387,24 +423,21 @@ export interface components { height: number | null } CreatePostResponse: { - /** Format: uuid */ - postId: string + post: components['schemas']['PostDto'] } CreateSignupCodeRequest: { code: string email: string name: string } - GetAllPublicPostsResponse: { + ListSignupCodesResult: { + signupCodes: components['schemas']['SignupCodeDto'][] + } + LoadPostsResponse: { posts: components['schemas']['PostDto'][] /** Format: uuid */ next: string | null } - /** Format: binary */ - IFormFile: string - ListSignupCodesResult: { - signupCodes: components['schemas']['SignupCodeDto'][] - } LoginRequest: { username: string password: string @@ -426,8 +459,10 @@ export interface components { postId: string content: string media: components['schemas']['PostMediaDto'][] + reactions: components['schemas']['PostReactionDto'][] /** Format: date-time */ createdAt: string + possibleReactions: string[] } PostMediaDto: { /** Format: uri */ @@ -437,6 +472,12 @@ export interface components { /** Format: int32 */ height: number | null } + PostReactionDto: { + emoji: string + /** Format: int32 */ + count: number + didReact: boolean + } RefreshUserResult: { /** Format: uuid */ userId: string diff --git a/src/app/feed/components/PostItem.tsx b/src/app/feed/components/PostItem.tsx index 99a1dd6..53259cc 100644 --- a/src/app/feed/components/PostItem.tsx +++ b/src/app/feed/components/PostItem.tsx @@ -55,9 +55,6 @@ interface PostReactionsProps { } function PostReactions({ post }: PostReactionsProps) { - // State to track user's reactions - const [userReactions, setUserReactions] = useState>(new Set()) - // Function to format reaction count const formatCount = (count: number): string => { if (count < 1000) return count.toString() @@ -65,34 +62,33 @@ function PostReactions({ post }: PostReactionsProps) { return `${Math.floor(count / 1000)}K` } - // Function to handle reaction click + // NOOP handlers for react/unreact functionality const handleReactionClick = (emoji: string) => { - setUserReactions((prev) => { - const newReactions = new Set(prev) - if (newReactions.has(emoji)) { - newReactions.delete(emoji) - } else { - newReactions.add(emoji) - } - return newReactions - }) + console.log(`Reaction clicked: ${emoji}`) + // This would normally call an API to add/remove a reaction } + // Find existing reactions to display + const reactionMap = new Map(post.reactions.map(r => [r.emoji, r])) + return (
- {post.reactions.map((reaction) => { - const isSelected = userReactions.has(reaction.emoji) + {post.possibleReactions.map((emoji) => { + const reaction = reactionMap.get(emoji) + const count = reaction?.count || 0 + const didReact = reaction?.didReact || false + return ( ) diff --git a/src/app/feed/posts/posts.ts b/src/app/feed/posts/posts.ts index dee5c7b..b25dba3 100644 --- a/src/app/feed/posts/posts.ts +++ b/src/app/feed/posts/posts.ts @@ -4,6 +4,7 @@ import { components } from '../../api/schema.ts' export interface EmojiReaction { emoji: string count: number + didReact: boolean } export class Post { @@ -13,6 +14,7 @@ export class Post { public readonly createdAt: Temporal.Instant public readonly authorName: string public readonly reactions: EmojiReaction[] + public readonly possibleReactions: string[] constructor( postId: string, @@ -21,50 +23,15 @@ export class Post { createdAt: string | Temporal.Instant, authorName: string, reactions: EmojiReaction[] = [], + possibleReactions: string[] = [], ) { this.postId = postId this.content = content this.media = media this.createdAt = Temporal.Instant.from(createdAt) this.authorName = authorName - this.reactions = reactions.length > 0 ? reactions : this.generateRandomReactions() - } - - private generateRandomReactions(): EmojiReaction[] { - // List of popular emojis - const emojis = [ - '👍', - '❤️', - '😂', - '🎉', - '🔥', - '👏', - '🙏', - '💯', - '🤔', - '😍', - '🥰', - '😮', - '😢', - '😡', - '🤩', - ] - - // Randomly select 5 unique emojis - const selectedEmojis: string[] = [] - while (selectedEmojis.length < 5) { - const randomIndex = Math.floor(Math.random() * emojis.length) - const emoji = emojis[randomIndex] - if (!selectedEmojis.includes(emoji!)) { - selectedEmojis.push(emoji!) - } - } - - // Create reaction objects with random counts - return selectedEmojis.map((emoji) => ({ - emoji, - count: Math.floor(Math.random() * 50), // Random count between 0 and 49 - })) + this.reactions = reactions + this.possibleReactions = possibleReactions } public static fromDto(dto: components['schemas']['PostDto']): Post { @@ -74,6 +41,12 @@ 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) => ({ + emoji: r.emoji, + count: r.count, + didReact: r.didReact + })), + dto.possibleReactions ) } } From dab626f2273bfc2c19e78f46cd536ba0a1566f77 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 28 May 2025 20:05:10 +0200 Subject: [PATCH 10/58] handle returned post from create post endpoint --- src/app/feed/pages/HomePage.tsx | 5 +---- src/app/feed/posts/postsService.ts | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/app/feed/pages/HomePage.tsx b/src/app/feed/pages/HomePage.tsx index 73b6d84..7da64a7 100644 --- a/src/app/feed/pages/HomePage.tsx +++ b/src/app/feed/pages/HomePage.tsx @@ -5,8 +5,6 @@ import { useUser } 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 SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx' import NavBar from '../../../components/NavBar.tsx' import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx' @@ -52,8 +50,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) } }), ) - const postId = await postsService.createNew(user.id, content, media, isPublic) - const post = new Post(postId, content, media, Temporal.Now.instant(), user.username) + const post = await postsService.createNew(user.id, content, media, isPublic) setPages((pages) => [[post], ...pages]) } catch (error) { console.error('Failed to create post:', error) diff --git a/src/app/feed/posts/postsService.ts b/src/app/feed/posts/postsService.ts index 6eaf4cc..51ff408 100644 --- a/src/app/feed/posts/postsService.ts +++ b/src/app/feed/posts/postsService.ts @@ -9,7 +9,7 @@ export class PostsService { content: string, media: CreatePostMedia[], isPublic: boolean, - ): Promise { + ): Promise { const response = await this.client.POST('/posts', { body: { authorId, @@ -26,7 +26,7 @@ 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 { From 48e7094c5e421d0a9db97da534c1d65a4d52df6c Mon Sep 17 00:00:00 2001 From: john Date: Wed, 28 May 2025 20:49:08 +0200 Subject: [PATCH 11/58] add and remove reactions --- src/app/api/schema.ts | 72 +++++++++++++++++++++++++ src/app/feed/components/FeedView.tsx | 11 +++- src/app/feed/components/PostItem.tsx | 79 +++++++++++++++++----------- src/app/feed/pages/AuthorPage.tsx | 15 +++++- src/app/feed/pages/HomePage.tsx | 15 +++++- src/app/feed/posts/postsService.ts | 32 +++++++++-- 6 files changed, 185 insertions(+), 39 deletions(-) diff --git a/src/app/api/schema.ts b/src/app/api/schema.ts index 2e88b48..da176b8 100644 --- a/src/app/api/schema.ts +++ b/src/app/api/schema.ts @@ -103,6 +103,72 @@ export interface paths { 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 + } '/media': { parameters: { query?: never @@ -404,6 +470,9 @@ export interface paths { export type webhooks = Record export interface components { schemas: { + AddPostReactionRequest: { + emoji: string + } CreatePostRequest: { /** Format: uuid */ authorId: string @@ -430,6 +499,9 @@ export interface components { email: string name: string } + DeletePostReactionRequest: { + emoji: string + } ListSignupCodesResult: { signupCodes: components['schemas']['SignupCodeDto'][] } diff --git a/src/app/feed/components/FeedView.tsx b/src/app/feed/components/FeedView.tsx index d35256f..2ae7839 100644 --- a/src/app/feed/components/FeedView.tsx +++ b/src/app/feed/components/FeedView.tsx @@ -6,9 +6,11 @@ import PostItem from './PostItem.tsx' interface FeedViewProps { pages: Post[][] onLoadMore: () => Promise + addReaction: (postId: string, emoji: string) => void + clearReaction: (postId: string, emoji: string) => void } -export default function FeedView({ pages, onLoadMore }: FeedViewProps) { +export default function FeedView({ pages, onLoadMore, addReaction, clearReaction }: FeedViewProps) { const sentinelRef = useRef(null) const posts = pages.flat() @@ -19,7 +21,12 @@ export default function FeedView({ pages, onLoadMore }: FeedViewProps) {
{posts.map((post) => ( - + addReaction(post.postId, emoji)} + clearReaction={(emoji) => clearReaction(post.postId, emoji)} + /> ))}
diff --git a/src/app/feed/components/PostItem.tsx b/src/app/feed/components/PostItem.tsx index 53259cc..fc92658 100644 --- a/src/app/feed/components/PostItem.tsx +++ b/src/app/feed/components/PostItem.tsx @@ -4,9 +4,11 @@ import { useEffect, useState } from 'react' interface PostItemProps { post: Post + addReaction: (emoji: string) => void + clearReaction: (emoji: string) => void } -export default function PostItem({ post }: PostItemProps) { +export default function PostItem({ post, addReaction, clearReaction }: PostItemProps) { const formattedDate = post.createdAt.toLocaleString('en-US', { year: 'numeric', month: 'short', @@ -45,58 +47,71 @@ export default function PostItem({ post }: PostItemProps) {
)} - + ) } interface PostReactionsProps { post: Post + addReaction: (emoji: string) => void + clearReaction: (emoji: string) => void } -function PostReactions({ post }: PostReactionsProps) { - // Function to format reaction count - const formatCount = (count: number): string => { - if (count < 1000) return count.toString() - if (count < 10000) return `${(count / 1000).toFixed(1)}K` - return `${Math.floor(count / 1000)}K` - } - - // NOOP handlers for react/unreact functionality - const handleReactionClick = (emoji: string) => { - console.log(`Reaction clicked: ${emoji}`) - // This would normally call an API to add/remove a reaction - } - - // Find existing reactions to display - const reactionMap = new Map(post.reactions.map(r => [r.emoji, r])) +function PostReactions({ post, addReaction, clearReaction }: PostReactionsProps) { + const reactionMap = new Map(post.reactions.map((r) => [r.emoji, r])) return (
{post.possibleReactions.map((emoji) => { const reaction = reactionMap.get(emoji) - const count = reaction?.count || 0 - const didReact = reaction?.didReact || false + const count = reaction?.count ?? 0 + const didReact = reaction?.didReact ?? false + const onClick = () => { + if (didReact) { + clearReaction(emoji) + } else { + addReaction(emoji) + } + } return ( - + onClick()} + /> ) })}
) } +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 } diff --git a/src/app/feed/pages/AuthorPage.tsx b/src/app/feed/pages/AuthorPage.tsx index d937b88..1cec207 100644 --- a/src/app/feed/pages/AuthorPage.tsx +++ b/src/app/feed/pages/AuthorPage.tsx @@ -24,6 +24,14 @@ export default function AuthorPage({ postsService }: AuthorPageParams) { const { pages, loadNextPage } = useFeedViewModel(fetchPosts) + const addReaction = async (postId: string, emoji: string) => { + await postsService.addReaction(postId, emoji) + } + + const clearReaction = async (postId: string, emoji: string) => { + await postsService.removeReaction(postId, emoji) + } + return ( } > - + ) } diff --git a/src/app/feed/pages/HomePage.tsx b/src/app/feed/pages/HomePage.tsx index 7da64a7..f4f6827 100644 --- a/src/app/feed/pages/HomePage.tsx +++ b/src/app/feed/pages/HomePage.tsx @@ -63,6 +63,14 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) const isLoggedIn = user != null + const addReaction = async (postId: string, emoji: string) => { + await postsService.addReaction(postId, emoji) + } + + const clearReaction = async (postId: string, emoji: string) => { + await postsService.removeReaction(postId, emoji) + } + return (
{isLoggedIn && } - +
) diff --git a/src/app/feed/posts/postsService.ts b/src/app/feed/posts/postsService.ts index 51ff408..d00c99b 100644 --- a/src/app/feed/posts/postsService.ts +++ b/src/app/feed/posts/postsService.ts @@ -48,9 +48,7 @@ export class PostsService { amount: number | null, ): Promise { const response = await this.client.GET('/posts', { - params: { - query: { From: cursor ?? undefined, Amount: amount ?? undefined, Author: username }, - }, + query: { From: cursor ?? undefined, Amount: amount ?? undefined, Author: username }, credentials: 'include', }) @@ -60,6 +58,34 @@ export class PostsService { return response.data?.posts.map((post) => Post.fromDto(post)) } + + async addReaction(postId: string, emoji: string): Promise { + const response = await this.client.POST('/posts/{postId}/reactions', { + params: { + path: { postId } + }, + body: { emoji }, + credentials: 'include', + }) + + if (!response.data) { + throw new Error('Failed to add reaction') + } + } + + async removeReaction(postId: string, emoji: string): Promise { + const response = await this.client.DELETE('/posts/{postId}/reactions', { + params: { + path: { postId } + }, + body: { emoji }, + credentials: 'include', + }) + + if (!response.data) { + throw new Error('Failed to remove reaction') + } + } } interface CreatePostMedia { From c21e193fbf667d922423d932442e2c6e5d0f8201 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 28 May 2025 22:05:51 +0200 Subject: [PATCH 12/58] reactions --- src/App.tsx | 2 - src/app/feed/components/FeedView.ts | 33 +----------- src/app/feed/components/FeedView.tsx | 5 +- src/app/feed/components/PostItem.tsx | 9 ++-- src/app/feed/pages/AuthorPage.tsx | 52 ------------------- src/app/feed/pages/HomePage.tsx | 75 ++++++++++++++++++++++++---- src/app/feed/posts/posts.ts | 7 ++- src/app/feed/posts/postsService.ts | 16 ++---- src/utils/debounce.ts | 17 +++++++ 9 files changed, 97 insertions(+), 119 deletions(-) delete mode 100644 src/app/feed/pages/AuthorPage.tsx create mode 100644 src/utils/debounce.ts diff --git a/src/App.tsx b/src/App.tsx index c1406bc..3694fdf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,5 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom' import HomePage from './app/feed/pages/HomePage.tsx' -import AuthorPage from './app/feed/pages/AuthorPage.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' @@ -23,7 +22,6 @@ export default function App() { path={'/'} element={} /> - } /> } /> } /> } /> diff --git a/src/app/feed/components/FeedView.ts b/src/app/feed/components/FeedView.ts index b79acb9..d8ed6bd 100644 --- a/src/app/feed/components/FeedView.ts +++ b/src/app/feed/components/FeedView.ts @@ -1,36 +1,5 @@ import { useCallback, useRef, useState } from 'react' import { Post } from '../posts/posts.ts' +import { produce, WritableDraft } from 'immer' 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 index 2ae7839..2b814b3 100644 --- a/src/app/feed/components/FeedView.tsx +++ b/src/app/feed/components/FeedView.tsx @@ -4,15 +4,14 @@ import { Post } from '../posts/posts.ts' import PostItem from './PostItem.tsx' interface FeedViewProps { - pages: Post[][] + posts: Post[] onLoadMore: () => Promise addReaction: (postId: string, emoji: string) => void clearReaction: (postId: string, emoji: string) => void } -export default function FeedView({ pages, onLoadMore, addReaction, clearReaction }: FeedViewProps) { +export default function FeedView({ posts, onLoadMore, addReaction, clearReaction }: FeedViewProps) { const sentinelRef = useRef(null) - const posts = pages.flat() useIntersectionLoad(onLoadMore, sentinelRef) diff --git a/src/app/feed/components/PostItem.tsx b/src/app/feed/components/PostItem.tsx index fc92658..a0a541c 100644 --- a/src/app/feed/components/PostItem.tsx +++ b/src/app/feed/components/PostItem.tsx @@ -1,5 +1,4 @@ import { Post, PostMedia } from '../posts/posts.ts' -import { Link } from 'react-router-dom' import { useEffect, useState } from 'react' interface PostItemProps { @@ -31,10 +30,7 @@ export default function PostItem({ post, addReaction, clearReaction }: PostItemP return (
- - @{post.authorName} - - • {formattedDate} + @{post.authorName}• {formattedDate}
{post.content}
@@ -77,10 +73,11 @@ function PostReactions({ post, addReaction, clearReaction }: PostReactionsProps) return ( onClick()} + onClick={onClick} /> ) })} diff --git a/src/app/feed/pages/AuthorPage.tsx b/src/app/feed/pages/AuthorPage.tsx deleted file mode 100644 index 1cec207..0000000 --- a/src/app/feed/pages/AuthorPage.tsx +++ /dev/null @@ -1,52 +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) - - const addReaction = async (postId: string, emoji: string) => { - await postsService.addReaction(postId, emoji) - } - - const clearReaction = async (postId: string, emoji: string) => { - await postsService.removeReaction(postId, emoji) - } - - return ( - - home - - - } - > - - - ) -} diff --git a/src/app/feed/pages/HomePage.tsx b/src/app/feed/pages/HomePage.tsx index f4f6827..e0b73b8 100644 --- a/src/app/feed/pages/HomePage.tsx +++ b/src/app/feed/pages/HomePage.tsx @@ -1,20 +1,23 @@ -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import FeedView from '../components/FeedView.tsx' import { PostsService } from '../posts/postsService.ts' import { useUser } from '../../user/user.ts' import { MediaService } from '../../media/mediaService.ts' import NewPostWidget from '../../../components/NewPostWidget.tsx' -import { useFeedViewModel } from '../components/FeedView.ts' 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 { Post } from '../posts/posts.ts' +import { produce, WritableDraft } from 'immer' interface HomePageProps { postsService: PostsService mediaService: MediaService } +const PageSize = 20 + export default function HomePage({ postsService, mediaService }: HomePageProps) { const user = useUser() useSaveSignupCodeToLocalStorage() @@ -27,7 +30,31 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) [postsService], ) - const { pages, setPages, loadNextPage } = useFeedViewModel(fetchPosts) + const [posts, setPosts] = 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 = fetchPosts(cursor.current, PageSize) + const [page] = await Promise.all([pagePromise, delay]) + setHasMore(page.length >= PageSize) + cursor.current = page.at(-1)?.postId ?? null + setPosts((prev) => [...prev, ...page]) + } catch (e: unknown) { + const err = e as Error + setError(err.message) + } finally { + loading.current = false + } + }, [fetchPosts, hasMore, error]) const onCreatePost = useCallback( async ( @@ -51,24 +78,52 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) }), ) const post = await postsService.createNew(user.id, content, media, isPublic) - setPages((pages) => [[post], ...pages]) + setPosts((pages) => [post, ...pages]) } catch (error) { console.error('Failed to create post:', error) } finally { setIsSubmitting(false) } }, - [mediaService, postsService, setPages, user], + [mediaService, postsService, setPosts, user], ) const isLoggedIn = user != null - const addReaction = async (postId: string, emoji: string) => { + const onAddReaction = async (postId: string, emoji: string) => { await postsService.addReaction(postId, emoji) + + setPosts((prev) => + produce(prev, (draft: WritableDraft) => { + const post = draft.find((p) => p.postId === postId) + if (!post) return + + const theReaction = post.reactions.find((r) => r.emoji === emoji) + if (theReaction) { + theReaction.count++ + theReaction.didReact = true + } else { + post.reactions.push({ emoji, count: 1, didReact: true }) + } + }), + ) } - const clearReaction = async (postId: string, emoji: string) => { + const onClearReaction = async (postId: string, emoji: string) => { await postsService.removeReaction(postId, emoji) + + setPosts((prev) => + produce(prev, (draft: WritableDraft) => { + const post = draft.find((p) => p.postId === postId) + if (!post) return + + const theReaction = post.reactions.find((r) => r.emoji === emoji) + if (theReaction) { + theReaction.count = Math.max(theReaction.count - 1, 0) + theReaction.didReact = false + } + }), + ) } return ( @@ -82,10 +137,10 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
{isLoggedIn && }
diff --git a/src/app/feed/posts/posts.ts b/src/app/feed/posts/posts.ts index b25dba3..bcd1b0b 100644 --- a/src/app/feed/posts/posts.ts +++ b/src/app/feed/posts/posts.ts @@ -1,5 +1,6 @@ import { Temporal } from '@js-temporal/polyfill' import { components } from '../../api/schema.ts' +import { immerable } from 'immer' export interface EmojiReaction { emoji: string @@ -8,6 +9,8 @@ export interface EmojiReaction { } export class Post { + [immerable] = true + public readonly postId: string public readonly content: string public readonly media: PostMedia[] @@ -44,9 +47,9 @@ export class Post { dto.reactions.map((r) => ({ emoji: r.emoji, count: r.count, - didReact: r.didReact + didReact: r.didReact, })), - dto.possibleReactions + dto.possibleReactions, ) } } diff --git a/src/app/feed/posts/postsService.ts b/src/app/feed/posts/postsService.ts index d00c99b..e6c2c88 100644 --- a/src/app/feed/posts/postsService.ts +++ b/src/app/feed/posts/postsService.ts @@ -60,31 +60,23 @@ export class PostsService { } async addReaction(postId: string, emoji: string): Promise { - const response = await this.client.POST('/posts/{postId}/reactions', { + await this.client.POST('/posts/{postId}/reactions', { params: { - path: { postId } + path: { postId }, }, body: { emoji }, credentials: 'include', }) - - if (!response.data) { - throw new Error('Failed to add reaction') - } } async removeReaction(postId: string, emoji: string): Promise { - const response = await this.client.DELETE('/posts/{postId}/reactions', { + await this.client.DELETE('/posts/{postId}/reactions', { params: { - path: { postId } + path: { postId }, }, body: { emoji }, credentials: 'include', }) - - if (!response.data) { - throw new Error('Failed to remove reaction') - } } } 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], + ) +} From e16f7941d51c7dbde51791b9ce066f6c2cb823de Mon Sep 17 00:00:00 2001 From: john Date: Wed, 28 May 2025 22:08:15 +0200 Subject: [PATCH 13/58] remove unused thing --- src/app/feed/components/FeedView.ts | 5 ---- src/app/feed/components/FeedView.tsx | 35 ---------------------------- src/app/feed/pages/HomePage.tsx | 27 +++++++++++++++------ 3 files changed, 20 insertions(+), 47 deletions(-) delete mode 100644 src/app/feed/components/FeedView.ts delete mode 100644 src/app/feed/components/FeedView.tsx diff --git a/src/app/feed/components/FeedView.ts b/src/app/feed/components/FeedView.ts deleted file mode 100644 index d8ed6bd..0000000 --- a/src/app/feed/components/FeedView.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useCallback, useRef, useState } from 'react' -import { Post } from '../posts/posts.ts' -import { produce, WritableDraft } from 'immer' - -const PageSize = 20 diff --git a/src/app/feed/components/FeedView.tsx b/src/app/feed/components/FeedView.tsx deleted file mode 100644 index 2b814b3..0000000 --- a/src/app/feed/components/FeedView.tsx +++ /dev/null @@ -1,35 +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 { - posts: Post[] - onLoadMore: () => Promise - addReaction: (postId: string, emoji: string) => void - clearReaction: (postId: string, emoji: string) => void -} - -export default function FeedView({ posts, onLoadMore, addReaction, clearReaction }: FeedViewProps) { - const sentinelRef = useRef(null) - - useIntersectionLoad(onLoadMore, sentinelRef) - - return ( -
-
-
- {posts.map((post) => ( - addReaction(post.postId, emoji)} - clearReaction={(emoji) => clearReaction(post.postId, emoji)} - /> - ))} -
-
-
-
- ) -} diff --git a/src/app/feed/pages/HomePage.tsx b/src/app/feed/pages/HomePage.tsx index e0b73b8..738d168 100644 --- a/src/app/feed/pages/HomePage.tsx +++ b/src/app/feed/pages/HomePage.tsx @@ -1,5 +1,4 @@ import { useCallback, useRef, useState } from 'react' -import FeedView from '../components/FeedView.tsx' import { PostsService } from '../posts/postsService.ts' import { useUser } from '../../user/user.ts' import { MediaService } from '../../media/mediaService.ts' @@ -10,6 +9,8 @@ import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx' import { useSaveSignupCodeToLocalStorage } from '../../../hooks/useSaveSignupCodeToLocalStorage.ts' import { Post } from '../posts/posts.ts' import { produce, WritableDraft } from 'immer' +import PostItem from '../components/PostItem.tsx' +import { useIntersectionLoad } from '../../../hooks/useIntersectionLoad.ts' interface HomePageProps { postsService: PostsService @@ -126,6 +127,9 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) ) } + const sentinelRef = useRef(null) + useIntersectionLoad(loadNextPage, sentinelRef) + return (
{isLoggedIn && } - +
+
+
+ {posts.map((post) => ( + onAddReaction(post.postId, emoji)} + clearReaction={(emoji) => onClearReaction(post.postId, emoji)} + /> + ))} +
+
+
+
) From d1288b8e6911834bd29c68512aa0c48ab7451842 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 28 May 2025 22:09:01 +0200 Subject: [PATCH 14/58] v1.17.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e9b6b9c..99b9495 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "femto-webapp", "private": true, - "version": "1.16.0", + "version": "1.17.0", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", From ca2d377fc42681d52b0bc4c91ddb93bc11239b23 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 28 May 2025 22:22:17 +0200 Subject: [PATCH 15/58] make email not required for codes --- src/app/admin/pages/subpages/SignupCodesManagementPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/admin/pages/subpages/SignupCodesManagementPage.tsx b/src/app/admin/pages/subpages/SignupCodesManagementPage.tsx index 366d5cd..4dfdfcb 100644 --- a/src/app/admin/pages/subpages/SignupCodesManagementPage.tsx +++ b/src/app/admin/pages/subpages/SignupCodesManagementPage.tsx @@ -201,7 +201,6 @@ export default function SignupCodesManagementPage({ authService }: SignupCodesMa value={email} onChange={(e) => setEmail(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md" - required />
From 6aeff5185793c0630e5b8b0a7e6534da7f7bc858 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 28 May 2025 22:35:20 +0200 Subject: [PATCH 16/58] ignore resist fingerprinting error when resizing image TODO find another way --- src/components/NewPostWidget.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/NewPostWidget.tsx b/src/components/NewPostWidget.tsx index 7146bc7..ea3496a 100644 --- a/src/components/NewPostWidget.tsx +++ b/src/components/NewPostWidget.tsx @@ -196,7 +196,15 @@ async function optimizeImageSize( dstCanvas.width = width dstCanvas.height = height - await pica.resize(srcCanvas, dstCanvas) + 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) From 334435cf78b3d072cd96af534a950fca78b5d8f1 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 28 May 2025 22:40:50 +0200 Subject: [PATCH 17/58] v1.18.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 99b9495..74427e6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "femto-webapp", "private": true, - "version": "1.17.0", + "version": "1.18.0", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", From 3b4f384582252dd926a5fb45af430649daa8004f Mon Sep 17 00:00:00 2001 From: john Date: Thu, 29 May 2025 13:21:37 +0200 Subject: [PATCH 18/58] add remember me checkbox --- src/app/api/schema.ts | 8 +++++--- src/app/auth/authService.ts | 8 ++++---- src/app/auth/pages/LoginPage.tsx | 16 +++++++++++++++- src/app/auth/pages/SignupPage.tsx | 15 ++++++++++++++- src/initApp.ts | 3 +++ src/types.d.ts | 2 ++ 6 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/app/api/schema.ts b/src/app/api/schema.ts index da176b8..b809ef0 100644 --- a/src/app/api/schema.ts +++ b/src/app/api/schema.ts @@ -188,8 +188,7 @@ export interface paths { requestBody: { content: { 'multipart/form-data': { - /** Format: binary */ - file?: string + file?: components['schemas']['IFormFile'] } } } @@ -502,6 +501,8 @@ export interface components { DeletePostReactionRequest: { emoji: string } + /** Format: binary */ + IFormFile: string ListSignupCodesResult: { signupCodes: components['schemas']['SignupCodeDto'][] } @@ -513,6 +514,7 @@ export interface components { LoginRequest: { username: string password: string + rememberMe: boolean | null } LoginResponse: { /** Format: uuid */ @@ -560,7 +562,7 @@ export interface components { username: string password: string signupCode: string - email: string | null + rememberMe: boolean | null } RegisterResponse: { /** Format: uuid */ diff --git a/src/app/auth/authService.ts b/src/app/auth/authService.ts index 51e56fa..64f1dcd 100644 --- a/src/app/auth/authService.ts +++ b/src/app/auth/authService.ts @@ -6,9 +6,9 @@ import { ApiClient } from '../api/client.ts' export class AuthService { constructor(private readonly client: ApiClient) {} - async login(username: string, password: string) { + async login(username: string, password: string, rememberMe: boolean = false) { const res = await this.client.POST('/auth/login', { - body: { username, password }, + body: { username, password, rememberMe }, credentials: 'include', }) @@ -19,9 +19,9 @@ export class AuthService { dispatchMessage('auth:logged-in', null) } - async signup(username: string, password: string, signupCode: string) { + 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 }, + body: { username, password, signupCode, email: null, rememberMe }, credentials: 'include', }) diff --git a/src/app/auth/pages/LoginPage.tsx b/src/app/auth/pages/LoginPage.tsx index 5163d71..16d1a3f 100644 --- a/src/app/auth/pages/LoginPage.tsx +++ b/src/app/auth/pages/LoginPage.tsx @@ -17,6 +17,7 @@ export default function LoginPage({ authService }: LoginPageProps) { 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) @@ -49,7 +50,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 { @@ -95,6 +96,19 @@ export default function LoginPage({ authService }: LoginPageProps) { /> +
+ setRememberMe(e.target.checked)} + className="h-4 w-4" + /> + +
+ diff --git a/src/app/auth/pages/SignupPage.tsx b/src/app/auth/pages/SignupPage.tsx index 19b2ab4..ab6b376 100644 --- a/src/app/auth/pages/SignupPage.tsx +++ b/src/app/auth/pages/SignupPage.tsx @@ -20,6 +20,7 @@ export default function SignupPage({ authService }: SignupPageProps) { 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) @@ -79,7 +80,7 @@ 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 @@ -116,6 +117,18 @@ export default function SignupPage({ authService }: SignupPageProps) { type="password" ref={passwordInputRef} /> +
+ setRememberMe(e.target.checked)} + className="h-4 w-4" + /> + +
- register instead? + {t('auth.login.register_instead')} {error} diff --git a/src/app/auth/pages/SignupPage.tsx b/src/app/auth/pages/SignupPage.tsx index ab6b376..ec3adba 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/useTranslations.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/i18n/en.json b/src/app/i18n/en.json new file mode 100644 index 0000000..6cc6cb5 --- /dev/null +++ b/src/app/i18n/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/i18n/translationKeys.ts b/src/app/i18n/translationKeys.ts new file mode 100644 index 0000000..de1c52a --- /dev/null +++ b/src/app/i18n/translationKeys.ts @@ -0,0 +1,24 @@ +export interface Translations { + '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 Translations diff --git a/src/app/i18n/useTranslations.ts b/src/app/i18n/useTranslations.ts new file mode 100644 index 0000000..c403be1 --- /dev/null +++ b/src/app/i18n/useTranslations.ts @@ -0,0 +1,13 @@ +import { TranslationKey, Translations } from './translationKeys.ts' +import en from './en.json' assert { type: 'json' } + +export function useTranslations() { + // TODO somehow handle other languages (reactively) + const texts = en as Translations + + function getText(key: K): Translations[K] { + return texts[key] ?? key + } + + return { t: getText } +} diff --git a/src/components/NewPostWidget.tsx b/src/components/NewPostWidget.tsx index ea3496a..d7b4828 100644 --- a/src/components/NewPostWidget.tsx +++ b/src/components/NewPostWidget.tsx @@ -3,6 +3,7 @@ import FancyTextEditor, { TextInputKeyDownEvent } from './inputs/FancyTextEditor import Button from './buttons/Button.tsx' import { openFileDialog } from '../utils/openFileDialog.ts' import makePica from 'pica' +import { useTranslations } from '../app/i18n/useTranslations.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
From bc5c2075f4464559d041cc2832689fc332323842 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 16 Jun 2025 21:28:24 +0200 Subject: [PATCH 28/58] some changes --- src/app/auth/components/AuthNavButtons.tsx | 2 +- src/app/auth/pages/LoginPage.tsx | 2 +- src/app/auth/pages/SignupPage.tsx | 2 +- src/app/i18n/translationKeys.ts | 24 ------------- src/app/i18n/translations.ts | 41 ++++++++++++++++++++++ src/app/i18n/{ => translations}/en.json | 0 src/app/i18n/useTranslations.ts | 13 ------- src/components/NewPostWidget.tsx | 2 +- 8 files changed, 45 insertions(+), 41 deletions(-) delete mode 100644 src/app/i18n/translationKeys.ts create mode 100644 src/app/i18n/translations.ts rename src/app/i18n/{ => translations}/en.json (100%) delete mode 100644 src/app/i18n/useTranslations.ts diff --git a/src/app/auth/components/AuthNavButtons.tsx b/src/app/auth/components/AuthNavButtons.tsx index f3161f4..c646888 100644 --- a/src/app/auth/components/AuthNavButtons.tsx +++ b/src/app/auth/components/AuthNavButtons.tsx @@ -1,7 +1,7 @@ import { useUser } from '../../user/user.ts' import NavButton from '../../../components/buttons/NavButton.tsx' import { useLocation } from 'react-router-dom' -import { useTranslations } from '../../i18n/useTranslations.ts' +import { useTranslations } from '../../i18n/translations.ts' export default function AuthNavButtons() { const { t } = useTranslations() diff --git a/src/app/auth/pages/LoginPage.tsx b/src/app/auth/pages/LoginPage.tsx index 215d8da..3838a88 100644 --- a/src/app/auth/pages/LoginPage.tsx +++ b/src/app/auth/pages/LoginPage.tsx @@ -8,7 +8,7 @@ import { useUser } 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/useTranslations.ts' +import { useTranslations } from '../../i18n/translations.ts' interface LoginPageProps { authService: AuthService diff --git a/src/app/auth/pages/SignupPage.tsx b/src/app/auth/pages/SignupPage.tsx index ec3adba..2e3a200 100644 --- a/src/app/auth/pages/SignupPage.tsx +++ b/src/app/auth/pages/SignupPage.tsx @@ -8,7 +8,7 @@ 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/useTranslations.ts' +import { useTranslations } from '../../i18n/translations.ts' const SignupCodeKey = 'signupCode' diff --git a/src/app/i18n/translationKeys.ts b/src/app/i18n/translationKeys.ts deleted file mode 100644 index de1c52a..0000000 --- a/src/app/i18n/translationKeys.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface Translations { - '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 Translations 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/en.json b/src/app/i18n/translations/en.json similarity index 100% rename from src/app/i18n/en.json rename to src/app/i18n/translations/en.json diff --git a/src/app/i18n/useTranslations.ts b/src/app/i18n/useTranslations.ts deleted file mode 100644 index c403be1..0000000 --- a/src/app/i18n/useTranslations.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { TranslationKey, Translations } from './translationKeys.ts' -import en from './en.json' assert { type: 'json' } - -export function useTranslations() { - // TODO somehow handle other languages (reactively) - const texts = en as Translations - - function getText(key: K): Translations[K] { - return texts[key] ?? key - } - - return { t: getText } -} diff --git a/src/components/NewPostWidget.tsx b/src/components/NewPostWidget.tsx index d7b4828..c943577 100644 --- a/src/components/NewPostWidget.tsx +++ b/src/components/NewPostWidget.tsx @@ -3,7 +3,7 @@ import FancyTextEditor, { TextInputKeyDownEvent } from './inputs/FancyTextEditor import Button from './buttons/Button.tsx' import { openFileDialog } from '../utils/openFileDialog.ts' import makePica from 'pica' -import { useTranslations } from '../app/i18n/useTranslations.ts' +import { useTranslations } from '../app/i18n/translations.ts' interface NewPostWidgetProps { onSubmit: ( From 1710d5d91d577470a62134da0e8228b2db6d2296 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 17 Jun 2025 09:44:30 +0200 Subject: [PATCH 29/58] remove email from authcodes --- .../subpages/SignupCodesManagementPage.tsx | 29 ++++--------------- src/app/auth/authService.ts | 11 +++++-- 2 files changed, 14 insertions(+), 26 deletions(-) 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" - /> -
-
@@ -120,7 +120,7 @@ export default function LoginPage({ authService }: LoginPageProps) { {t('auth.login.register_instead')} - {error} + {error} From 1fc52a16b964229ef85d6ddf8d67c40d6902d92a Mon Sep 17 00:00:00 2001 From: john Date: Tue, 17 Jun 2025 09:57:01 +0200 Subject: [PATCH 33/58] v1.24.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2e81fa1..5542282 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "femto-webapp", "private": true, - "version": "1.23.0", + "version": "1.24.0", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", From c0d08897c1908c519543413aca0f19c1f9a25398 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 17 Jun 2025 09:58:24 +0200 Subject: [PATCH 34/58] change api url --- scripts/bump-build-push.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/bump-build-push.sh b/scripts/bump-build-push.sh index ae679c6..45d88e4 100755 --- a/scripts/bump-build-push.sh +++ b/scripts/bump-build-push.sh @@ -8,8 +8,7 @@ USERNAME="johnbotris" IMAGE_NAME="femto-webapp" # Add this before the docker build line -export VITE_API_URL="https://femto-api.botris.social" - +export VITE_API_URL="https://api.botris.social" # Step 0: Ensure clean working directory if [[ -n $(git status --porcelain) ]]; then From 58a214444fbc17d38a3cc0f83de851d7417d5708 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 17 Jun 2025 09:59:03 +0200 Subject: [PATCH 35/58] v1.25.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5542282..f826503 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "femto-webapp", "private": true, - "version": "1.24.0", + "version": "1.25.0", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", From df0a145f3bb4f9d921243fe42eae0e6f10fd2338 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 17 Jun 2025 10:09:19 +0200 Subject: [PATCH 36/58] add deployment to build script --- scripts/bump-build-push.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scripts/bump-build-push.sh b/scripts/bump-build-push.sh index 45d88e4..42c2d57 100755 --- a/scripts/bump-build-push.sh +++ b/scripts/bump-build-push.sh @@ -2,6 +2,15 @@ set -euo pipefail +# Parse command line arguments +DEPLOY=false +for arg in "$@"; do + case "$arg" in + -d|--deploy) DEPLOY=true ;; + *) echo "Unknown option: $arg"; echo "Usage: $0 [-d|--deploy]"; exit 1 ;; + esac +done + # CONFIGURATION REGISTRY="docker.botris.dev" USERNAME="johnbotris" @@ -54,3 +63,10 @@ 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 From f21d20e08c44d0450bdd52cf38cbcff6e0d2eac6 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 17 Jun 2025 10:30:54 +0200 Subject: [PATCH 37/58] niceify build script --- package.json | 2 +- scripts/{bump-build-push.sh => publish.sh} | 35 ++++++++++++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) rename scripts/{bump-build-push.sh => publish.sh} (58%) diff --git a/package.json b/package.json index f826503..e89b605 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "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": { diff --git a/scripts/bump-build-push.sh b/scripts/publish.sh similarity index 58% rename from scripts/bump-build-push.sh rename to scripts/publish.sh index 42c2d57..fbe77c7 100755 --- a/scripts/bump-build-push.sh +++ b/scripts/publish.sh @@ -2,12 +2,41 @@ 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 ;; - *) echo "Unknown option: $arg"; echo "Usage: $0 [-d|--deploy]"; exit 1 ;; + --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 @@ -30,8 +59,8 @@ 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 +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" From f7771c7df36c5cadd4268d70e911ba8d709ca846 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 17 Jun 2025 10:47:46 +0200 Subject: [PATCH 38/58] tweak help text --- scripts/publish.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/publish.sh b/scripts/publish.sh index fbe77c7..68a75e4 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -11,8 +11,8 @@ show_help() { 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 " -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]" From 7fab3d0d9f40f44f965c79ac90b1722618b2ff00 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 17 Jun 2025 11:18:23 +0200 Subject: [PATCH 39/58] add source code link --- public/forgejo-logo-primary.svg | 40 +++++++++++++++++++++++++++++++++ src/components/NavBar.tsx | 23 ++++++++++++++++--- 2 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 public/forgejo-logo-primary.svg 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/src/components/NavBar.tsx b/src/components/NavBar.tsx index 02baea5..330c7aa 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -8,9 +8,26 @@ export default function NavBar({ children }: PropsWithChildren) { const user = useUser() const isSuperUser = user?.roles.includes(Role.SuperUser) return ( -