-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'main' of github.com:mst-mkt/chefcam
- Loading branch information
Showing
10 changed files
with
386 additions
and
50 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
), | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
</> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' }) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.