shrink imagies
This commit is contained in:
parent
8457604da7
commit
a6022d31c6
9 changed files with 131 additions and 39 deletions
1
.env
1
.env
|
@ -1 +0,0 @@
|
||||||
VITE_API_URL=http://localhost:5181
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -22,3 +22,5 @@ dist-ssr
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
.env
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
"@tailwindcss/vite": "^4.1.5",
|
"@tailwindcss/vite": "^4.1.5",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"openapi-fetch": "^0.14.0",
|
"openapi-fetch": "^0.14.0",
|
||||||
|
"pica": "^9.0.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.6.0",
|
"react-router-dom": "^7.6.0",
|
||||||
|
@ -24,6 +25,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.22.0",
|
"@eslint/js": "^9.22.0",
|
||||||
"@types/node": "^22.15.19",
|
"@types/node": "^22.15.19",
|
||||||
|
"@types/pica": "^9.0.5",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
|
|
@ -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`
|
|
||||||
}
|
|
|
@ -42,7 +42,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
|
||||||
try {
|
try {
|
||||||
const media = await Promise.all(
|
const media = await Promise.all(
|
||||||
files.map(async ({ file, width, height }) => {
|
files.map(async ({ file, width, height }) => {
|
||||||
const { mediaId, url } = await mediaService.uploadFile(file)
|
const { mediaId, url } = await mediaService.uploadImage(file)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mediaId,
|
mediaId,
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { ApiClient } from '../api/client.ts'
|
||||||
|
|
||||||
export class MediaService {
|
export class MediaService {
|
||||||
constructor(private readonly client: ApiClient) {}
|
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()
|
const body = new FormData()
|
||||||
body.append('file', file)
|
body.append('file', file)
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||||
import FancyTextEditor, { TextInputKeyDownEvent } from './inputs/FancyTextEditor.tsx'
|
import FancyTextEditor, { TextInputKeyDownEvent } from './inputs/FancyTextEditor.tsx'
|
||||||
import Button from './buttons/Button.tsx'
|
import Button from './buttons/Button.tsx'
|
||||||
import { openFileDialog } from '../utils/openFileDialog.ts'
|
import { openFileDialog } from '../utils/openFileDialog.ts'
|
||||||
|
import makePica from 'pica'
|
||||||
|
|
||||||
interface NewPostWidgetProps {
|
interface NewPostWidgetProps {
|
||||||
onSubmit: (
|
onSubmit: (
|
||||||
|
@ -121,11 +122,13 @@ async function createAttachment(file: File): Promise<Attachment> {
|
||||||
throw new Error('not an image')
|
throw new Error('not an image')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
file = await optimizeImageSize(file)
|
||||||
|
|
||||||
const objectUrl = URL.createObjectURL(file)
|
const objectUrl = URL.createObjectURL(file)
|
||||||
const { width, height } = await getImageFileDimensions(objectUrl)
|
const { width, height } = await getImageFileDimensions(objectUrl)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: crypto.randomUUID(),
|
id: getRandomId(),
|
||||||
file,
|
file,
|
||||||
objectUrl,
|
objectUrl,
|
||||||
width,
|
width,
|
||||||
|
@ -144,3 +147,82 @@ function getImageFileDimensions(objectURL: string): Promise<{ width: number; hei
|
||||||
img.src = objectURL
|
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<File> {
|
||||||
|
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('')}`
|
||||||
|
}
|
||||||
|
|
|
@ -3,11 +3,13 @@ import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import fs from 'node:fs'
|
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/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig(() => {
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf-8'))
|
||||||
|
|
||||||
|
process.env.VITE_FEMTO_VERSION = packageJson.version
|
||||||
|
|
||||||
|
return {
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
35
yarn.lock
35
yarn.lock
|
@ -785,6 +785,11 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types "~6.21.0"
|
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":
|
"@types/react-dom@^19.0.4":
|
||||||
version "19.1.3"
|
version "19.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.3.tgz#3f0c60804441bf34d19f8dd0d44405c0c0e21bfa"
|
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"
|
resolved "https://registry.yarnpkg.com/globals/-/globals-16.0.0.tgz#3d7684652c5c4fbd086ec82f9448214da49382d8"
|
||||||
integrity sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A==
|
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:
|
gopd@^1.2.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
|
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"
|
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
||||||
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
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:
|
nanoid@^3.3.8:
|
||||||
version "3.3.11"
|
version "3.3.11"
|
||||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
|
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"
|
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314"
|
||||||
integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==
|
integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==
|
||||||
|
|
||||||
object-assign@^4:
|
object-assign@^4, object-assign@^4.1.1:
|
||||||
version "4.1.1"
|
version "4.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
||||||
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
|
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"
|
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz#73990cc29e57a3ff2a0d914095156df5db79e8b4"
|
||||||
integrity sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==
|
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:
|
picocolors@^1.1.1:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
|
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
|
||||||
|
@ -2495,6 +2523,11 @@ vite@^6.3.1:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents "~2.3.3"
|
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:
|
which@^2.0.1:
|
||||||
version "2.0.2"
|
version "2.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
|
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue