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
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
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,
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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<Attachment> {
|
|||
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<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 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({
|
||||
export default defineConfig(() => {
|
||||
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf-8'))
|
||||
|
||||
process.env.VITE_FEMTO_VERSION = packageJson.version
|
||||
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
}
|
||||
})
|
||||
|
|
35
yarn.lock
35
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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue