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"