Skip to content

Commit

Permalink
Merge pull request #21 from mst-mkt/feat/upload-page_#7
Browse files Browse the repository at this point in the history
`/upload`の追加
  • Loading branch information
imoken777 authored Jun 21, 2024
2 parents 68d9fee + f60810d commit 0e9e41e
Show file tree
Hide file tree
Showing 18 changed files with 389 additions and 96 deletions.
4 changes: 3 additions & 1 deletion apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"@tanstack/router-devtools": "^1.35.1",
"hono": "^4.4.5",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@biomejs/biome": "^1.8.1",
Expand All @@ -26,6 +27,7 @@
"backend": "workspace:*",
"biome-config": "workspace:*",
"postcss": "^8.4.38",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5",
"vite": "^5.2.12"
Expand Down
11 changes: 11 additions & 0 deletions apps/frontend/src/components/common/LinkButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Link, type LinkProps } from '@tanstack/react-router'
import type { FC } from 'react'

export const LinkButton: FC<LinkProps> = ({ children, ...props }) => (
<Link
{...props}
className="flex w-full items-center justify-center rounded bg-[#6c8] px-4 py-2 font-bold text-white shadow transition-colors disabled:bg-[#8b9] hover:bg-[#5b7]"
>
{children}
</Link>
)
6 changes: 6 additions & 0 deletions apps/frontend/src/components/layout/Footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const Footer = () => (
<footer className="mx-auto flex w-full max-w-[600px] items-center gap-x-4 py-4">
<p className="font-bold text-lg">ChefCam.</p>
<div className="h-[1px] grow bg-gray-400 " />
</footer>
)
15 changes: 15 additions & 0 deletions apps/frontend/src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { IconBrandGithubFilled } from '@tabler/icons-react'

export const Header = () => (
<header className="sticky top-0 border-b bg-[#fff2] p-4 backdrop-blur-md">
<div className="mx-auto flex max-w-[600px] items-center gap-y-4 font-bold text-2xl ">
<h1 className="grow">ChefCam.</h1>
<a
href="https://github.com/mst-mkt/monthly-vol7"
className="w-fit rounded-md p-2 transition-colors hover:bg-gray-200"
>
<IconBrandGithubFilled size={20} />
</a>
</div>
</header>
)
65 changes: 65 additions & 0 deletions apps/frontend/src/components/upload/FoodSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { IconCircleCheck, IconCircleX } from '@tabler/icons-react'
import { type Dispatch, type FC, type SetStateAction, useMemo } from 'react'

type FoodSelectProps = {
foods: string[]
selectedFoods: string[]
setSelectedFoods: Dispatch<SetStateAction<string[]>>
}

const Food = ({
food,
selected,
onClick,
}: { food: string; selected: boolean; onClick: () => void }) => {
const Icon = useMemo(() => (selected ? IconCircleCheck : IconCircleX), [selected])
const color = useMemo(() => (selected ? '#6c8' : '#bbb'), [selected])
const backgroundColor = useMemo(() => (selected ? '#6c82' : 'transparent'), [selected])

return (
<div
className="flex cursor-pointer items-center gap-x-2 rounded-full border py-1 pr-4 pl-2 transition-colors"
style={{
borderColor: color,
backgroundColor,
}}
onClick={onClick}
onKeyDown={onClick}
>
<Icon size={20} color={color} />
<span>{food}</span>
</div>
)
}

export const FoodSelect: FC<FoodSelectProps> = ({ foods, selectedFoods, setSelectedFoods }) => {
const toggleFoodSelect = (food: string) => {
setSelectedFoods((prev) => {
if (prev.includes(food)) {
return prev.filter((f) => f !== food)
}
return [...prev, food]
})
}

return (
<div className="flex flex-wrap gap-x-1 gap-y-2">
{foods.map((food) => (
<Food
key={food}
food={food}
selected={selectedFoods.includes(food)}
onClick={() => toggleFoodSelect(food)}
/>
))}
{/* {foods.length !== 0 && (
<button
className="box-content flex h-[1lh] w-[1lh] items-center justify-center rounded-full border border-[#6c8] bg-[#6c8] px-1 py-1"
type="button"
>
<IconPlus size={20} color="#fff" className="aspect-square" />
</button>
)} */}
</div>
)
}
101 changes: 101 additions & 0 deletions apps/frontend/src/components/upload/ImagePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { IconLoader2, IconPhotoPlus, IconX } from '@tabler/icons-react'
import {
type ChangeEvent,
type Dispatch,
type FC,
type SetStateAction,
useMemo,
useState,
} from 'react'
import { apiClient } from '../../lib/apiClient'
import type { FoodImage } from '../../types/FoodTypes'

type ImagePickerProps = {
foodImages: FoodImage[]
setFoodImages: Dispatch<SetStateAction<FoodImage[]>>
setSelectedFoods: Dispatch<SetStateAction<string[]>>
}

export const ImagePicker: FC<ImagePickerProps> = ({
foodImages,
setFoodImages,
setSelectedFoods,
}) => {
const [isLoading, setIsLoading] = useState(false)
const fileUrls = useMemo(
() => foodImages.map((image) => URL.createObjectURL(image.file)),
[foodImages],
)

const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
setIsLoading(true)
const postImage = (file: File) => apiClient.upload.$post({ form: { file } })

const files = [...(e.target.files ?? [])]
const currentFiles = foodImages.map((image) => image.file)
const newFiles = files.filter((file) => !currentFiles.includes(file))

const responses = await Promise.all(newFiles.map(postImage))
const foodData = await Promise.all(responses.map((res) => res.json()))

const newFoodImages = foodData.map((data, i) => ({
file: newFiles[i],
foods: 'foods' in data ? data.foods : [],
}))
setFoodImages((prev) => [...prev, ...newFoodImages])
setSelectedFoods((prev) => [
...prev,
...newFoodImages.flatMap((image) => image.foods).filter((food) => !prev.includes(food)),
])

setIsLoading(false)
}

const handleFileRemove = (index: number) => {
setFoodImages((prev) => prev.filter((_, i) => i !== index))
}

return (
<div className="flex flex-col gap-y-4">
<label className="flex aspect-[2] w-full cursor-pointer flex-col items-center justify-center gap-y-2 rounded-2xl border-4 border-[#6c8] border-dotted p-16 transition-colors focus-within:bg-[#6c82] hover:bg-[#6c82]">
{isLoading ? (
<IconLoader2 size={64} color="#486" className="animate-spin" />
) : (
<>
<IconPhotoPlus size={64} color="#486" />
<input
type="file"
className="h-0 border-0 opacity-0"
onChange={handleFileChange}
accept="image/*"
multiple
/>
<p>
ファイルをドロップするか、
<span className="font-bold text-[#486]">ここをクリック</span>
</p>
</>
)}
</label>
{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-square w-20 shrink-0 overflow-hidden rounded-md bg-green-50 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-[#f00] p-1 text-white opacity-0 transition-opacity hover:bg-[#d00] group-hover:opacity-100"
onClick={() => handleFileRemove(i)}
>
<IconX size={16} color="#fff" />
</button>
</div>
))}
</div>
)}
</div>
)
}
7 changes: 7 additions & 0 deletions apps/frontend/src/lib/apiClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { HonoRoutes } from 'backend/src/index'
import { hc } from 'hono/client'
import { BACKEND_BASE_URL } from './envValue'

const requestUrl = new URL(BACKEND_BASE_URL).origin.toString()

export const apiClient = hc<HonoRoutes>(requestUrl)
5 changes: 5 additions & 0 deletions apps/frontend/src/lib/envValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { z } from 'zod'

const backendBaseUrlSchema = z.string().url()

export const BACKEND_BASE_URL = backendBaseUrlSchema.parse(import.meta.env.VITE_BACKEND_BASE_URL)
62 changes: 60 additions & 2 deletions apps/frontend/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,36 @@ import { createFileRoute } from '@tanstack/react-router'
// Import Routes

import { Route as rootRoute } from './routes/__root'
import { Route as AppImport } from './routes/_app'
import { Route as AppUploadImport } from './routes/_app/upload'
import { Route as AppRecipeImport } from './routes/_app/recipe'

// Create Virtual Routes

const IndexLazyImport = createFileRoute('/')()

// Create/Update Routes

const AppRoute = AppImport.update({
id: '/_app',
getParentRoute: () => rootRoute,
} as any)

const IndexLazyRoute = IndexLazyImport.update({
path: '/',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))

const AppUploadRoute = AppUploadImport.update({
path: '/upload',
getParentRoute: () => AppRoute,
} as any)

const AppRecipeRoute = AppRecipeImport.update({
path: '/recipe',
getParentRoute: () => AppRoute,
} as any)

// Populate the FileRoutesByPath interface

declare module '@tanstack/react-router' {
Expand All @@ -36,12 +54,36 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexLazyImport
parentRoute: typeof rootRoute
}
'/_app': {
id: '/_app'
path: ''
fullPath: ''
preLoaderRoute: typeof AppImport
parentRoute: typeof rootRoute
}
'/_app/recipe': {
id: '/_app/recipe'
path: '/recipe'
fullPath: '/recipe'
preLoaderRoute: typeof AppRecipeImport
parentRoute: typeof AppImport
}
'/_app/upload': {
id: '/_app/upload'
path: '/upload'
fullPath: '/upload'
preLoaderRoute: typeof AppUploadImport
parentRoute: typeof AppImport
}
}
}

// Create and export the route tree

export const routeTree = rootRoute.addChildren({ IndexLazyRoute })
export const routeTree = rootRoute.addChildren({
IndexLazyRoute,
AppRoute: AppRoute.addChildren({ AppRecipeRoute, AppUploadRoute }),
})

/* prettier-ignore-end */

Expand All @@ -51,11 +93,27 @@ export const routeTree = rootRoute.addChildren({ IndexLazyRoute })
"__root__": {
"filePath": "__root.tsx",
"children": [
"/"
"/",
"/_app"
]
},
"/": {
"filePath": "index.lazy.tsx"
},
"/_app": {
"filePath": "_app.tsx",
"children": [
"/_app/recipe",
"/_app/upload"
]
},
"/_app/recipe": {
"filePath": "_app/recipe.tsx",
"parent": "/_app"
},
"/_app/upload": {
"filePath": "_app/upload.tsx",
"parent": "/_app"
}
}
}
Expand Down
17 changes: 17 additions & 0 deletions apps/frontend/src/routes/_app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Outlet, createFileRoute } from '@tanstack/react-router'
import { Footer } from '../components/layout/Footer'
import { Header } from '../components/layout/Header'

export const Route = createFileRoute('/_app')({
component: () => <AppLayout />,
})

const AppLayout = () => (
<div className="flex min-h-svh flex-col gap-y-12">
<Header />
<main className="mx-auto flex w-full max-w-[600px] grow flex-col gap-y-8">
<Outlet />
</main>
<Footer />
</div>
)
25 changes: 25 additions & 0 deletions apps/frontend/src/routes/_app/recipe.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'

const recipeSearchSchema = z.object({
foods: z.array(z.string()).catch([]),
})

export const Route = createFileRoute('/_app/recipe')({
validateSearch: (search) => recipeSearchSchema.parse(search),
component: () => <Recipe />,
})

const Recipe = () => {
const { foods } = Route.useSearch()

return (
<div>
<ul>
{foods.map((food) => (
<li key={food}>{food}</li>
))}
</ul>
</div>
)
}
Loading

0 comments on commit 0e9e41e

Please sign in to comment.