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 { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
|
||||||
|
|
||||||
import { Markdown } from '@/components/ui/Markdown'
|
import { Markdown } from '@/components/ui/Markdown'
|
||||||
import { type Message } from '@/types/global'
|
|
||||||
|
|
||||||
export interface MessageItemProps {
|
export interface MessageItemProps {
|
||||||
data: Message
|
data: Message
|
||||||
|
|
|
@ -7,7 +7,6 @@ import { Button } from '@/components/ui/Button'
|
||||||
import MessageInputDomain from '@/domain/MessageInput'
|
import MessageInputDomain from '@/domain/MessageInput'
|
||||||
import MessageListDomain from '@/domain/MessageList'
|
import MessageListDomain from '@/domain/MessageList'
|
||||||
import { MESSAGE_MAX_LENGTH } from '@/constants'
|
import { MESSAGE_MAX_LENGTH } from '@/constants'
|
||||||
import { type Message } from '@/types/global'
|
|
||||||
|
|
||||||
const Footer: FC = () => {
|
const Footer: FC = () => {
|
||||||
const send = useRemeshSend()
|
const send = useRemeshSend()
|
||||||
|
|
|
@ -9,6 +9,7 @@ export interface AvatarSelectProps {
|
||||||
value?: string
|
value?: string
|
||||||
className?: string
|
className?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
compressSize?: number
|
||||||
onSuccess?: (blob: Blob) => void
|
onSuccess?: (blob: Blob) => void
|
||||||
onWarning?: (error: Error) => void
|
onWarning?: (error: Error) => void
|
||||||
onError?: (error: Error) => void
|
onError?: (error: Error) => void
|
||||||
|
@ -16,7 +17,7 @@ export interface AvatarSelectProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const AvatarSelect = React.forwardRef<HTMLInputElement, 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 handleChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (file) {
|
if (file) {
|
||||||
|
@ -26,8 +27,11 @@ const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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()
|
const reader = new FileReader()
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
onSuccess?.(blob)
|
onSuccess?.(blob)
|
||||||
|
|
|
@ -3,35 +3,52 @@ import { useForm } from 'react-hook-form'
|
||||||
import { valibotResolver } from '@hookform/resolvers/valibot'
|
import { valibotResolver } from '@hookform/resolvers/valibot'
|
||||||
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import { useEffect } from 'react'
|
||||||
import AvatarSelect from './AvatarSelect'
|
import AvatarSelect from './AvatarSelect'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Switch } from '@/components/ui/Switch'
|
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({
|
const formSchema = object({
|
||||||
username: string([
|
id: string(),
|
||||||
|
name: string([
|
||||||
toTrimmed(),
|
toTrimmed(),
|
||||||
minBytes(1, 'Please enter your username.'),
|
minBytes(1, 'Please enter your username.'),
|
||||||
maxBytes(20, 'Your username cannot exceed 20 bytes.')
|
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()
|
darkMode: boolean()
|
||||||
})
|
})
|
||||||
|
|
||||||
const ProfileForm = () => {
|
const ProfileForm = () => {
|
||||||
|
const send = useRemeshSend()
|
||||||
|
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||||
|
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: valibotResolver(formSchema),
|
resolver: valibotResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: userInfo ?? {
|
||||||
username: '',
|
id: nanoid(),
|
||||||
|
name: '',
|
||||||
avatar: '',
|
avatar: '',
|
||||||
darkMode: window.matchMedia('(prefers-color-scheme: dark)').matches
|
darkMode: window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSubmit = (data: Output<typeof formSchema>) => {
|
useEffect(() => {
|
||||||
console.log(data)
|
userInfo && form.reset(userInfo)
|
||||||
console.log(data.avatar.length * 0.001)
|
}, [userInfo, form])
|
||||||
|
|
||||||
|
const handleSubmit = (userInfo: Output<typeof formSchema>) => {
|
||||||
|
send(userInfoDomain.command.SetUserInfoCommand(userInfo))
|
||||||
toast.success('Saved successfully!')
|
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">
|
<FormItem className="absolute left-1/2 top-0 grid -translate-x-1/2 -translate-y-1/2 justify-items-center">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<AvatarSelect
|
<AvatarSelect
|
||||||
|
compressSize={COMPRESS_SIZE}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
onWarning={handleWarning}
|
onWarning={handleWarning}
|
||||||
className="shadow-lg"
|
className="shadow-lg"
|
||||||
|
@ -65,7 +83,7 @@ const ProfileForm = () => {
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="username"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Username</FormLabel>
|
<FormLabel>Username</FormLabel>
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
import { Remesh } from 'remesh'
|
import { Remesh } from 'remesh'
|
||||||
import InputModule from './modules/Input'
|
import InputModule from './modules/Input'
|
||||||
|
|
||||||
export const MESSAGE_INPUT_STORAGE_KEY = 'MESSAGE_INPUT'
|
|
||||||
|
|
||||||
const MessageInputDomain = Remesh.domain({
|
const MessageInputDomain = Remesh.domain({
|
||||||
name: 'MessageInputDomain',
|
name: 'MessageInputDomain',
|
||||||
impl: (domain) => {
|
impl: (domain) => {
|
||||||
|
|
|
@ -3,13 +3,12 @@ import { ListModule } from 'remesh/modules/list'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { from, map, tap, merge } from 'rxjs'
|
import { from, map, tap, merge } from 'rxjs'
|
||||||
import Storage from './externs/Storage'
|
import Storage from './externs/Storage'
|
||||||
import { type Message } from '@/types/global'
|
|
||||||
|
|
||||||
const MessageListDomain = Remesh.domain({
|
const MessageListDomain = Remesh.domain({
|
||||||
name: 'MessageListDomain',
|
name: 'MessageListDomain',
|
||||||
impl: (domain) => {
|
impl: (domain) => {
|
||||||
const storage = domain.getExtern(Storage)
|
const storage = domain.getExtern(Storage)
|
||||||
const storageKey = `MESSAGE_LIST`
|
const storageKey = `MESSAGE_LIST` as const
|
||||||
|
|
||||||
const MessageListModule = ListModule<Message>(domain, {
|
const MessageListModule = ListModule<Message>(domain, {
|
||||||
name: 'MessageListModule',
|
name: 'MessageListModule',
|
||||||
|
@ -93,10 +92,10 @@ const MessageListDomain = Remesh.domain({
|
||||||
domain.effect({
|
domain.effect({
|
||||||
name: 'FormStateToStorageEffect',
|
name: 'FormStateToStorageEffect',
|
||||||
impl: ({ fromEvent }) => {
|
impl: ({ fromEvent }) => {
|
||||||
const createItem$ = fromEvent(ChangeListEvent).pipe(
|
const changeList$ = fromEvent(ChangeListEvent).pipe(
|
||||||
tap(async (messages) => await storage.set<Message[]>(storageKey, messages))
|
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
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
body: string
|
body: string
|
||||||
|
@ -12,3 +12,10 @@ export interface Message {
|
||||||
hateUsers: string[]
|
hateUsers: string[]
|
||||||
hateCount: number
|
hateCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare interface UserInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
avatar: string
|
||||||
|
darkMode: boolean
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
const compress = async (
|
const compress = async (
|
||||||
imageBitmap: ImageBitmap,
|
imageBitmap: ImageBitmap,
|
||||||
size: number,
|
targetSize: number,
|
||||||
low: number,
|
low: number,
|
||||||
high: number,
|
high: number,
|
||||||
bestBlob: Blob
|
bestBlob: Blob
|
||||||
|
@ -22,21 +22,21 @@ const compress = async (
|
||||||
// Calculate the current size based on the current quality
|
// Calculate the current size based on the current quality
|
||||||
const currentSize = outputBlob.size
|
const currentSize = outputBlob.size
|
||||||
|
|
||||||
// If the current size is close to the target size, update the bestResult
|
// If the current size is close to the target size, update the bestBlob
|
||||||
if (Math.abs(currentSize - size) < Math.abs(bestBlob.size - size)) {
|
if (currentSize <= targetSize && Math.abs(currentSize - targetSize) < Math.abs(bestBlob.size - targetSize)) {
|
||||||
bestBlob = outputBlob
|
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 the current size is between -1024 ~ 0, return the result
|
||||||
if (Math.abs(currentSize - size) < 100 || high - low < 0.01) {
|
if ((currentSize - targetSize <= 0 && currentSize - targetSize >= -1024) || high - low < 0.01) {
|
||||||
return bestBlob
|
return bestBlob
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust the range for recursion based on the current quality and size
|
// Adjust the range for recursion based on the current quality and size
|
||||||
if (currentSize > size) {
|
if (currentSize > targetSize) {
|
||||||
return await compress(imageBitmap, size, low, mid, bestBlob)
|
return await compress(imageBitmap, targetSize, low, mid, bestBlob)
|
||||||
} else {
|
} 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: {
|
runner: {
|
||||||
startUrls: ['https://www.example.com/']
|
startUrls: ['https://www.example.com/']
|
||||||
},
|
},
|
||||||
|
manifest: {
|
||||||
|
permissions: ['storage']
|
||||||
|
},
|
||||||
vite: () => ({
|
vite: () => ({
|
||||||
define: {
|
define: {
|
||||||
__DEV__: isDev,
|
__DEV__: isDev,
|
||||||
|
|
Loading…
Reference in a new issue