Skip to content

Commit

Permalink
Merge branch 'dev' into fix/search-notfound
Browse files Browse the repository at this point in the history
  • Loading branch information
edwintantawi committed Jul 26, 2023
2 parents e2e3dca + 91e9853 commit deecc96
Show file tree
Hide file tree
Showing 26 changed files with 1,531 additions and 277 deletions.
10 changes: 9 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,13 @@
]
}
]
}
},
"overrides": [
{
"files": ["*.ts", "*.tsx"],
"rules": {
"no-undef": "off"
}
}
]
}
12 changes: 12 additions & 0 deletions app/analyze/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use server';

import { spoonacular } from '~/lib/spoonacular';

export async function analyzeFoodImageAction(formData: FormData) {
const file = formData.get('file');
if (file === null) {
throw new Error('File not found');
}

return spoonacular.AnalyzeFoodImageByFile(file as File);
}
6 changes: 6 additions & 0 deletions app/analyze/constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const constant = {
metadata: {
title: 'Analyze Food Image',
description: 'Get to know your food by taking a photo of it',
},
};
21 changes: 21 additions & 0 deletions app/analyze/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as React from 'react';
import { Metadata } from 'next';

import { constant } from '~/app/analyze/constant';

export const metadata: Metadata = {
title: constant.metadata.title,
description: constant.metadata.description,
openGraph: {
title: constant.metadata.title,
description: constant.metadata.description,
},
};

interface AnalyzeLayoutProps {
children?: React.ReactNode;
}

export default function AnalyzeLayout({ children }: AnalyzeLayoutProps) {
return children;
}
207 changes: 207 additions & 0 deletions app/analyze/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
'use client';

import * as React from 'react';
import Link from 'next/link';

import { analyzeFoodImageAction } from '~/app/analyze/actions';
import { Header } from '~/components/header';
import { Icons } from '~/components/icons';
import { RecipeCard } from '~/components/recipe-card';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '~/components/ui/alert-dialog';
import { Button } from '~/components/ui/button';
import { ScrollArea, ScrollBar } from '~/components/ui/scroll-area';
import { Separator } from '~/components/ui/separator';
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '~/components/ui/sheet';
import { useCanvas } from '~/hooks/use-canvas';
import { useVideoStream } from '~/hooks/use-video-stream';
import { getRecipeImageById } from '~/lib/utils';
import { FoodAnalyzeResult } from '~/types/spoonacular/food-analyze';

export default function AnalyzePage() {
const [result, setResult] = React.useState<FoodAnalyzeResult | null>(null);
const [isPending, startTransition] = React.useTransition();

const [
videoElementRef,
{ error: videoStreamError, retry: retryVideoStream },
] = useVideoStream();

const [
canvasElementRef,
{ drawImageToCanvas, getImageFromCanvas, clearCanvas },
] = useCanvas();

const handleClickAnalyze = async () => {
if (canvasElementRef.current === null || videoElementRef.current === null) {
return;
}

drawImageToCanvas(
videoElementRef.current,
videoElementRef.current.videoWidth,
videoElementRef.current.videoHeight
);

const file = await getImageFromCanvas();

const formData = new FormData();
formData.append('file', file);

startTransition(async () => {
const result = await analyzeFoodImageAction(formData);
setResult(result);
});
};

const handleClose = () => {
setResult(null);
clearCanvas();
};

return (
<main className="container flex flex-1 flex-col justify-stretch">
<Header
title="Analyze Food Image"
subTitle="Get to know your food by taking a photo of it"
/>

<div className="relative flex-1">
<video
ref={videoElementRef}
className="absolute inset-0 h-full w-full flex-1 rounded-lg border bg-muted object-cover"
autoPlay
muted
/>
<canvas
ref={canvasElementRef}
className="absolute inset-0 h-full w-full rounded-lg border object-cover"
/>
<div className="absolute inset-x-0 bottom-0 flex flex-col items-center px-4 py-6">
<Button
className="gap-2 border border-white"
onClick={handleClickAnalyze}
disabled={isPending}
>
{isPending ? (
<Icons.Loader className="animate-spin" size={16} />
) : (
<Icons.Analyze size={16} />
)}
Analyze
</Button>
</div>

<Sheet open={result !== null}>
<SheetContent side="bottom" className="rounded-t-3xl">
<div className="container p-0">
<SheetHeader>
<SheetTitle>Analyze Result</SheetTitle>
<SheetDescription>
From the picture you took it looks like{' '}
<span className="font-bold">{result?.category.name}</span>{' '}
with{' '}
<span className="font-bold">
{result?.category.probability.toFixed(2)}
</span>{' '}
probability
</SheetDescription>
</SheetHeader>
<Separator className="my-4" />
<div className="mb-4">
<ul className="space-y-1 text-sm">
<li className="flex items-center justify-between">
<span className="font-bold">Calories</span>
<span className="text-muted-foreground">
{result?.nutrition.calories.value}{' '}
{result?.nutrition.calories.unit}
</span>
</li>
<li className="flex items-center justify-between">
<span className="font-bold">Carbohydrates</span>
<span className="text-muted-foreground">
{result?.nutrition.carbs.value}{' '}
{result?.nutrition.carbs.unit}
</span>
</li>
<li className="flex items-center justify-between">
<span className="font-bold">Fat</span>
<span className="text-muted-foreground">
{result?.nutrition.fat.value} {result?.nutrition.fat.unit}
</span>
</li>
<li className="flex items-center justify-between">
<span className="font-bold">Protein</span>
<span className="text-muted-foreground">
{result?.nutrition.protein.value}{' '}
{result?.nutrition.protein.unit}
</span>
</li>
</ul>
<Separator className="my-4" />
<ScrollArea>
<ul className="flex gap-1">
{result?.recipes.map((recipe) => (
<li key={recipe.id}>
<RecipeCard
openInNewTab
id={recipe.id}
title={recipe.title}
image={getRecipeImageById(recipe.id)}
/>
</li>
))}
</ul>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
<SheetFooter>
<SheetClose asChild>
<Button onClick={handleClose}>Ok and close</Button>
</SheetClose>
</SheetFooter>
</div>
</SheetContent>
</Sheet>

<AlertDialog open={videoStreamError !== null}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Failed to Access Device Camera!
</AlertDialogTitle>
<AlertDialogDescription>
Please make sure you have granted access to your device camera
and try again
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Link href="/">Back</Link>
</AlertDialogCancel>
<AlertDialogAction onClick={retryVideoStream}>
Try again
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</main>
);
}
11 changes: 4 additions & 7 deletions app/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,22 @@ import { Balancer } from 'react-wrap-balancer';

import { Icons } from '~/components/icons';
import { Button } from '~/components/ui/button';
import { getErrorDetail } from '~/lib/exceptions';

interface RootError {
error: Error & { digest?: string };
reset: () => void;
}

export default function Error({ error, reset }: RootError) {
const errorDetail = getErrorDetail(error.message);

export default function Error({ reset }: RootError) {
return (
<main className="container flex flex-1 items-center py-4">
<div className="flex w-full flex-col items-center rounded-lg border p-6 text-center">
<div className="relative flex w-full flex-col items-center rounded-lg border p-6 text-center">
<Icons.ServerError size={50} className="mb-2" />
<h1 className="mb-1 text-xl font-bold">
<Balancer>{errorDetail.title}</Balancer>
<Balancer>Something went wrong!</Balancer>
</h1>
<p className="mb-4 text-sm text-muted-foreground">
<Balancer>{errorDetail.description}</Balancer>
<Balancer>Please try again later</Balancer>
</p>

<Button className="w-full gap-2" onClick={reset}>
Expand Down
9 changes: 6 additions & 3 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Metadata } from 'next';

import { Providers } from '~/app/providers';
import { AppBar } from '~/components/app-bar';
import { Footer } from '~/components/footer';
import { siteConfig } from '~/configs/site';
Expand Down Expand Up @@ -47,9 +48,11 @@ export default function RootLayout({ children }: RootLayoutProps) {
fontMono.variable
)}
>
<AppBar />
{children}
<Footer />
<Providers>
<AppBar />
{children}
<Footer />
</Providers>
</body>
</html>
);
Expand Down
13 changes: 13 additions & 0 deletions app/providers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client';

import * as React from 'react';

import { TooltipProvider } from '~/components/ui/tooltip';

interface ProvidersProps {
children?: React.ReactNode;
}

export function Providers({ children }: ProvidersProps) {
return <TooltipProvider>{children}</TooltipProvider>;
}
File renamed without changes.
File renamed without changes.
20 changes: 12 additions & 8 deletions app/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { redirect } from 'next/navigation';
import { Balancer } from 'react-wrap-balancer';

import { constant } from '~/app/search/constant';
import { Header } from '~/components/header';
import { Icons } from '~/components/icons';
import { RecipeCard } from '~/components/recipe-card';
import { Button } from '~/components/ui/button';
Expand Down Expand Up @@ -77,14 +78,17 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {

return (
<main className="container flex-1">
<header className="my-4 rounded-md border bg-background px-3 py-2">
<h1 className="text-muted-foreground">
Search results for{' '}
<span className="font-bold text-foreground">
{searchParams.q ?? 'nothing there!'}
</span>
</h1>
</header>
<Header
title="Your Search Result"
subTitle={
<React.Fragment>
For keyword:{' '}
<span className="font-bold text-foreground">
{searchParams.q ?? 'nothing there!'}
</span>
</React.Fragment>
}
/>

<section className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{recipes.map((recipe) => {
Expand Down
Loading

0 comments on commit deecc96

Please sign in to comment.