chore(form): compressed avatar
This commit is contained in:
parent
8df1b08fe5
commit
bac534f5d1
17 changed files with 766 additions and 644 deletions
14
package.json
14
package.json
|
@ -59,15 +59,15 @@
|
|||
"clsx": "^2.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"lucide-react": "^0.292.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"nanoid": "^5.0.3",
|
||||
"peerjs": "^1.5.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-nice-avatar": "^1.4.1",
|
||||
"react-use": "^17.4.0",
|
||||
"react-nice-avatar": "^1.5.0",
|
||||
"react-use": "^17.4.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remesh": "^4.2.0",
|
||||
|
@ -82,9 +82,9 @@
|
|||
"devDependencies": {
|
||||
"@commitlint/cli": "^18.4.3",
|
||||
"@commitlint/config-conventional": "^18.4.3",
|
||||
"@types/node": "^20.9.3",
|
||||
"@types/react": "^18.2.38",
|
||||
"@types/react-dom": "^18.2.16",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/react": "^18.2.39",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"cross-env": "^7.0.3",
|
||||
|
@ -108,7 +108,7 @@
|
|||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.3.2",
|
||||
"webext-bridge": "^6.0.1",
|
||||
"wxt": "^0.10.2"
|
||||
"wxt": "^0.10.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": "eslint --fix"
|
||||
|
|
1149
pnpm-lock.yaml
1149
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -1,3 +1,6 @@
|
|||
import { browser } from 'wxt/browser'
|
||||
import { defineBackground } from 'wxt/client'
|
||||
|
||||
export default defineBackground({
|
||||
// Set manifest options
|
||||
persistent: true,
|
||||
|
|
|
@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
|
|||
import { Remesh } from 'remesh'
|
||||
import { RemeshRoot } from 'remesh-react'
|
||||
import { RemeshLogger } from 'remesh-logger'
|
||||
import { defineContentScript, createContentScriptUi } from 'wxt/client'
|
||||
import App from './App'
|
||||
import { StorageIndexDBImpl } from '@/impl/Storage'
|
||||
import '@/assets/styles/tailwind.css'
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { type ReactNode, type FC, useState, type MouseEvent, useRef } from 'react'
|
||||
import { SettingsIcon, MoonIcon, SunIcon } from 'lucide-react'
|
||||
import { useClickAway } from 'react-use'
|
||||
import { browser } from 'wxt/browser'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { EVENTS } from '@/constants'
|
||||
|
||||
|
|
|
@ -1,41 +1,58 @@
|
|||
import { type FC, type ChangeEvent } from 'react'
|
||||
import { Globe2Icon } from 'lucide-react'
|
||||
import { type ChangeEvent } from 'react'
|
||||
import { ImagePlusIcon } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { cn, compressImage } from '@/utils'
|
||||
|
||||
export interface AvatarSelectProps {
|
||||
value?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
onload?: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null
|
||||
onerror?: ((this: FileReader, ev: ProgressEvent) => any) | null
|
||||
onChange?: (src: string) => void
|
||||
}
|
||||
|
||||
const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
|
||||
({ onChange, value, onerror, onload, className }, ref) => {
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
({ onChange, value, onerror, onload, className, disabled }, ref) => {
|
||||
const handleChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
|
||||
if (file) {
|
||||
// Compress to 10kb
|
||||
const blob = await compressImage(file, 10 * 1024)
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
onload?.call(reader, e)
|
||||
const src = e.target?.result as string
|
||||
onChange?.(src)
|
||||
console.log(file.size, blob.size)
|
||||
}
|
||||
reader.onerror = (e) => onerror?.call(reader, e)
|
||||
reader.readAsDataURL(file)
|
||||
reader.readAsDataURL(blob)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Label className="contents">
|
||||
<Avatar className={cn('h-20 w-20 cursor-pointer border-4 border-white', className)}>
|
||||
<Avatar
|
||||
tabIndex={disabled ? -1 : 1}
|
||||
className={cn(
|
||||
'group h-20 w-20 cursor-pointer border-4 border-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
{
|
||||
'cursor-not-allowed': disabled,
|
||||
'opacity-50': disabled
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<AvatarImage src={value} alt="avatar" />
|
||||
<AvatarFallback>
|
||||
<Globe2Icon size="100%" className="text-gray-400" />
|
||||
<ImagePlusIcon size={30} className="text-slate-400 group-hover:text-slate-500" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<input ref={ref} hidden type="file" accept="image/*" onChange={handleChange} />
|
||||
<input ref={ref} hidden disabled={disabled} type="file" accept="image/*" onChange={handleChange} />
|
||||
</Label>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { object, string, type Output, minLength, maxLength, toTrimmed, boolean } from 'valibot'
|
||||
import { object, string, type Output, minBytes, maxBytes, toTrimmed, boolean, notLength } from 'valibot'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { valibotResolver } from '@hookform/resolvers/valibot'
|
||||
|
||||
import AvatarSelect from './AvatarSelect'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
|
||||
|
@ -10,10 +11,10 @@ import { Switch } from '@/components/ui/Switch'
|
|||
const formSchema = object({
|
||||
username: string([
|
||||
toTrimmed(),
|
||||
minLength(1, 'Please enter your username.'),
|
||||
maxLength(8, 'Your username must have 8 characters or more.')
|
||||
minBytes(1, 'Please enter your username.'),
|
||||
maxBytes(20, 'Your username cannot exceed 20 bytes.')
|
||||
]),
|
||||
avatar: string(),
|
||||
avatar: string([notLength(0, 'Please select your avatar.')]),
|
||||
darkMode: boolean()
|
||||
})
|
||||
|
||||
|
@ -29,21 +30,19 @@ const ProfileForm = () => {
|
|||
|
||||
const handleSubmit = (data: Output<typeof formSchema>) => {
|
||||
console.log(data)
|
||||
console.log(data.avatar.length * 0.001)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="relative w-96 space-y-8 p-10">
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} autoComplete="off" className="relative w-96 space-y-8 p-10">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="avatar"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormItem className="absolute left-1/2 top-0 grid -translate-x-1/2 -translate-y-1/2 justify-items-center pb-8">
|
||||
<FormControl>
|
||||
<AvatarSelect
|
||||
className="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 shadow-lg"
|
||||
{...field}
|
||||
></AvatarSelect>
|
||||
<AvatarSelect className="shadow-lg" {...field}></AvatarSelect>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -69,15 +68,19 @@ const ProfileForm = () => {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>DarkMode</FormLabel>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange}></Switch>
|
||||
</FormControl>
|
||||
<FormDescription>This is your public display name.</FormDescription>
|
||||
<FormMessage />
|
||||
<div className="flex items-center gap-x-2">
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange}></Switch>
|
||||
</FormControl>
|
||||
<FormDescription>Enable dark mode</FormDescription>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit">Submit</Button>
|
||||
<Button className="w-full" type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import indexedDbDriver from 'unstorage/drivers/indexedb'
|
||||
import { webExtensionDriver, createStorage } from 'wxt/storage'
|
||||
import StorageExtern from '@/domain/externs/Storage'
|
||||
import { STORAGE_NAME } from '@/constants'
|
||||
|
||||
|
|
4
src/utils/chunk.ts
Normal file
4
src/utils/chunk.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
const chunk = <T = any>(array: T[], size: number) =>
|
||||
Array.from({ length: Math.ceil(array.length / size) }, (_v, i) => array.slice(i * size, i * size + size))
|
||||
|
||||
export default chunk
|
2
src/utils/clamp.ts
Normal file
2
src/utils/clamp.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
const clamp = (number: number, min: number, max: number) => Math.min(Math.max(number, min), max)
|
||||
export default clamp
|
6
src/utils/cn.ts
Normal file
6
src/utils/cn.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))
|
||||
|
||||
export default cn
|
62
src/utils/compressImage.ts
Normal file
62
src/utils/compressImage.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
const compress = async (
|
||||
imageBitmap: ImageBitmap,
|
||||
size: number,
|
||||
low: number,
|
||||
high: number,
|
||||
bestBlob: Blob
|
||||
): Promise<Blob> => {
|
||||
// Calculate the middle value of quality
|
||||
const mid = (low + high) / 2
|
||||
|
||||
// Calculate the width and height after scaling
|
||||
const width = imageBitmap.width * mid
|
||||
const height = imageBitmap.height * mid
|
||||
|
||||
const offscreenCanvas = new OffscreenCanvas(width, height)
|
||||
const offscreenContext = offscreenCanvas.getContext('2d')!
|
||||
|
||||
offscreenContext.drawImage(imageBitmap, 0, 0, width, height)
|
||||
|
||||
const outputBlob = await offscreenCanvas.convertToBlob({ type: 'image/jpeg', quality: mid })
|
||||
|
||||
// Calculate the current size based on the current quality
|
||||
const currentSize = outputBlob.size
|
||||
|
||||
// If the current size is close to the target size, update the bestResult
|
||||
if (Math.abs(currentSize - size) < Math.abs(bestBlob.size - size)) {
|
||||
bestBlob = outputBlob
|
||||
}
|
||||
|
||||
// If the current size is close to the target size or the range of low and high is too small, return the result
|
||||
if (Math.abs(currentSize - size) < 100 || high - low < 0.01) {
|
||||
return bestBlob
|
||||
}
|
||||
|
||||
// Adjust the range for recursion based on the current quality and size
|
||||
if (currentSize > size) {
|
||||
return await compress(imageBitmap, size, low, mid, bestBlob)
|
||||
} else {
|
||||
return await compress(imageBitmap, size, mid, high, bestBlob)
|
||||
}
|
||||
}
|
||||
|
||||
const compressImage = async (inputBlob: Blob, targetSize: number) => {
|
||||
// If the original size already meets the target size, return the original Blob
|
||||
if (inputBlob.size <= targetSize) {
|
||||
return inputBlob
|
||||
}
|
||||
|
||||
const imageBitmap = await createImageBitmap(inputBlob)
|
||||
|
||||
// Initialize the range of quality
|
||||
const low = 0
|
||||
const high = 1
|
||||
|
||||
// Initialize bestBlob with the original input Blob
|
||||
const bestBlob = inputBlob
|
||||
|
||||
// Call the recursive function
|
||||
return await compress(imageBitmap, targetSize, low, high, bestBlob)
|
||||
}
|
||||
|
||||
export default compressImage
|
5
src/utils/createElement.ts
Normal file
5
src/utils/createElement.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
const createElement = <T extends Element>(template: string) => {
|
||||
return new Range().createContextualFragment(template).firstElementChild as unknown as T
|
||||
}
|
||||
|
||||
export default createElement
|
34
src/utils/getWebSiteInfo.ts
Normal file
34
src/utils/getWebSiteInfo.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
export interface WebSiteInfo {
|
||||
host: string
|
||||
hostname: string
|
||||
href: string
|
||||
origin: string
|
||||
title: string
|
||||
icon: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const getWebSiteInfo = (): WebSiteInfo => {
|
||||
return {
|
||||
host: document.location.host,
|
||||
hostname: document.location.hostname,
|
||||
href: document.location.href,
|
||||
origin: document.location.origin,
|
||||
title:
|
||||
document.querySelector('meta[rel="og:title i"]')?.getAttribute('content') ??
|
||||
document.querySelector('meta[rel="og:title i"]')?.getAttribute('content') ??
|
||||
document.querySelector('meta[rel="og:site_name i"]')?.getAttribute('content') ??
|
||||
document.title,
|
||||
icon:
|
||||
document.querySelector('meta[property="og:image" i]')?.getAttribute('href') ??
|
||||
document.querySelector('link[rel="icon" i]')?.getAttribute('href') ??
|
||||
document.querySelector('link[rel="shortcut icon" i]')?.getAttribute('href') ??
|
||||
`${document.location.origin}/favicon.ico`,
|
||||
description:
|
||||
document.querySelector('meta[property="og:description i"]')?.getAttribute('content') ??
|
||||
document.querySelector('meta[name="description" i]')?.getAttribute('content') ??
|
||||
''
|
||||
}
|
||||
}
|
||||
|
||||
export default getWebSiteInfo
|
|
@ -1,49 +1,7 @@
|
|||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export interface WebSiteInfo {
|
||||
host: string
|
||||
hostname: string
|
||||
href: string
|
||||
origin: string
|
||||
title: string
|
||||
icon: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const cn = (...inputs: ClassValue[]) => {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export const createElement = <T extends Element>(template: string) => {
|
||||
return new Range().createContextualFragment(template).firstElementChild as unknown as T
|
||||
}
|
||||
|
||||
export const chunk = <T = any>(array: T[], size: number) =>
|
||||
Array.from({ length: Math.ceil(array.length / size) }, (_v, i) => array.slice(i * size, i * size + size))
|
||||
|
||||
export const clamp = (number: number, min: number, max: number) => Math.min(Math.max(number, min), max)
|
||||
export const isInRange = (number: number, min: number, max: number) => number >= min && number <= max
|
||||
|
||||
export const getWebSiteInfo = (): WebSiteInfo => {
|
||||
return {
|
||||
host: document.location.host,
|
||||
hostname: document.location.hostname,
|
||||
href: document.location.href,
|
||||
origin: document.location.origin,
|
||||
title:
|
||||
document.querySelector('meta[rel="og:title i"]')?.getAttribute('content') ??
|
||||
document.querySelector('meta[rel="og:title i"]')?.getAttribute('content') ??
|
||||
document.querySelector('meta[rel="og:site_name i"]')?.getAttribute('content') ??
|
||||
document.title,
|
||||
icon:
|
||||
document.querySelector('meta[property="og:image" i]')?.getAttribute('href') ??
|
||||
document.querySelector('link[rel="icon" i]')?.getAttribute('href') ??
|
||||
document.querySelector('link[rel="shortcut icon" i]')?.getAttribute('href') ??
|
||||
`${document.location.origin}/favicon.ico`,
|
||||
description:
|
||||
document.querySelector('meta[property="og:description i"]')?.getAttribute('content') ??
|
||||
document.querySelector('meta[name="description" i]')?.getAttribute('content') ??
|
||||
''
|
||||
}
|
||||
}
|
||||
export { default as cn } from './cn'
|
||||
export { default as isInRange } from './isInRange'
|
||||
export { default as clamp } from './clamp'
|
||||
export { default as createElement } from './createElement'
|
||||
export { default as getWebSiteInfo } from './getWebSiteInfo'
|
||||
export { default as chunk } from './chunk'
|
||||
export { default as compressImage } from './compressImage'
|
||||
|
|
3
src/utils/isInRange.ts
Normal file
3
src/utils/isInRange.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
const isInRange = (number: number, min: number, max: number) => number >= min && number <= max
|
||||
|
||||
export default isInRange
|
|
@ -7,6 +7,7 @@ const isDev = process.env.NODE_ENV === 'development'
|
|||
|
||||
export default defineConfig({
|
||||
srcDir: path.resolve('src'),
|
||||
imports: false,
|
||||
entrypointsDir: path.resolve('src', 'app'),
|
||||
runner: {
|
||||
startUrls: ['https://www.example.com/']
|
||||
|
|
Loading…
Reference in a new issue