Skip to content

Commit

Permalink
Merge branch 'main' of github.com:mst-mkt/chefcam
Browse files Browse the repository at this point in the history
  • Loading branch information
mst-mkt committed Sep 6, 2024
2 parents 0245d05 + 6627144 commit c32b9ff
Show file tree
Hide file tree
Showing 10 changed files with 386 additions and 50 deletions.
1 change: 1 addition & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@tanstack/router-devtools": "^1.52.5",
"hono": "^4.5.11",
"react": "^18.3.1",
"react-camera-pro": "^1.4.0",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.2",
"zod": "^3.23.8"
Expand Down
35 changes: 35 additions & 0 deletions apps/frontend/src/components/common/IconButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Icon } from '@tabler/icons-react'
import type { FC, JSX } from 'react'
import { twMerge } from 'tailwind-merge'

type IconButtonProps = {
icon: Icon
label?: string
iconPosition?: 'left' | 'right'
size?: number
iconClassName?: string
} & Omit<JSX.IntrinsicElements['button'], 'children'>

export const IconButton: FC<IconButtonProps> = ({
label,
size,
icon: Icon,
iconPosition,
iconClassName,
...props
}) => (
<button
type="button"
{...props}
className={twMerge(
iconPosition === 'right' && 'flex-row-reverse',
'flex w-fit items-center justify-center gap-x-2 rounded-md bg-background-50 p-2 transition-colors',
'hover:bg-background-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background',
'disabled:text-foreground-500 disabled:hover:bg-background-50',
props.className,
)}
>
<Icon size={size ?? 20} className={iconClassName} />
{label}
</button>
)
34 changes: 34 additions & 0 deletions apps/frontend/src/components/common/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { IconX } from '@tabler/icons-react'
import { type ReactNode, forwardRef } from 'react'
import { twJoin } from 'tailwind-merge'
import { IconButton } from './IconButton'

type ModalProps = {
close: () => void
title?: string
displayContent?: boolean
children?: ReactNode
}

export const Modal = forwardRef<HTMLDialogElement, ModalProps>(
({ close, title, children, displayContent = true }, ref) => (
<dialog
ref={ref}
className={twJoin(
'h-fit max-h-[calc(100svh-8svmin)] w-[92svmin] flex-col gap-y-4 rounded-lg bg-background p-4 shadow-md open:flex',
'backdrop:bg-black/50 backdrop:backdrop-blur-md',
)}
>
{displayContent && (
<>
<header className="flex items-center justify-between">
<h2 className="shrink grow font-bold">{title}</h2>
{/* biome-ignore lint/a11y/noPositiveTabindex: don't focus close button at fist time */}
<IconButton icon={IconX} onClick={close} className="bg-transparent" tabIndex={1} />
</header>
{children}
</>
)}
</dialog>
),
)
23 changes: 23 additions & 0 deletions apps/frontend/src/hooks/useModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { type FC, type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'
import { Modal as ModalComponent } from '../components/common/Modal'

export const useModal = () => {
const modalRef = useRef<HTMLDialogElement>(null)

const open = useCallback(() => modalRef.current?.showModal(), [])
const close = useCallback(() => modalRef.current?.close(), [])

const isOpen = useMemo(() => modalRef.current?.open, [])

useEffect(() => {
if (isOpen) close()
}, [isOpen, close])

const Modal: FC<{ children: ReactNode; title: string }> = ({ children, title }) => (
<ModalComponent close={close} ref={modalRef} title={title} displayContent={isOpen}>
{children}
</ModalComponent>
)

return { open, close, Modal, isOpen }
}
85 changes: 85 additions & 0 deletions apps/frontend/src/routes/_app/upload/.camera.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { IconCamera, IconCameraPlus, IconRotate, IconX } from '@tabler/icons-react'
import { type Dispatch, type FC, type SetStateAction, useCallback, useRef } from 'react'
import { Camera, type CameraType } from 'react-camera-pro'
import { twJoin } from 'tailwind-merge'
import { IconButton } from '../../../components/common/IconButton'
import { useModal } from '../../../hooks/useModal'
import { apiClient } from '../../../lib/apiClient'
import type { FoodImage } from '../../../types/FoodTypes'
import { imageDataToFile } from '../../../utils/imageDataToFile'

type CameraButtonProps = {
setIsLoading: Dispatch<SetStateAction<boolean>>
setFoodImages: Dispatch<SetStateAction<FoodImage[]>>
setSelectedFoods: Dispatch<SetStateAction<string[]>>
}

export const CameraButton: FC<CameraButtonProps> = ({
setIsLoading,
setFoodImages,
setSelectedFoods,
}) => {
const { Modal, open, close } = useModal()
const cameraRef = useRef<CameraType>(null)

const handlerClick = useCallback(async () => {
await navigator.mediaDevices.getUserMedia({ video: true })
open()
}, [open])

const uploadFiles = useCallback(
async (file: File) => {
setIsLoading(true)
const res = await apiClient.upload.$post({ form: { file } })
if (!res.ok) return setIsLoading(false)

const { foods: newFoods } = await res.json()

setFoodImages((prev) => [...prev, { file, foods: newFoods }])
setSelectedFoods((prev) => [...prev, ...newFoods.filter((food) => !prev.includes(food))])

setIsLoading(false)
close()
},
[close, setIsLoading, setFoodImages, setSelectedFoods],
)

const handleTakePhoto = useCallback(async () => {
const imageData = cameraRef.current?.takePhoto('imgData')
if (typeof imageData === 'string' || imageData === undefined) return

const imageFile = imageDataToFile(imageData)
uploadFiles(imageFile)
}, [uploadFiles])

return (
<>
<button
type="button"
onClick={handlerClick}
className={twJoin(
'flex aspect-1 w-20 items-center justify-center rounded-lg border-2 border-background-200 bg-primary text-accent transition-colors',
'hover:border-background-400 hover:bg-background-50',
'focus-visible:border-accent focus-visible:bg-background-50 focus-visible:outline-none',
)}
>
<IconCameraPlus size={24} />
</button>
<Modal title="カメラ">
<div className="relative overflow-hidden rounded-md">
<Camera ref={cameraRef} errorMessages={{}} aspectRatio={4 / 3} />
</div>
<div className="flex items-center justify-center gap-x-8">
<IconButton icon={IconCamera} onClick={handleTakePhoto} size={24} className="order-2" />
<IconButton
icon={IconRotate}
onClick={() => cameraRef.current?.switchCamera()}
size={24}
className="order-1"
/>
<IconButton icon={IconX} onClick={() => close()} size={24} className="order-3" />
</div>
</Modal>
</>
)
}
40 changes: 22 additions & 18 deletions apps/frontend/src/routes/_app/upload/.image-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type Dispatch, type FC, type SetStateAction, useMemo, useState } from '
import { FileInput } from '../../../components/common/FileInput'
import { apiClient } from '../../../lib/apiClient'
import type { FoodImage } from '../../../types/FoodTypes'
import { CameraButton } from './.camera'

type ImagePickerProps = {
foodImages: FoodImage[]
Expand Down Expand Up @@ -55,25 +56,28 @@ export const ImagePicker: FC<ImagePickerProps> = ({
return (
<div className="flex flex-col gap-y-4">
<FileInput onChange={uploadFiles} isLoading={isLoading} />
{foodImages.length !== 0 && (
<div className="scrollbar-thin scrollbar-thumb-rounded-full scrollbar-track-rounded-full scrollbar-thumb-gray-300 scrollbar-track-transparent flex gap-x-2 overflow-x-scroll rounded-md">
{fileUrls.map((url, i) => (
<div
className="group relative aspect-1 w-20 shrink-0 overflow-hidden rounded-md bg-accent shadow"
key={foodImages[i]?.file.name}
<div className="scrollbar-thin scrollbar-thumb-rounded-full scrollbar-track-rounded-full scrollbar-thumb-gray-300 scrollbar-track-transparent flex gap-x-2 overflow-x-scroll rounded-md">
{fileUrls.map((url, i) => (
<div
className="group relative aspect-1 w-20 shrink-0 overflow-hidden rounded-md bg-accent shadow"
key={foodImages[i]?.file.name}
>
<img src={url} alt="preview" className="block h-full w-full object-cover" />
<button
type="button"
className="absolute top-0 right-0 cursor-pointer rounded-bl-md bg-red-400 p-1 text-white opacity-0 transition-opacity hover:bg-red-600 group-hover:opacity-100"
onClick={() => handleFileRemove(i)}
>
<img src={url} alt="preview" className="block h-full w-full object-cover" />
<button
type="button"
className="absolute top-0 right-0 cursor-pointer rounded-bl-md bg-red-400 p-1 text-white opacity-0 transition-opacity hover:bg-red-600 group-hover:opacity-100"
onClick={() => handleFileRemove(i)}
>
<IconX size={16} color="#fff" />
</button>
</div>
))}
</div>
)}
<IconX size={16} color="#fff" />
</button>
</div>
))}
<CameraButton
setIsLoading={setIsLoading}
setFoodImages={setFoodImages}
setSelectedFoods={setSelectedFoods}
/>
</div>
</div>
)
}
1 change: 0 additions & 1 deletion apps/frontend/src/routes/_app/upload/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ const Upload = () => {
setFoodImages={setFoodImages}
setSelectedFoods={setSelectedFoods}
/>

<FoodSelect foods={foods} selectedFoods={selectedFoods} setSelectedFoods={setSelectedFoods} />
{selectedFoods.length > 5 && foods.length > 0 && (
<div className="flex gap-x-2">
Expand Down
23 changes: 23 additions & 0 deletions apps/frontend/src/utils/imageDataToFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export const imageDataToFile = (imageData: ImageData, fileName?: string): File => {
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')

if (context === null) {
throw new Error('Could not get 2d context from canvas')
}

canvas.width = imageData.width
canvas.height = imageData.height
context.putImageData(imageData, 0, 0)

const dataUrl = canvas.toDataURL('image/png')
const byteString = atob(dataUrl.split(',')[1] ?? '')
const arrayBuffer = new ArrayBuffer(byteString.length)
const uint8Array = new Uint8Array(arrayBuffer)

for (let i = 0; i < byteString.length; i++) {
uint8Array[i] = byteString.charCodeAt(i)
}

return new File([uint8Array], fileName ?? 'image.png', { type: 'image/png' })
}
2 changes: 1 addition & 1 deletion apps/frontend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true
},
"include": ["src"],
"include": ["src/**/*", "src/**/.*"],
"references": [{ "path": "./tsconfig.node.json" }]
}
Loading

0 comments on commit c32b9ff

Please sign in to comment.