├── .env.example
├── .eslintrc.json
├── .gitignore
├── LICENSE
├── README.md
├── app
├── favicon.ico
├── globals.css
├── layout.tsx
├── opengraph-image.png
└── page.tsx
├── components.json
├── components
├── card-grid-skeleton.tsx
├── deploy-button.tsx
├── error.tsx
├── image-card.tsx
├── image-search.tsx
├── loading-spinner.tsx
├── match-badge.tsx
├── no-images-found.tsx
├── search-box.tsx
├── suspended-image-search.tsx
└── ui
│ ├── alert.tsx
│ ├── badge.tsx
│ ├── button.tsx
│ ├── card.tsx
│ ├── input.tsx
│ └── skeleton.tsx
├── drizzle.config.ts
├── lib
├── ai
│ ├── 0-upload.ts
│ ├── 1-generate-metadata.ts
│ ├── 2-embed-and-save.ts
│ └── utils.ts
├── db
│ ├── api.ts
│ ├── index.ts
│ └── schema.ts
├── hooks
│ └── use-shared-transition.tsx
└── utils.ts
├── next.config.mjs
├── package.json
├── postcss.config.mjs
├── public
├── next.svg
└── vercel.svg
├── tailwind.config.ts
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 | OPENAI_API_KEY=
2 |
3 | BLOB_READ_WRITE_TOKEN=
4 |
5 | POSTGRES_URL=
6 | POSTGRES_PRISMA_URL=
7 | POSTGRES_URL_NO_SSL=
8 | POSTGRES_URL_NON_POOLING=
9 | POSTGRES_USER=
10 | POSTGRES_HOST=
11 | POSTGRES_PASSWORD=
12 | POSTGRES_DATABASE=
13 |
14 | KV_URL=
15 | KV_REST_API_URL=
16 | KV_REST_API_TOKEN=
17 | KV_REST_API_READ_ONLY_TOKEN=
18 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 | .env
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
39 | /images-to-index
40 | images-with-metadata.json
41 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2024 Vercel, Inc.
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 |
7 | http://www.apache.org/licenses/LICENSE-2.0
8 |
9 | Unless required by applicable law or agreed to in writing, software
10 | distributed under the License is distributed on an "AS IS" BASIS,
11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | See the License for the specific language governing permissions and
13 | limitations under the License.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | Semantic Image Search
4 |
5 |
6 |
7 | An open-source AI semantic image search app template built with Next.js, the Vercel AI SDK, OpenAI, Vercel Postgres, Vercel Blob and Vercel KV.
8 |
9 |
10 |
11 | Features ·
12 | Model Providers ·
13 | Deploy Your Own ·
14 | Running locally ·
15 | Authors
16 |
17 |
18 |
19 | ## Features
20 |
21 | - [Next.js](https://nextjs.org) App Router
22 | - React Server Components (RSCs), Suspense, and Server Actions
23 | - [Vercel AI SDK](https://sdk.vercel.ai/docs) for multimodal prompting, generating & embedding image metadata, and streaming images from Server to Client
24 | - Support for OpenAI (default), Gemini, Anthropic, Cohere, or custom AI chat models
25 | - [shadcn/ui](https://ui.shadcn.com)
26 | - Styling with [Tailwind CSS](https://tailwindcss.com)
27 | - [Radix UI](https://radix-ui.com) for headless component primitives
28 | - Query caching with [Vercel KV](https://vercel.com/storage/kv)
29 | - Embeddings powered by [Vercel Postgres](https://vercel.com/storage/kv), [pgvector](https://github.com/pgvector/pgvector-node#drizzle-orm), and [Drizzle ORM](https://orm.drizzle.team/)
30 | - File (image) storage with [Vercel Blob](https://vercel.com/storage/blob)
31 |
32 | ## Model Providers
33 |
34 | This template ships with OpenAI `GPT-4o` as the default. However, thanks to the [Vercel AI SDK](https://sdk.vercel.ai/docs), you can switch LLM providers to [Gemini](https://gemini.google.com/), [Anthropic](https://anthropic.com), [Cohere](https://cohere.com/), and [more](https://sdk.vercel.ai/providers/ai-sdk-providers) with just a few lines of code.
35 |
36 | ## Deploy Your Own
37 |
38 | You can deploy your own version of the Semantic Image Search App to Vercel with one click:
39 |
40 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fsemantic-image-search&env=OPENAI_API_KEY&envDescription=OpenAI%20key%20needed&envLink=https%3A%2F%2Fplatform.openai.com%2Fdocs%2Foverview)
41 |
42 | ## Setup
43 | ### Creating a KV Database Instance
44 |
45 | Follow the steps outlined in the [quick start guide](https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database) provided by Vercel. This guide will assist you in creating and configuring your KV database instance on Vercel, enabling your application to interact with it.
46 |
47 | Remember to update your environment variables (`KV_URL`, `KV_REST_API_URL`, `KV_REST_API_TOKEN`, `KV_REST_API_READ_ONLY_TOKEN`) in the `.env` file with the appropriate credentials provided during the KV database setup.
48 |
49 | ### Creating a Postgres Database Instance
50 |
51 | Follow the steps outlined in the [quick start guide](https://vercel.com/docs/storage/vercel-postgres/quickstart) provided by Vercel. This guide will assist you in creating and configuring your Postgres database instance on Vercel, enabling your application to interact with it.
52 |
53 | Once you have instantiated your Vercel Postgres instance, run the following code to enable `pgvector`:
54 | ```bash
55 | CREATE EXTENSION vector;
56 | ```
57 |
58 | Remember to update your environment variables (`POSTGRES_URL`, `POSTGRES_PRISMA_URL`, `POSTGRES_URL_NON_POOLING`, `POSTGRES_USER`, `POSTGRES_HOST`, `POSTGRES_PASSWORD`, `POSTGRES_DATABASE`) in the `.env` file with the appropriate credentials provided during the Postgres database setup.
59 |
60 | ### Creating a Blob Instance
61 |
62 | Follow the steps outlined in the [quick start guide](https://vercel.com/docs/storage/vercel-blob) provided by Vercel. This guide will assist you in creating and configuring your Blob instance on Vercel, enabling your application to interact with it.
63 |
64 | Remember to update your environment variable (`BLOB_READ_WRITE_TOKEN`) in the `.env` file with the appropriate credentials provided during the Blob setup.
65 |
66 |
67 | ## Running locally
68 |
69 | You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js Semantic Image Search. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/projects/environment-variables) for this, but a `.env` file is all that is necessary.
70 |
71 | > Note: You should not commit your `.env` file or it will expose secrets that will allow others to control access to your various OpenAI and authentication provider accounts.
72 |
73 | 1. Install Vercel CLI: `npm i -g vercel`
74 | 2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
75 | 3. Download your environment variables: `vercel env pull`
76 |
77 | ```bash
78 | pnpm install
79 | ```
80 |
81 | ## Add OpenAI API Key
82 | Be sure to add your OpenAI API Key to your `.env`.
83 |
84 | ## Database Setup
85 | To push your schema changes to your Vercel Postgres database, run the following command.
86 | ```bash
87 | pnpm run db:generate
88 | pnpm run db:push
89 | ```
90 |
91 | ## Prepare your Images (Indexing Step)
92 | To get your application ready for Semantic search, you will have to complete three steps.
93 | 1. Upload Images to storage
94 | 2. Send Images to a Large Language Model to generate metadata (title, description)
95 | 3. Iterate over each image, embed the metadata, and then save to the database
96 |
97 | ### Upload Images
98 | Put the images you want to upload in the `images-to-index` directory (.jpg format) at the root of your application. Run the following command.
99 | ```bash
100 | pnpm run upload
101 | ```
102 | This script will upload the images to your Vercel Blob store.
103 | Depending on how many photos you are uploading, this step could take a while.
104 |
105 | ### Generate Metadata
106 | Run the following command.
107 | ```bash
108 | pnpm run generate-metadata
109 | ```
110 | This script will generate metadata for each of the images you uploaded in the previous step.
111 | Depending on how many photos you are uploading, this step could take a while.
112 |
113 | ### Embed Metadata and Save to Database
114 | Run the following command.
115 | ```bash
116 | pnpm run embed-and-save
117 | ```
118 | Depending on how many photos you are uploading, this step could take a while. This script will embed the descriptions generated in the previous step and save them to your Vercel Postgres instance.
119 |
120 | ## Starting the Server
121 | Run the following command
122 | ```bash
123 | pnpm run dev
124 | ```
125 | Your app template should now be running on [localhost:3000](http://localhost:3000/).
126 |
127 | ## Authors
128 |
129 | This library is created by [Vercel](https://vercel.com) and [Next.js](https://nextjs.org) team members, with contributions from:
130 |
131 | - Jared Palmer ([@jaredpalmer](https://twitter.com/jaredpalmer)) - [Vercel](https://vercel.com)
132 | - Shu Ding ([@shuding\_](https://twitter.com/shuding_)) - [Vercel](https://vercel.com)
133 | - shadcn ([@shadcn](https://twitter.com/shadcn)) - [Vercel](https://vercel.com)
134 | - Lars Grammel ([@lgrammel](https://twitter.com/lgrammel)) - [Vercel](https://vercel.com)
135 | - Nico Albanese ([@nicoalbanese10](https://twitter.com/nicoalbanese10)) - [Vercel](https://vercel.com)
136 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vercel-labs/semantic-image-search/a88d9f1d56e79e4eba027e8e875b339966aa4b60/app/favicon.ico
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 |
10 | --card: 0 0% 100%;
11 | --card-foreground: 240 10% 3.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 240 10% 3.9%;
15 |
16 | --primary: 240 5.9% 10%;
17 | --primary-foreground: 0 0% 98%;
18 |
19 | --secondary: 240 4.8% 95.9%;
20 | --secondary-foreground: 240 5.9% 10%;
21 |
22 | --muted: 240 4.8% 95.9%;
23 | --muted-foreground: 240 3.8% 46.1%;
24 |
25 | --accent: 240 4.8% 95.9%;
26 | --accent-foreground: 240 5.9% 10%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 0 0% 98%;
30 |
31 | --border: 240 5.9% 90%;
32 | --input: 240 5.9% 90%;
33 | --ring: 240 10% 3.9%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 240 10% 3.9%;
40 | --foreground: 0 0% 98%;
41 |
42 | --card: 240 10% 3.9%;
43 | --card-foreground: 0 0% 98%;
44 |
45 | --popover: 240 10% 3.9%;
46 | --popover-foreground: 0 0% 98%;
47 |
48 | --primary: 0 0% 98%;
49 | --primary-foreground: 240 5.9% 10%;
50 |
51 | --secondary: 240 3.7% 15.9%;
52 | --secondary-foreground: 0 0% 98%;
53 |
54 | --muted: 240 3.7% 15.9%;
55 | --muted-foreground: 240 5% 64.9%;
56 |
57 | --accent: 240 3.7% 15.9%;
58 | --accent-foreground: 0 0% 98%;
59 |
60 | --destructive: 0 62.8% 30.6%;
61 | --destructive-foreground: 0 0% 98%;
62 |
63 | --border: 240 3.7% 15.9%;
64 | --input: 240 3.7% 15.9%;
65 | --ring: 240 4.9% 83.9%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 | body {
74 | @apply bg-background text-foreground;
75 | }
76 | }
77 |
78 | body {
79 | overflow-y: scroll;
80 | }
81 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { GeistSans } from "geist/font/sans";
3 | import "./globals.css";
4 | import { cn } from "@/lib/utils";
5 | import { TransitionProvider } from "@/lib/hooks/use-shared-transition";
6 |
7 | export const metadata: Metadata = {
8 | title: "Semantic Image Search Demo",
9 | description: "Semantic Image Search Demo built with the Vercel AI SDK.",
10 | metadataBase: process.env.VERCEL_URL
11 | ? new URL(`https://${process.env.VERCEL_URL}`)
12 | : undefined,
13 | };
14 |
15 | export default function RootLayout({
16 | children,
17 | }: Readonly<{
18 | children: React.ReactNode;
19 | }>) {
20 | return (
21 |
22 |
23 | {children}
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/app/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vercel-labs/semantic-image-search/a88d9f1d56e79e4eba027e8e875b339966aa4b60/app/opengraph-image.png
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { CardGridSkeleton } from "@/components/card-grid-skeleton";
2 | import { DeployButton } from "@/components/deploy-button";
3 | import { SearchBox } from "@/components/search-box";
4 | import { SuspendedImageSearch } from "@/components/suspended-image-search";
5 | import Link from "next/link";
6 | import { Suspense } from "react";
7 |
8 | export default async function Home({
9 | searchParams,
10 | }: {
11 | searchParams: Promise<{ q?: string }>;
12 | }) {
13 | const query = (await searchParams).q;
14 | return (
15 |
16 |
17 |
18 |
Semantic Search
19 |
20 |
21 |
22 |
23 |
24 | This demo showcases how to use the{" "}
25 |
30 | AI SDK
31 | {" "}
32 | to build semantic search applications. Try searching for something
33 | semantically, like "tasty food".
34 |
35 |
36 |
37 |
38 |
39 |
40 |
} key={query}>
41 |
42 |
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/components/card-grid-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@/components/ui/skeleton";
2 |
3 | export function CardGridSkeleton() {
4 | return (
5 |
6 | {new Array(16).fill("").map((_, i) => (
7 |
8 | ))}
9 |
10 | );
11 | }
12 |
13 | export function SkeletonCard() {
14 | return (
15 |
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/components/deploy-button.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import { buttonVariants } from "./ui/button";
3 |
4 | export const DeployButton = () => {
5 | return (
6 |
17 | );
18 | };
19 | function IconVercel({ className, ...props }: React.ComponentProps<"svg">) {
20 | return (
21 |
28 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/components/error.tsx:
--------------------------------------------------------------------------------
1 | import { AlertCircle } from "lucide-react";
2 |
3 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
4 |
5 | export function ErrorComponent({ error }: { error: Error }) {
6 | return (
7 |
8 |
9 | Error
10 |
11 | {error.message ?? "An error occured. Please try again later."}
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/components/image-card.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { DBImage } from "@/lib/db/schema";
4 | import Image from "next/image";
5 | import { MatchBadge } from "./match-badge";
6 | import { Card } from "./ui/card";
7 |
8 | export function ImageCard({
9 | image,
10 | similarity,
11 | }: {
12 | image: DBImage;
13 | similarity?: number;
14 | }) {
15 | return (
16 |
20 |
21 | View image
22 |
23 |
30 |
31 |
{image.title}
32 |
33 | {image.description}
34 |
35 |
36 | Metadata Generated by GPT-4o
37 |
38 |
39 | {similarity ? (
40 |
41 |
45 |
46 | ) : null}
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/components/image-search.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { ImageCard } from "./image-card";
3 | import { DBImage } from "@/lib/db/schema";
4 | import { NoImagesFound } from "./no-images-found";
5 | import { useSharedTransition } from "@/lib/hooks/use-shared-transition";
6 | import { CardGridSkeleton } from "./card-grid-skeleton";
7 |
8 | export const ImageSearch = ({
9 | images,
10 | query,
11 | }: {
12 | images: DBImage[];
13 | query?: string;
14 | }) => {
15 | const { isPending } = useSharedTransition();
16 |
17 | if (isPending) return ;
18 |
19 | if (images.length === 0) {
20 | return ;
21 | }
22 |
23 | return ;
24 | };
25 |
26 | const ImageGrid = ({ images }: { images: DBImage[] }) => {
27 | return (
28 |
29 | {images.map((image) => (
30 |
35 | ))}
36 |
37 | );
38 | };
39 |
--------------------------------------------------------------------------------
/components/loading-spinner.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * v0 by Vercel.
3 | * @see https://v0.dev/t/tm9EX5NO8KN
4 | * Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app
5 | */
6 | "use client";
7 |
8 | import { ImageStreamStatus } from "@/lib/utils";
9 |
10 | export function LoadingSpinner({ status }: { status?: ImageStreamStatus }) {
11 | return (
12 |
13 |
14 |
15 |
16 | Searching
17 | {status
18 | ? status?.regular
19 | ? " for direct matches"
20 | : " for semantic results"
21 | : ""}
22 | ...
23 |
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/components/match-badge.tsx:
--------------------------------------------------------------------------------
1 | import { Badge } from "@/components/ui/badge";
2 |
3 | export const MatchBadge = ({
4 | type,
5 | similarity,
6 | }: {
7 | type: "direct" | "semantic";
8 | similarity?: number;
9 | }) => {
10 | return (
11 |
12 | {type === "semantic" ? (
13 | <>
14 |
18 | Similarity: {similarity?.toFixed(3)}
19 |
20 |
24 | Semantic Match: {similarity?.toFixed(3)}
25 |
26 | >
27 | ) : (
28 | Direct Match
29 | )}
30 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/components/no-images-found.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * This code was generated by v0 by Vercel.
3 | * @see https://v0.dev/t/Q2jvX35BnWA
4 | * Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app
5 | */
6 |
7 | /** Add fonts into your Next.js project:
8 |
9 | import { Inter } from 'next/font/google'
10 |
11 | inter({
12 | subsets: ['latin'],
13 | display: 'swap',
14 | })
15 |
16 | To read more about using these font, please visit the Next.js documentation:
17 | - App Directory: https://nextjs.org/docs/app/building-your-application/optimizing/fonts
18 | - Pages Directory: https://nextjs.org/docs/pages/building-your-application/optimizing/fonts
19 | **/
20 | export function NoImagesFound({ query }: { query: string }) {
21 | return (
22 |
23 |
24 |
25 | No images found
26 |
27 |
28 | There were no results (semantic or direct) found for the query '
29 | {query}'.
30 |
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/components/search-box.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | /**
3 | * This code was generated by v0 by Vercel.
4 | * @see https://v0.dev/t/GHXhZDO4KL4
5 | * Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app
6 | */
7 |
8 | import { Input } from "@/components/ui/input";
9 | import { usePathname, useRouter, useSearchParams } from "next/navigation";
10 | import { SearchIcon } from "lucide-react";
11 | import { useRef, useState } from "react";
12 | import { Button } from "./ui/button";
13 | import { X } from "lucide-react";
14 | import { useDebouncedCallback } from "use-debounce";
15 | import { useSharedTransition } from "@/lib/hooks/use-shared-transition";
16 |
17 | export function SearchBox({
18 | query,
19 | disabled,
20 | }: {
21 | query?: string | null;
22 | disabled?: boolean;
23 | }) {
24 | const { startTransition } = useSharedTransition();
25 | const inputRef = useRef(null);
26 | const [isValid, setIsValid] = useState(true);
27 |
28 | const searchParams = useSearchParams();
29 | const q = searchParams.get("q")?.toString() ?? "";
30 | const pathname = usePathname();
31 |
32 | const router = useRouter();
33 |
34 | const handleSearch = useDebouncedCallback((term: string) => {
35 | const params = new URLSearchParams(searchParams);
36 |
37 | if (term) {
38 | params.set("q", term);
39 | } else {
40 | params.delete("q");
41 | }
42 | startTransition &&
43 | startTransition(() => {
44 | router.push(`${pathname}?${params.toString()}`);
45 | });
46 | }, 300);
47 |
48 | const resetQuery = () => {
49 | startTransition &&
50 | startTransition(() => {
51 | router.push("/");
52 | if (inputRef.current) {
53 | inputRef.current.value = "";
54 | inputRef.current?.focus();
55 | }
56 | });
57 | };
58 |
59 | return (
60 |
61 |
62 |
63 |
64 |
65 | {
71 | const newValue = e.target.value;
72 | if (newValue.length > 2) {
73 | setIsValid(true);
74 | handleSearch(newValue);
75 | } else if (newValue.length === 0) {
76 | handleSearch(newValue);
77 | setIsValid(false);
78 | } else {
79 | setIsValid(false);
80 | }
81 | }}
82 | className={
83 | "text-base w-full pl-12 pr-4 py-3 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:focus:ring-blue-500"
84 | }
85 | placeholder="Search..."
86 | />
87 | {q.length > 0 ? (
88 |
95 |
96 |
97 | ) : null}
98 |
99 |
100 | {!isValid ? (
101 |
102 | Query must be 3 characters or longer
103 |
104 | ) : (
105 |
106 | )}
107 |
108 |
109 | );
110 | }
111 |
--------------------------------------------------------------------------------
/components/suspended-image-search.tsx:
--------------------------------------------------------------------------------
1 | import { getImages } from "@/lib/db/api";
2 | import { ErrorComponent } from "./error";
3 | import { ImageSearch } from "./image-search";
4 |
5 | export const SuspendedImageSearch = async ({ query }: { query?: string }) => {
6 | const { images, error } = await getImages(query);
7 |
8 | if (error) {
9 | return ;
10 | }
11 |
12 | return ;
13 | };
14 |
--------------------------------------------------------------------------------
/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = "Alert"
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default: "border-transparent bg-primary text-primary-foreground ",
12 | secondary: "border-transparent bg-secondary text-secondary-foreground ",
13 | destructive:
14 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
15 | outline: "text-foreground",
16 | },
17 | },
18 | defaultVariants: {
19 | variant: "default",
20 | },
21 | },
22 | );
23 |
24 | export interface BadgeProps
25 | extends React.HTMLAttributes,
26 | VariantProps {}
27 |
28 | function Badge({ className, variant, ...props }: BadgeProps) {
29 | return (
30 |
31 | );
32 | }
33 |
34 | export { Badge, badgeVariants };
35 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ))
45 | CardTitle.displayName = "CardTitle"
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | CardDescription.displayName = "CardDescription"
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ))
65 | CardContent.displayName = "CardContent"
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ))
77 | CardFooter.displayName = "CardFooter"
78 |
79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
80 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "drizzle-kit";
2 |
3 | export default {
4 | schema: "./lib/db/schema.ts",
5 | out: "./lib/db/migrations",
6 | dialect: "postgresql",
7 | dbCredentials: {
8 | url: process.env.POSTGRES_URL!,
9 | },
10 | } satisfies Config;
11 |
--------------------------------------------------------------------------------
/lib/ai/0-upload.ts:
--------------------------------------------------------------------------------
1 | import dotenv from "dotenv";
2 | import { getJpgFiles } from "./utils";
3 | import { list, put } from "@vercel/blob";
4 | import fs from "fs";
5 |
6 | dotenv.config();
7 |
8 | async function main() {
9 | const basePath = "images-to-index";
10 | const files = await getJpgFiles(basePath);
11 | const { blobs } = await list();
12 |
13 | for (const file of files) {
14 | const exists = blobs.some((blob) => blob.pathname === file);
15 | if (exists) {
16 | console.log(`File (${file}) already exists in Blob store`);
17 | continue;
18 | }
19 | const filePath = basePath + "/" + file;
20 | const fileContent = fs.readFileSync(filePath);
21 |
22 | console.clear();
23 | console.log(
24 | `Uploading ${file} (${files.indexOf(file) + 1}/${files.length}) to Blob storage`,
25 | );
26 | try {
27 | await put(file, fileContent, { access: "public" });
28 | console.log(`Uploaded ${file}`);
29 | } catch (e) {
30 | console.error(e);
31 | }
32 | }
33 | console.log("All images uploaded!");
34 | process.exit(0);
35 | }
36 |
37 | main().catch(console.error);
38 |
--------------------------------------------------------------------------------
/lib/ai/1-generate-metadata.ts:
--------------------------------------------------------------------------------
1 | import { openai } from "@ai-sdk/openai";
2 | import { generateObject } from "ai";
3 | import dotenv from "dotenv";
4 | import { z } from "zod";
5 | import { ImageMetadata, writeAllMetadataToFile } from "./utils";
6 | import { list } from "@vercel/blob";
7 |
8 | dotenv.config();
9 |
10 | async function main() {
11 | const blobs = await list();
12 | const files = blobs.blobs.map((b) => b.url);
13 |
14 | console.log("files to process:\n", files);
15 |
16 | const images: ImageMetadata[] = [];
17 |
18 | for (const file of files) {
19 | console.clear();
20 | console.log(
21 | `Generating description for ${file} (${files.indexOf(file) + 1}/${files.length})`,
22 | );
23 | const result = await generateObject({
24 | model: openai("gpt-4o"),
25 | schema: z.object({
26 | image: z.object({
27 | title: z.string().describe("an artistic title for the image"),
28 | description: z
29 | .string()
30 | .describe("A one sentence description of the image"),
31 | }),
32 | }),
33 | maxTokens: 512,
34 | messages: [
35 | {
36 | role: "user",
37 | content: [
38 | { type: "text", text: "Describe the image in detail." },
39 | {
40 | type: "image",
41 | image: file,
42 | },
43 | ],
44 | },
45 | ],
46 | });
47 | images.push({ path: file, metadata: result.object.image });
48 | }
49 | await writeAllMetadataToFile(images, "images-with-metadata.json");
50 | console.log("All images processed!");
51 | }
52 |
53 | main().catch(console.error);
54 |
--------------------------------------------------------------------------------
/lib/ai/2-embed-and-save.ts:
--------------------------------------------------------------------------------
1 | import dotenv from "dotenv";
2 | import { embeddingModel, getMetadataFile } from "./utils";
3 | import { embed } from "ai";
4 | import { nanoid } from "nanoid";
5 | import { drizzle } from "drizzle-orm/postgres-js";
6 | import postgres from "postgres";
7 | import { DBImage, dbImageSchema, images } from "../db/schema";
8 |
9 | dotenv.config();
10 |
11 | export const client = postgres(process.env.POSTGRES_URL!);
12 | export const db = drizzle(client);
13 |
14 | const saveImage = async (image: DBImage) => {
15 | try {
16 | const safeImage = dbImageSchema.parse(image);
17 | const [savedImage] = await db.insert(images).values(safeImage);
18 | return savedImage;
19 | } catch (e) {
20 | console.error(e);
21 | }
22 | };
23 |
24 | async function main() {
25 | // read metadata json file
26 | const imagesWithMetadata = await getMetadataFile("images-with-metadata.json");
27 |
28 | // map over it and embed each .metadata key
29 | for (const image of imagesWithMetadata) {
30 | console.clear();
31 | console.log(
32 | `Generating embedding for ${image.path} (${imagesWithMetadata.indexOf(image) + 1}/${imagesWithMetadata.length})`,
33 | );
34 |
35 | // create embedding
36 | const { embedding } = await embed({
37 | model: embeddingModel,
38 | value: image.metadata.title + "\n" + image.metadata.description,
39 | });
40 | //
41 |
42 | console.log(
43 | `Saving ${image.path} to the DB (${imagesWithMetadata.indexOf(image) + 1}/${imagesWithMetadata.length})`,
44 | );
45 | // push to db
46 | try {
47 | await saveImage({
48 | title: image.metadata.title,
49 | description: image.metadata.description,
50 | id: nanoid(),
51 | path: image.path,
52 | embedding,
53 | });
54 | } catch (e) {
55 | console.error(e);
56 | }
57 | }
58 | console.log("Successfully embedded and saved all images!");
59 | process.exit(0);
60 | }
61 |
62 | main().catch(console.error);
63 |
--------------------------------------------------------------------------------
/lib/ai/utils.ts:
--------------------------------------------------------------------------------
1 | import { openai } from "@ai-sdk/openai";
2 | import { embed } from "ai";
3 | import fs from "fs";
4 | import path from "path";
5 |
6 | export type ImageMetadata = {
7 | path: string;
8 | metadata: {
9 | title: string;
10 | description: string;
11 | };
12 | };
13 |
14 | export const embeddingModel = openai.embedding("text-embedding-3-small");
15 |
16 | /**
17 | * Asynchronously gets all `.jpg` files in the specified directory.
18 | *
19 | * @param dir The directory to search within.
20 | * @returns A promise that resolves to an array of filenames.
21 | */
22 | export async function getJpgFiles(dir: string): Promise {
23 | try {
24 | const files = await fs.promises.readdir(dir);
25 | const jpgFiles = files.filter(
26 | (file) => path.extname(file).toLowerCase() === ".jpg",
27 | );
28 | return jpgFiles;
29 | } catch (error) {
30 | console.error("Error reading directory:", error);
31 | throw error; // Re-throw the error for further handling if necessary
32 | }
33 | }
34 |
35 | /**
36 | * Writes all metadata to a single JSON file.
37 | *
38 | * @param metadataArray An array of metadata objects.
39 | * @param outputPath The path including filename to the output JSON file.
40 | */
41 | export async function writeAllMetadataToFile(
42 | metadataArray: ImageMetadata[],
43 | outputPath: string,
44 | ) {
45 | try {
46 | await fs.promises.writeFile(
47 | outputPath,
48 | JSON.stringify(metadataArray, null, 2),
49 | );
50 | console.log(`All metadata written to ${outputPath}`);
51 | } catch (error) {
52 | console.error("Error writing metadata to file:", error);
53 | throw error;
54 | }
55 | }
56 |
57 | export async function getMetadataFile(path: string): Promise {
58 | try {
59 | const rawFile = await fs.promises.readFile(path, { encoding: "utf-8" });
60 | const file = JSON.parse(rawFile) as ImageMetadata[];
61 | return file;
62 | } catch (error) {
63 | console.error("Error reading file:", error);
64 | throw error; // Re-throw the error for further handling if necessary
65 | }
66 | }
67 |
68 | export const generateEmbedding = async (value: string): Promise => {
69 | const input = value.replaceAll("\n", " ");
70 | const { embedding } = await embed({
71 | model: embeddingModel,
72 | value: input,
73 | });
74 | return embedding;
75 | };
76 |
--------------------------------------------------------------------------------
/lib/db/api.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import {
4 | cosineDistance,
5 | desc,
6 | getTableColumns,
7 | gt,
8 | or,
9 | sql,
10 | } from "drizzle-orm";
11 | import { db } from ".";
12 | import { DBImage, images } from "./schema";
13 | import { generateEmbedding } from "../ai/utils";
14 | import { kv } from "@vercel/kv";
15 |
16 | const { embedding: _, ...rest } = getTableColumns(images);
17 | const imagesWithoutEmbedding = {
18 | ...rest,
19 | embedding: sql`ARRAY[]::integer[]`,
20 | };
21 |
22 | export const findSimilarContent = async (description: string) => {
23 | const embedding = await generateEmbedding(description);
24 | const similarity = sql`1 - (${cosineDistance(images.embedding, embedding)})`;
25 | const similarGuides = await db
26 | .select({ image: imagesWithoutEmbedding, similarity })
27 | .from(images)
28 | .where(gt(similarity, 0.28)) // experiment with this value based on your embedding model
29 | .orderBy((t) => desc(t.similarity))
30 | .limit(10);
31 |
32 | return similarGuides;
33 | };
34 |
35 | export const findImageByQuery = async (query: string) => {
36 | const result = await db
37 | .select({ image: imagesWithoutEmbedding, similarity: sql`1` })
38 | .from(images)
39 | .where(
40 | or(
41 | sql`title ILIKE ${"%" + query + "%"}`,
42 | sql`description ILIKE ${"%" + query + "%"}`,
43 | ),
44 | );
45 | return result;
46 | };
47 |
48 | function uniqueItemsByObject(items: DBImage[]): DBImage[] {
49 | const seenObjects = new Set();
50 | const uniqueItems: DBImage[] = [];
51 |
52 | for (const item of items) {
53 | if (!seenObjects.has(item.title)) {
54 | seenObjects.add(item.title);
55 | uniqueItems.push(item);
56 | }
57 | }
58 |
59 | return uniqueItems;
60 | }
61 |
62 | export const getImages = async (
63 | query?: string,
64 | ): Promise<{ images: DBImage[]; error?: Error }> => {
65 | try {
66 | const formattedQuery = query
67 | ? "q:" + query?.replaceAll(" ", "_")
68 | : "all_images";
69 |
70 | const cached = await kv.get(formattedQuery);
71 | if (cached) {
72 | return { images: cached };
73 | } else {
74 | if (query === undefined || query.length < 3) {
75 | const allImages = await db
76 | .select(imagesWithoutEmbedding)
77 | .from(images)
78 | .limit(20);
79 | await kv.set("all_images", JSON.stringify(allImages));
80 | return { images: allImages };
81 | } else {
82 | const directMatches = await findImageByQuery(query);
83 | const semanticMatches = await findSimilarContent(query);
84 | const allMatches = uniqueItemsByObject(
85 | [...directMatches, ...semanticMatches].map((image) => ({
86 | ...image.image,
87 | similarity: image.similarity,
88 | })),
89 | );
90 |
91 | await kv.set(formattedQuery, JSON.stringify(allMatches));
92 | return { images: allMatches };
93 | }
94 | }
95 | } catch (e) {
96 | if (e instanceof Error) return { error: e, images: [] };
97 | return {
98 | images: [],
99 | error: { message: "Error, please try again." } as Error,
100 | };
101 | }
102 | };
103 |
--------------------------------------------------------------------------------
/lib/db/index.ts:
--------------------------------------------------------------------------------
1 | import { sql } from "@vercel/postgres";
2 | import { drizzle } from "drizzle-orm/vercel-postgres";
3 |
4 | export const db = drizzle(sql);
5 |
--------------------------------------------------------------------------------
/lib/db/schema.ts:
--------------------------------------------------------------------------------
1 | import { varchar, index, pgTable, vector, text } from "drizzle-orm/pg-core";
2 | import { nanoid } from "nanoid";
3 | import { z } from "zod";
4 |
5 | export const images = pgTable(
6 | "images",
7 | {
8 | id: varchar("id", { length: 191 })
9 | .primaryKey()
10 | .$defaultFn(() => nanoid()),
11 | title: text("title").notNull(),
12 | description: text("description").notNull(),
13 | path: text("path").notNull(),
14 | embedding: vector("embedding", { dimensions: 1536 }).notNull(),
15 | },
16 | (table) => ({
17 | embeddingIndex: index("embeddingIndex").using(
18 | "hnsw",
19 | table.embedding.op("vector_cosine_ops"),
20 | ),
21 | }),
22 | );
23 |
24 | export const dbImageSchema = z.object({
25 | id: z.string(),
26 | embedding: z.array(z.number()),
27 | title: z.string(),
28 | path: z.string(),
29 | description: z.string(),
30 | similarity: z.number().optional(),
31 | });
32 |
33 | export type DBImage = z.infer;
34 |
--------------------------------------------------------------------------------
/lib/hooks/use-shared-transition.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { createContext, useContext, useTransition } from "react";
4 |
5 | const defaultValue: {
6 | isPending: boolean;
7 | startTransition?: React.TransitionStartFunction;
8 | } = { isPending: false };
9 | const TransitionContext = createContext(defaultValue);
10 |
11 | export const TransitionProvider = ({
12 | children,
13 | }: {
14 | children: React.ReactNode;
15 | }) => {
16 | const [isPending, startTransition] = useTransition();
17 |
18 | return (
19 |
20 | {children}
21 |
22 | );
23 | };
24 |
25 | export const useSharedTransition = () => useContext(TransitionContext);
26 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export type ImageStreamStatus = {
9 | regular: boolean;
10 | semantic: boolean;
11 | };
12 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [
5 | {
6 | protocol: 'https',
7 | hostname: 'bodo0tgbs4falkp7.public.blob.vercel-storage.com',
8 | port: '',
9 | },
10 | ],
11 | minimumCacheTTL: 60,
12 | },
13 | };
14 |
15 | export default nextConfig;
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "semantic-image-search",
3 | "version": "0.0.1",
4 | "scripts": {
5 | "dev": "next dev",
6 | "build": "next build",
7 | "start": "next start",
8 | "lint": "next lint",
9 | "db:generate": "drizzle-kit generate",
10 | "db:push": "drizzle-kit push",
11 | "db:studio": "drizzle-kit studio",
12 | "upload": "tsx lib/ai/0-upload.ts",
13 | "generate-metadata": "tsx lib/ai/1-generate-metadata.ts",
14 | "embed-and-save": "tsx lib/ai/2-embed-and-save.ts"
15 | },
16 | "dependencies": {
17 | "@ai-sdk/openai": "^1.2.1",
18 | "@radix-ui/react-slot": "^1.1.0",
19 | "@vercel/blob": "^0.27.0",
20 | "@vercel/kv": "^3.0.0",
21 | "@vercel/postgres": "^0.10.0",
22 | "ai": "^4.1.54",
23 | "class-variance-authority": "^0.7.1",
24 | "clsx": "^2.1.1",
25 | "drizzle-orm": "^0.38.1",
26 | "geist": "^1.3.1",
27 | "lucide-react": "^0.468.0",
28 | "nanoid": "^5.0.9",
29 | "next": "15.1.0",
30 | "postgres": "^3.4.5",
31 | "react": "^19.0.0",
32 | "react-dom": "^19.0.0",
33 | "sharp": "^0.33.5",
34 | "tailwind-merge": "^2.5.5",
35 | "tailwindcss-animate": "^1.0.7",
36 | "use-debounce": "^10.0.4",
37 | "zod": "^3.24.1"
38 | },
39 | "devDependencies": {
40 | "@types/lodash": "^4.17.13",
41 | "@types/node": "^22.10.2",
42 | "@types/react": "^19.0.1",
43 | "@types/react-dom": "^19.0.2",
44 | "dotenv": "^16.4.7",
45 | "drizzle-kit": "^0.30.0",
46 | "eslint": "^9.16.0",
47 | "eslint-config-next": "15.1.0",
48 | "postcss": "^8.4.49",
49 | "tailwindcss": "^3.4.16",
50 | "tsx": "^4.19.2",
51 | "typescript": "^5.7.2"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | './pages/**/*.{ts,tsx}',
7 | './components/**/*.{ts,tsx}',
8 | './app/**/*.{ts,tsx}',
9 | './src/**/*.{ts,tsx}',
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | } satisfies Config
79 |
80 | export default config
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------