femto-webapp/src/components/NewPostWidget.tsx

233 lines
6.3 KiB
TypeScript

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: (
content: string,
media: { file: File; width: number; height: number }[],
isPublic: boolean,
) => void
isSubmitting?: boolean
}
interface Attachment {
id: string
file: File
objectUrl: string
width: number
height: number
}
export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPostWidgetProps) {
const [content, setContent] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([])
const [isPublic, setIsPublic] = useState(false)
const onContentInput = (value: string) => {
setContent(value)
}
async function onAddMediaClicked() {
const files = await openFileDialog('image/*', true)
if (files == null || files.length === 0) {
return
}
const newAttachments = await Promise.all(Array.from(files).map(createAttachment))
setAttachments((attachments) => [...attachments, ...newAttachments])
}
const handleSubmit = () => {
if (!content.trim() && attachments.length === 0) {
return
}
onSubmit(content, attachments, isPublic)
attachments.forEach(({ objectUrl }) => URL.revokeObjectURL(objectUrl))
setContent('')
setAttachments([])
}
const handleRemoveMedia = (id: string) => {
const attachment = attachments.find((attachment) => attachment.id === id)!
setAttachments(attachments.filter((attachment) => attachment.id !== id))
URL.revokeObjectURL(attachment.objectUrl)
}
const onInputKeyDown = (e: TextInputKeyDownEvent) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault()
handleSubmit()
}
}
return (
<div className="w-full p-4 border-b border-gray-200">
<FancyTextEditor
value={content}
onInput={onContentInput}
onKeyDown={onInputKeyDown}
className="mb-3"
placeholder="write something..."
/>
{attachments.length > 0 && (
<div className="flex flex-row flex-wrap gap-2 mb-3">
{attachments.map(({ objectUrl, id }) => (
<button
key={id}
className="relative cursor-pointer"
onClick={() => handleRemoveMedia(id)}
disabled={isSubmitting}
>
<img src={objectUrl} alt="" className="w-24 h-24 object-cover rounded-md" />
</button>
))}
</div>
)}
<div className="flex justify-between items-center pt-2">
<div className="flex items-center gap-2">
<Button secondary onClick={onAddMediaClicked}>
+ add media
</Button>
<label className="flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
disabled={isSubmitting}
className="form-checkbox h-4 w-4 text-blue-600"
/>
<span className="text-primary-500">public</span>
</label>
</div>
<Button
onClick={handleSubmit}
disabled={isSubmitting || (content.trim() === '' && attachments.length === 0)}
>
post
</Button>
</div>
</div>
)
}
async function createAttachment(file: File): Promise<Attachment> {
if (!file.type.startsWith('image/')) {
throw new Error('not an image')
}
file = await optimizeImageSize(file)
const objectUrl = URL.createObjectURL(file)
const { width, height } = await getImageFileDimensions(objectUrl)
return {
id: getRandomId(),
file,
objectUrl,
width,
height,
}
}
function getImageFileDimensions(objectURL: string): Promise<{ width: number; height: number }> {
return new Promise((resolve) => {
const img = document.createElement('img')
img.addEventListener('load', () => {
resolve({ width: img.width, height: img.height })
})
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 originalSize = file.size
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 -= 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 })
}
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('')}`
}