chore(form): store user info
This commit is contained in:
parent
6645eda390
commit
f55a7f479d
10 changed files with 149 additions and 28 deletions
|
@ -5,7 +5,6 @@ import LikeButton from './LikeButton'
|
|||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
|
||||
|
||||
import { Markdown } from '@/components/ui/Markdown'
|
||||
import { type Message } from '@/types/global'
|
||||
|
||||
export interface MessageItemProps {
|
||||
data: Message
|
||||
|
|
|
@ -7,7 +7,6 @@ import { Button } from '@/components/ui/Button'
|
|||
import MessageInputDomain from '@/domain/MessageInput'
|
||||
import MessageListDomain from '@/domain/MessageList'
|
||||
import { MESSAGE_MAX_LENGTH } from '@/constants'
|
||||
import { type Message } from '@/types/global'
|
||||
|
||||
const Footer: FC = () => {
|
||||
const send = useRemeshSend()
|
||||
|
|
|
@ -9,6 +9,7 @@ export interface AvatarSelectProps {
|
|||
value?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
compressSize?: number
|
||||
onSuccess?: (blob: Blob) => void
|
||||
onWarning?: (error: Error) => void
|
||||
onError?: (error: Error) => void
|
||||
|
@ -16,7 +17,7 @@ export interface AvatarSelectProps {
|
|||
}
|
||||
|
||||
const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
|
||||
({ onChange, value, onError, onWarning, onSuccess, className, disabled }, ref) => {
|
||||
({ onChange, value, onError, onWarning, onSuccess, className, compressSize = 8 * 1024, disabled }, ref) => {
|
||||
const handleChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
|
@ -26,8 +27,11 @@ const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
|
|||
}
|
||||
|
||||
try {
|
||||
// Compress to 10kb
|
||||
const blob = await compressImage(file, 10 * 1024)
|
||||
/**
|
||||
* In chrome storage.sync, each key-value pair supports a maximum storage of 8kb
|
||||
* and all key-value pairs support a maximum storage of 100kb.
|
||||
*/
|
||||
const blob = await compressImage(file, compressSize)
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
onSuccess?.(blob)
|
||||
|
|
|
@ -3,35 +3,52 @@ import { useForm } from 'react-hook-form'
|
|||
import { valibotResolver } from '@hookform/resolvers/valibot'
|
||||
|
||||
import { toast } from 'sonner'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { useEffect } from 'react'
|
||||
import AvatarSelect from './AvatarSelect'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Switch } from '@/components/ui/Switch'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
|
||||
// In chrome storage.sync, each key-value pair supports a maximum storage of 8kb
|
||||
// Image is encoded as base64, and the size is increased by about 33%.
|
||||
const COMPRESS_SIZE = 8 * 1024 - 8 * 1024 * 0.33
|
||||
|
||||
const formSchema = object({
|
||||
username: string([
|
||||
id: string(),
|
||||
name: string([
|
||||
toTrimmed(),
|
||||
minBytes(1, 'Please enter your username.'),
|
||||
maxBytes(20, 'Your username cannot exceed 20 bytes.')
|
||||
]),
|
||||
avatar: string([notLength(0, 'Please select your avatar.')]),
|
||||
avatar: string([notLength(0, 'Please select your avatar.'), maxBytes(8 * 1024, 'Your avatar cannot exceed 8kb.')]),
|
||||
darkMode: boolean()
|
||||
})
|
||||
|
||||
const ProfileForm = () => {
|
||||
const send = useRemeshSend()
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
|
||||
const form = useForm({
|
||||
resolver: valibotResolver(formSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
defaultValues: userInfo ?? {
|
||||
id: nanoid(),
|
||||
name: '',
|
||||
avatar: '',
|
||||
darkMode: window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
})
|
||||
|
||||
const handleSubmit = (data: Output<typeof formSchema>) => {
|
||||
console.log(data)
|
||||
console.log(data.avatar.length * 0.001)
|
||||
useEffect(() => {
|
||||
userInfo && form.reset(userInfo)
|
||||
}, [userInfo, form])
|
||||
|
||||
const handleSubmit = (userInfo: Output<typeof formSchema>) => {
|
||||
send(userInfoDomain.command.SetUserInfoCommand(userInfo))
|
||||
toast.success('Saved successfully!')
|
||||
}
|
||||
|
||||
|
@ -53,6 +70,7 @@ const ProfileForm = () => {
|
|||
<FormItem className="absolute left-1/2 top-0 grid -translate-x-1/2 -translate-y-1/2 justify-items-center">
|
||||
<FormControl>
|
||||
<AvatarSelect
|
||||
compressSize={COMPRESS_SIZE}
|
||||
onError={handleError}
|
||||
onWarning={handleWarning}
|
||||
className="shadow-lg"
|
||||
|
@ -65,7 +83,7 @@ const ProfileForm = () => {
|
|||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import InputModule from './modules/Input'
|
||||
|
||||
export const MESSAGE_INPUT_STORAGE_KEY = 'MESSAGE_INPUT'
|
||||
|
||||
const MessageInputDomain = Remesh.domain({
|
||||
name: 'MessageInputDomain',
|
||||
impl: (domain) => {
|
||||
|
|
|
@ -3,13 +3,12 @@ import { ListModule } from 'remesh/modules/list'
|
|||
import { nanoid } from 'nanoid'
|
||||
import { from, map, tap, merge } from 'rxjs'
|
||||
import Storage from './externs/Storage'
|
||||
import { type Message } from '@/types/global'
|
||||
|
||||
const MessageListDomain = Remesh.domain({
|
||||
name: 'MessageListDomain',
|
||||
impl: (domain) => {
|
||||
const storage = domain.getExtern(Storage)
|
||||
const storageKey = `MESSAGE_LIST`
|
||||
const storageKey = `MESSAGE_LIST` as const
|
||||
|
||||
const MessageListModule = ListModule<Message>(domain, {
|
||||
name: 'MessageListModule',
|
||||
|
@ -93,10 +92,10 @@ const MessageListDomain = Remesh.domain({
|
|||
domain.effect({
|
||||
name: 'FormStateToStorageEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
const createItem$ = fromEvent(ChangeListEvent).pipe(
|
||||
const changeList$ = fromEvent(ChangeListEvent).pipe(
|
||||
tap(async (messages) => await storage.set<Message[]>(storageKey, messages))
|
||||
)
|
||||
return merge(createItem$).pipe(map(() => null))
|
||||
return merge(changeList$).pipe(map(() => null))
|
||||
}
|
||||
})
|
||||
|
||||
|
|
94
src/domain/UserInfo.ts
Normal file
94
src/domain/UserInfo.ts
Normal file
|
@ -0,0 +1,94 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { forkJoin, from, map, merge, tap } from 'rxjs'
|
||||
import Storage from './externs/Storage'
|
||||
|
||||
const UserInfoDomain = Remesh.domain({
|
||||
name: 'UserInfoDomain',
|
||||
impl: (domain) => {
|
||||
const storage = domain.getExtern(Storage)
|
||||
const storageKeys = {
|
||||
USER_INFO_ID: 'USER_INFO_ID',
|
||||
USER_INFO_NAME: 'USER_INFO_NAME',
|
||||
USER_INFO_AVATAR: 'USER_INFO_AVATAR',
|
||||
USER_INFO_DARK_MODE: 'USER_INFO_DARK_MODE'
|
||||
} as const
|
||||
|
||||
const UserInfoState = domain.state<UserInfo | null>({
|
||||
name: 'UserInfo.UserInfoState',
|
||||
default: null
|
||||
})
|
||||
|
||||
const UserInfoQuery = domain.query({
|
||||
name: 'UserInfo.UserInfoQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(UserInfoState())
|
||||
}
|
||||
})
|
||||
|
||||
const SetUserInfoCommand = domain.command({
|
||||
name: 'UserInfo.SetUserInfoCommand',
|
||||
impl: (_, userInfo: UserInfo | null) => {
|
||||
return [UserInfoState().new(userInfo), ChangeUserInfoEvent()]
|
||||
}
|
||||
})
|
||||
|
||||
const ChangeUserInfoEvent = domain.event({
|
||||
name: 'UserInfo.ChangeUserInfoEvent',
|
||||
impl: ({ get }) => {
|
||||
return get(UserInfoQuery())
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'FormStorageToStateEffect',
|
||||
impl: () => {
|
||||
return forkJoin({
|
||||
id: from(storage.get<UserInfo['id']>(storageKeys.USER_INFO_ID)),
|
||||
name: from(storage.get<UserInfo['name']>(storageKeys.USER_INFO_NAME)),
|
||||
avatar: from(storage.get<UserInfo['avatar']>(storageKeys.USER_INFO_AVATAR)),
|
||||
darkMode: from(storage.get<UserInfo['darkMode']>(storageKeys.USER_INFO_DARK_MODE))
|
||||
}).pipe(
|
||||
map((userInfo) => {
|
||||
if (userInfo.id && userInfo.name && userInfo.avatar && userInfo.darkMode) {
|
||||
return SetUserInfoCommand(userInfo as UserInfo)
|
||||
} else {
|
||||
return SetUserInfoCommand(null)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'FormStateToStorageEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
const changeUserInfo$ = fromEvent(ChangeUserInfoEvent).pipe(
|
||||
tap(async (userInfo) => {
|
||||
return await Promise.all([
|
||||
storage.set<UserInfo['id'] | null>(storageKeys.USER_INFO_ID, userInfo?.id ?? null),
|
||||
storage.set<UserInfo['name'] | null>(storageKeys.USER_INFO_NAME, userInfo?.name ?? null),
|
||||
storage.set<UserInfo['avatar'] | null>(storageKeys.USER_INFO_AVATAR, userInfo?.avatar ?? null),
|
||||
storage.set<UserInfo['darkMode'] | null>(storageKeys.USER_INFO_DARK_MODE, userInfo?.darkMode ?? null)
|
||||
])
|
||||
})
|
||||
)
|
||||
|
||||
return merge(changeUserInfo$).pipe(map(() => null))
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
query: {
|
||||
UserInfoQuery
|
||||
},
|
||||
command: {
|
||||
SetUserInfoCommand
|
||||
},
|
||||
event: {
|
||||
ChangeUserInfoEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default UserInfoDomain
|
9
src/types/global.d.ts
vendored
9
src/types/global.d.ts
vendored
|
@ -1,4 +1,4 @@
|
|||
export interface Message {
|
||||
declare interface Message {
|
||||
id: string
|
||||
userId: string
|
||||
body: string
|
||||
|
@ -12,3 +12,10 @@ export interface Message {
|
|||
hateUsers: string[]
|
||||
hateCount: number
|
||||
}
|
||||
|
||||
declare interface UserInfo {
|
||||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
darkMode: boolean
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const compress = async (
|
||||
imageBitmap: ImageBitmap,
|
||||
size: number,
|
||||
targetSize: number,
|
||||
low: number,
|
||||
high: number,
|
||||
bestBlob: Blob
|
||||
|
@ -22,21 +22,21 @@ const compress = async (
|
|||
// 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)) {
|
||||
// If the current size is close to the target size, update the bestBlob
|
||||
if (currentSize <= targetSize && Math.abs(currentSize - targetSize) < Math.abs(bestBlob.size - targetSize)) {
|
||||
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) {
|
||||
// If the current size is between -1024 ~ 0, return the result
|
||||
if ((currentSize - targetSize <= 0 && currentSize - targetSize >= -1024) || 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)
|
||||
if (currentSize > targetSize) {
|
||||
return await compress(imageBitmap, targetSize, low, mid, bestBlob)
|
||||
} else {
|
||||
return await compress(imageBitmap, size, mid, high, bestBlob)
|
||||
return await compress(imageBitmap, targetSize, mid, high, bestBlob)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,9 @@ export default defineConfig({
|
|||
runner: {
|
||||
startUrls: ['https://www.example.com/']
|
||||
},
|
||||
manifest: {
|
||||
permissions: ['storage']
|
||||
},
|
||||
vite: () => ({
|
||||
define: {
|
||||
__DEV__: isDev,
|
||||
|
|
Loading…
Reference in a new issue