├── src ├── middleware.ts ├── app │ ├── favicon.ico │ ├── fonts │ │ ├── GeistVF.woff │ │ └── GeistMonoVF.woff │ ├── api │ │ └── auth │ │ │ ├── [...nextauth] │ │ │ └── route.ts │ │ │ └── route.ts │ ├── page.tsx │ ├── db │ │ ├── db.ts │ │ └── schema.ts │ ├── lib │ │ ├── image-kit.ts │ │ └── auth-utils.ts │ ├── search │ │ ├── loaders.ts │ │ ├── page.tsx │ │ ├── results-list.tsx │ │ └── upload-meme-button.tsx │ ├── theme-provider.tsx │ ├── favorites │ │ ├── loaders.ts │ │ ├── page.tsx │ │ └── favorites-list.tsx │ ├── search-input.tsx │ ├── customize │ │ └── [fileId] │ │ │ ├── loaders.ts │ │ │ ├── page.tsx │ │ │ ├── favorite-button.tsx │ │ │ ├── actions.ts │ │ │ ├── text-overlay.tsx │ │ │ └── customize-panel.tsx │ ├── layout.tsx │ ├── providers.tsx │ ├── globals.css │ └── header.tsx ├── lib │ └── utils.ts ├── auth.ts └── components │ └── ui │ ├── label.tsx │ ├── input.tsx │ ├── slider.tsx │ ├── checkbox.tsx │ ├── tooltip.tsx │ ├── mode-toggle.tsx │ ├── button.tsx │ ├── card.tsx │ ├── dialog.tsx │ ├── sheet.tsx │ └── dropdown-menu.tsx ├── .eslintrc.json ├── postcss.config.mjs ├── .env.sample ├── next.config.mjs ├── drizzle.config.ts ├── components.json ├── .gitignore ├── tsconfig.json ├── LICENSE ├── README.md ├── package.json ├── tailwind.config.ts └── public └── empty.svg /src/middleware.ts: -------------------------------------------------------------------------------- 1 | export { auth as middleware } from "@/auth"; 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/meme-generator/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/meme-generator/HEAD/src/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /src/app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/meme-generator/HEAD/src/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from "@/auth"; // Referring to the auth.ts we just created 2 | export const { GET, POST } = handlers; 3 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { redirect } from "next/navigation"; 4 | 5 | export default function Home() { 6 | redirect("/search?q="); 7 | } 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_PUBLIC_KEY="" 2 | NEXT_PUBLIC_URL_ENDPOINT="" 3 | PRIVATE_KEY="" 4 | DRIZZLE_DATABASE_URL="" 5 | AUTH_SECRET="8ZuCSyjqf/RhLRnuj7gVCWllku1s003AVfCEsZGmj24=" 6 | AUTH_GOOGLE_ID="" 7 | AUTH_GOOGLE_SECRET="" -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | const nextConfig = { 2 | images: { 3 | remotePatterns: [ 4 | { 5 | protocol: "https", 6 | hostname: "ik.imagekit.io", 7 | port: "", 8 | }, 9 | ], 10 | }, 11 | }; 12 | 13 | export default nextConfig; 14 | -------------------------------------------------------------------------------- /src/app/db/db.ts: -------------------------------------------------------------------------------- 1 | import { neon } from "@neondatabase/serverless"; 2 | import { drizzle } from "drizzle-orm/neon-http"; 3 | import * as schema from "./schema"; 4 | const sql = neon(process.env.DRIZZLE_DATABASE_URL!); 5 | const db = drizzle(sql, { schema }); 6 | export { db }; 7 | -------------------------------------------------------------------------------- /src/app/lib/image-kit.ts: -------------------------------------------------------------------------------- 1 | import ImageKit from "imagekit"; 2 | const imagekit = new ImageKit({ 3 | publicKey: process.env.NEXT_PUBLIC_PUBLIC_KEY!, 4 | privateKey: process.env.PRIVATE_KEY!, 5 | urlEndpoint: process.env.NEXT_PUBLIC_URL_ENDPOINT!, 6 | }); 7 | export { imagekit }; 8 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | export default defineConfig({ 4 | schema: "./src/app/db/schema.ts", 5 | dialect: "postgresql", 6 | dbCredentials: { 7 | url: process.env.DRIZZLE_DATABASE_URL!, 8 | }, 9 | verbose: true, 10 | strict: true, 11 | }); 12 | -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import Google from "next-auth/providers/google"; 3 | import { DrizzleAdapter } from "@auth/drizzle-adapter"; 4 | import { db } from "./app/db/db"; 5 | 6 | export const { handlers, signIn, signOut, auth } = NextAuth({ 7 | adapter: DrizzleAdapter(db), 8 | providers: [Google], 9 | }); 10 | -------------------------------------------------------------------------------- /src/app/search/loaders.ts: -------------------------------------------------------------------------------- 1 | import { inArray } from "drizzle-orm"; 2 | import { db } from "../db/db"; 3 | import { favoriteCounts } from "../db/schema"; 4 | 5 | export async function getFavoriteCounts(fileIds: string[]) { 6 | const counts = await db 7 | .select() 8 | .from(favoriteCounts) 9 | .where(inArray(favoriteCounts.memeId, fileIds)); 10 | return counts; 11 | } 12 | -------------------------------------------------------------------------------- /src/app/lib/auth-utils.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/auth"; 2 | 3 | export async function assertAuthenticated() { 4 | const session = await auth(); 5 | 6 | if (!session) { 7 | throw new Error("Unauthorized"); 8 | } 9 | 10 | const userId = session.user?.id; 11 | 12 | if (!userId) { 13 | throw new Error("User ID not found"); 14 | } 15 | 16 | return userId; 17 | } 18 | -------------------------------------------------------------------------------- /src/app/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { type ThemeProviderProps } from "next-themes/dist/types"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/favorites/loaders.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { db } from "../db/db"; 3 | import { assertAuthenticated } from "../lib/auth-utils"; 4 | import { favorites } from "../db/schema"; 5 | 6 | export async function getFavorites() { 7 | const userId = await assertAuthenticated(); 8 | 9 | const allFavorites = await db.query.favorites.findMany({ 10 | where: eq(favorites.userId, userId), 11 | }); 12 | 13 | return allFavorites; 14 | } 15 | -------------------------------------------------------------------------------- /src/app/search-input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Input } from "@/components/ui/input"; 4 | import { useSearchParams } from "next/navigation"; 5 | 6 | export function SearchInput() { 7 | const queryString = useSearchParams(); 8 | 9 | return ( 10 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /src/app/customize/[fileId]/loaders.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/app/db/db"; 2 | import { favorites } from "@/app/db/schema"; 3 | import { assertAuthenticated } from "@/app/lib/auth-utils"; 4 | import { and, eq } from "drizzle-orm"; 5 | 6 | export async function getFavoriteMeme(fileId: string) { 7 | const userId = await assertAuthenticated(); 8 | 9 | const favorite = await db.query.favorites.findFirst({ 10 | where: and(eq(favorites.userId, userId), eq(favorites.memeId, fileId)), 11 | }); 12 | 13 | return !!favorite; 14 | } 15 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /src/app/api/auth/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import crypto from "crypto"; 3 | 4 | const privateKey = process.env.PRIVATE_KEY!; 5 | 6 | export async function GET(request: NextRequest) { 7 | const { searchParams } = new URL(request.url); 8 | const token = searchParams.get("token") || crypto.randomUUID(); 9 | const expire = 10 | searchParams.get("expire") || 11 | (Math.floor(Date.now() / 1000) + 2400).toString(); 12 | const privateAPIKey = privateKey; 13 | const signature = crypto 14 | .createHmac("sha1", privateAPIKey) 15 | .update(token + expire) 16 | .digest("hex"); 17 | 18 | return NextResponse.json({ 19 | token, 20 | expire, 21 | signature, 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/app/customize/[fileId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { imagekit } from "@/app/lib/image-kit"; 2 | import { unstable_noStore } from "next/cache"; 3 | import { CustomizePanel } from "./customize-panel"; 4 | import { getFavoriteMeme } from "./loaders"; 5 | import { auth } from "@/auth"; 6 | 7 | export default async function CustomizePage({ 8 | params, 9 | }: { 10 | params: { fileId: string }; 11 | }) { 12 | unstable_noStore(); 13 | 14 | const session = await auth(); 15 | 16 | const file = await imagekit.getFileDetails(params.fileId); 17 | const isFavorited = session ? await getFavoriteMeme(params.fileId) : false; 18 | 19 | return ( 20 |
21 | 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/app/search/page.tsx: -------------------------------------------------------------------------------- 1 | import { unstable_noStore } from "next/cache"; 2 | import { ResultsList } from "./results-list"; 3 | import { UploadMemeButton } from "./upload-meme-button"; 4 | import { imagekit } from "../lib/image-kit"; 5 | import { getFavoriteCounts } from "./loaders"; 6 | 7 | export default async function SearchPage({ 8 | searchParams, 9 | }: { 10 | searchParams: { q: string }; 11 | }) { 12 | unstable_noStore(); 13 | 14 | const files = await imagekit.listFiles({ 15 | tags: searchParams.q, 16 | }); 17 | 18 | const favoriteCounts = await getFavoriteCounts( 19 | files.map((file) => file.fileId) 20 | ); 21 | 22 | return ( 23 |
24 |
25 |

Search Results

26 | 27 |
28 | 29 | 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import localFont from "next/font/local"; 3 | import "./globals.css"; 4 | import { Header } from "./header"; 5 | import { Providers } from "./providers"; 6 | 7 | const geistSans = localFont({ 8 | src: "./fonts/GeistVF.woff", 9 | variable: "--font-geist-sans", 10 | weight: "100 900", 11 | }); 12 | const geistMono = localFont({ 13 | src: "./fonts/GeistMonoVF.woff", 14 | variable: "--font-geist-mono", 15 | weight: "100 900", 16 | }); 17 | 18 | export const metadata: Metadata = { 19 | title: "Meme Generator", 20 | description: "Generate cool memes with friends", 21 | }; 22 | 23 | export default function RootLayout({ 24 | children, 25 | }: Readonly<{ 26 | children: React.ReactNode; 27 | }>) { 28 | return ( 29 | 30 | 33 | 34 |
35 | {children} 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Web Dev Cody 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/ui/slider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SliderPrimitive from "@radix-ui/react-slider" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Slider = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 21 | 22 | 23 | 24 | 25 | )) 26 | Slider.displayName = SliderPrimitive.Root.displayName 27 | 28 | export { Slider } 29 | -------------------------------------------------------------------------------- /src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { CheckIcon } from "@radix-ui/react-icons" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )) 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 27 | )) 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 31 | -------------------------------------------------------------------------------- /src/app/favorites/page.tsx: -------------------------------------------------------------------------------- 1 | import { unstable_noStore } from "next/cache"; 2 | import { getFavorites } from "./loaders"; 3 | import { FavoritesList } from "./favorites-list"; 4 | import Image from "next/image"; 5 | import { Button } from "@/components/ui/button"; 6 | import Link from "next/link"; 7 | import { Card } from "@/components/ui/card"; 8 | 9 | export default async function FavoritesPage() { 10 | unstable_noStore(); 11 | 12 | const favorites = await getFavorites(); 13 | 14 | return ( 15 |
16 |
17 |

Favorites

18 |
19 | 20 | {favorites.length === 0 && ( 21 | 22 | an empty state image 28 |

You have not favorited any memes!

29 | 32 |
33 | )} 34 | 35 | {favorites.length > 0 && } 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/app/customize/[fileId]/favorite-button.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Heart } from "lucide-react"; 3 | import { 4 | Tooltip, 5 | TooltipContent, 6 | TooltipProvider, 7 | TooltipTrigger, 8 | } from "@/components/ui/tooltip"; 9 | import { toggleFavoriteMemeAction } from "./actions"; 10 | import { HeartFilledIcon } from "@radix-ui/react-icons"; 11 | 12 | export function FavoriteButton({ 13 | isFavorited, 14 | fileId, 15 | filePath, 16 | pathToRevalidate, 17 | }: { 18 | isFavorited: boolean; 19 | fileId: string; 20 | filePath: string; 21 | pathToRevalidate: string; 22 | }) { 23 | return ( 24 | 25 | 26 | 27 |
35 | 38 |
39 |
40 | 41 |

{isFavorited ? "Unfavorite Meme" : "Favorite Meme"}

42 |
43 |
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Getting Started 2 | 3 | 1. `npm i` 4 | 2. setup env variables (see below) 5 | 3. `npm run dev` 6 | 7 | ## Environment Variables 8 | 9 | In order to run this project, you'll need to create a `.env` file based on the `.env.sample` file. 10 | 11 | ### ImageKit Setup 12 | 13 | 1. create an imagekit account 14 | 2. navigate to https://imagekit.io/dashboard/developer/api-keys 15 | 3. copy the necessary keys and put into .env 16 | 17 | - NEXT_PUBLIC_PUBLIC_KEY 18 | - NEXT_PUBLIC_URL_ENDPOINT 19 | - PRIVATE_KEY 20 | 21 | ### Neon Setup 22 | 23 | 1. create a neon account and setup a neon database 24 | 2. copy the connection string and paste into the .env file for DRIZZLE_DATABASE_URL 25 | 26 | ### Google Auth 27 | 28 | By default, this starter only comes with the google provider which you'll need to setup: 29 | 30 | 1. https://console.cloud.google.com/apis/credentials 31 | 2. create a new project 32 | 3. setup oauth consent screen 33 | 4. create credentials - oauth client id 34 | 5. for authorized javascript origins 35 | 36 | - http://localhost:3000 37 | - https://your-domain.com 38 | 39 | 6. Authorized redirect URIs 40 | 41 | - http://localhost:3000/api/auth/callback/google 42 | - https://your-domain.com/api/auth/callback/google 43 | 44 | 7. Set your google id and secret inside of .env 45 | 46 | - **AUTH_GOOGLE_ID** 47 | - **AUTH_GOOGLE_SECRET** 48 | -------------------------------------------------------------------------------- /src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ImageKitProvider } from "imagekitio-next"; 4 | import { ThemeProvider } from "./theme-provider"; 5 | 6 | const publicKey = process.env.NEXT_PUBLIC_PUBLIC_KEY; 7 | const authenticator = async () => { 8 | try { 9 | const response = await fetch("http://localhost:3000/api/auth"); 10 | 11 | if (!response.ok) { 12 | const errorText = await response.text(); 13 | throw new Error( 14 | `Request failed with status ${response.status}: ${errorText}` 15 | ); 16 | } 17 | 18 | const data = await response.json(); 19 | const { signature, expire, token } = data; 20 | return { signature, expire, token }; 21 | } catch (err) { 22 | const error = err as Error; 23 | throw new Error(`Authentication request failed: ${error.message}`); 24 | } 25 | }; 26 | 27 | export const urlEndpoint = process.env.NEXT_PUBLIC_URL_ENDPOINT; 28 | 29 | export function Providers({ children }: { children: React.ReactNode }) { 30 | return ( 31 | 37 | 42 | {children} 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/components/ui/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { Moon, Sun } from "lucide-react"; 5 | import { useTheme } from "next-themes"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu"; 14 | 15 | export function ModeToggle() { 16 | const { setTheme } = useTheme(); 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | Light 30 | 31 | setTheme("dark")}> 32 | Dark 33 | 34 | setTheme("system")}> 35 | System 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/app/favorites/favorites-list.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { IKImage } from "imagekitio-next"; 4 | import { Card, CardContent, CardFooter } from "@/components/ui/card"; 5 | import { Button } from "@/components/ui/button"; 6 | import Link from "next/link"; 7 | import { type Favorite } from "../db/schema"; 8 | import { FavoriteButton } from "../customize/[fileId]/favorite-button"; 9 | 10 | export function FavoritesList({ favorites }: { favorites: Favorite[] }) { 11 | return ( 12 |
13 | {favorites.map((favorite) => ( 14 | 15 | 16 | 23 | 24 | 25 | 28 | 29 | 35 | 36 | 37 | ))} 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/app/customize/[fileId]/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { db } from "@/app/db/db"; 4 | import { favoriteCounts, favorites } from "@/app/db/schema"; 5 | import { assertAuthenticated } from "@/app/lib/auth-utils"; 6 | import { eq, and, sql } from "drizzle-orm"; 7 | import { revalidatePath } from "next/cache"; 8 | 9 | export async function toggleFavoriteMemeAction( 10 | fileId: string, 11 | filePath: string, 12 | pathToRevalidate: string 13 | ) { 14 | const userId = await assertAuthenticated(); 15 | 16 | const favorite = await db.query.favorites.findFirst({ 17 | where: and(eq(favorites.userId, userId), eq(favorites.memeId, fileId)), 18 | }); 19 | 20 | if (favorite) { 21 | await db 22 | .delete(favorites) 23 | .where(and(eq(favorites.userId, userId), eq(favorites.memeId, fileId))); 24 | await db 25 | .update(favoriteCounts) 26 | .set({ 27 | count: sql`${favoriteCounts.count} - 1`, 28 | }) 29 | .where(eq(favoriteCounts.memeId, fileId)); 30 | } else { 31 | await db.insert(favorites).values({ 32 | userId, 33 | memeId: fileId, 34 | filePath: filePath, 35 | }); 36 | await db 37 | .insert(favoriteCounts) 38 | .values({ 39 | memeId: fileId, 40 | count: 1, 41 | }) 42 | .onConflictDoUpdate({ 43 | set: { 44 | count: sql`${favoriteCounts.count} + 1`, 45 | }, 46 | target: favoriteCounts.memeId, 47 | }); 48 | } 49 | 50 | revalidatePath(pathToRevalidate); 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meme-generator", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@auth/drizzle-adapter": "^1.4.2", 13 | "@neondatabase/serverless": "^0.9.5", 14 | "@radix-ui/react-checkbox": "^1.1.1", 15 | "@radix-ui/react-dialog": "^1.1.1", 16 | "@radix-ui/react-dropdown-menu": "^2.1.1", 17 | "@radix-ui/react-icons": "^1.3.0", 18 | "@radix-ui/react-label": "^2.1.0", 19 | "@radix-ui/react-slider": "^1.2.0", 20 | "@radix-ui/react-slot": "^1.1.0", 21 | "@radix-ui/react-tooltip": "^1.1.2", 22 | "@types/imagekit": "^3.1.5", 23 | "@types/lodash": "^4.17.7", 24 | "class-variance-authority": "^0.7.0", 25 | "clsx": "^2.1.1", 26 | "drizzle-orm": "^0.33.0", 27 | "imagekit": "^5.2.0", 28 | "imagekitio-next": "^1.0.0", 29 | "lodash": "^4.17.21", 30 | "lucide-react": "^0.439.0", 31 | "next": "14.2.8", 32 | "next-auth": "^5.0.0-beta.20", 33 | "next-themes": "^0.3.0", 34 | "postgres": "^3.4.4", 35 | "react": "^18", 36 | "react-color": "^2.19.3", 37 | "react-dom": "^18", 38 | "tailwind-merge": "^2.5.2", 39 | "tailwindcss-animate": "^1.0.7" 40 | }, 41 | "devDependencies": { 42 | "@types/node": "^20", 43 | "@types/react": "^18", 44 | "@types/react-color": "^3.0.12", 45 | "@types/react-dom": "^18", 46 | "drizzle-kit": "^0.24.2", 47 | "eslint": "^8", 48 | "eslint-config-next": "14.2.8", 49 | "postcss": "^8", 50 | "tailwindcss": "^3.4.1", 51 | "typescript": "^5" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/app/search/results-list.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FileObject } from "imagekit/dist/libs/interfaces"; 4 | import { IKImage } from "imagekitio-next"; 5 | import { 6 | Card, 7 | CardContent, 8 | CardFooter, 9 | CardHeader, 10 | CardTitle, 11 | } from "@/components/ui/card"; 12 | import { Button } from "@/components/ui/button"; 13 | import Link from "next/link"; 14 | import { HeartFilledIcon } from "@radix-ui/react-icons"; 15 | 16 | export function ResultsList({ 17 | files, 18 | counts, 19 | }: { 20 | files: FileObject[]; 21 | counts: { 22 | memeId: string; 23 | count: number; 24 | }[]; 25 | }) { 26 | return ( 27 |
28 | {files.map((file) => ( 29 | 30 | 31 | 32 |
{file.customMetadata?.displayName ?? file.name}
33 |
34 | 35 | {counts.find((c) => c.memeId === file.fileId)?.count ?? 0} 36 |
37 |
38 |
39 | 40 | 47 | 48 | 49 | 52 | 53 |
54 | ))} 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer utilities { 6 | .text-balance { 7 | text-wrap: balance; 8 | } 9 | } 10 | 11 | @layer base { 12 | :root { 13 | --background: 0 0% 100%; 14 | --foreground: 0 0% 3.9%; 15 | --card: 0 0% 100%; 16 | --card-foreground: 0 0% 3.9%; 17 | --popover: 0 0% 100%; 18 | --popover-foreground: 0 0% 3.9%; 19 | --primary: 0 0% 9%; 20 | --primary-foreground: 0 0% 98%; 21 | --secondary: 0 0% 96.1%; 22 | --secondary-foreground: 0 0% 9%; 23 | --muted: 0 0% 96.1%; 24 | --muted-foreground: 0 0% 45.1%; 25 | --accent: 0 0% 96.1%; 26 | --accent-foreground: 0 0% 9%; 27 | --destructive: 0 84.2% 60.2%; 28 | --destructive-foreground: 0 0% 98%; 29 | --border: 0 0% 89.8%; 30 | --input: 0 0% 89.8%; 31 | --ring: 0 0% 3.9%; 32 | --chart-1: 12 76% 61%; 33 | --chart-2: 173 58% 39%; 34 | --chart-3: 197 37% 24%; 35 | --chart-4: 43 74% 66%; 36 | --chart-5: 27 87% 67%; 37 | --radius: 0.5rem; 38 | } 39 | 40 | .dark { 41 | --background: 0 0% 3.9%; 42 | --foreground: 0 0% 98%; 43 | --card: 0 0% 3.9%; 44 | --card-foreground: 0 0% 98%; 45 | --popover: 0 0% 3.9%; 46 | --popover-foreground: 0 0% 98%; 47 | --primary: 0 0% 98%; 48 | --primary-foreground: 0 0% 9%; 49 | --secondary: 0 0% 14.9%; 50 | --secondary-foreground: 0 0% 98%; 51 | --muted: 0 0% 14.9%; 52 | --muted-foreground: 0 0% 63.9%; 53 | --accent: 0 0% 14.9%; 54 | --accent-foreground: 0 0% 98%; 55 | --destructive: 0 62.8% 30.6%; 56 | --destructive-foreground: 0 0% 98%; 57 | --border: 0 0% 14.9%; 58 | --input: 0 0% 14.9%; 59 | --ring: 0 0% 83.1%; 60 | --chart-1: 220 70% 50%; 61 | --chart-2: 160 60% 45%; 62 | --chart-3: 30 80% 55%; 63 | --chart-4: 280 65% 60%; 64 | --chart-5: 340 75% 55%; 65 | } 66 | } 67 | 68 | @layer base { 69 | * { 70 | @apply border-border; 71 | } 72 | body { 73 | @apply bg-background text-foreground; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/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 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | theme: { 11 | extend: { 12 | colors: { 13 | background: "hsl(var(--background))", 14 | foreground: "hsl(var(--foreground))", 15 | card: { 16 | DEFAULT: "hsl(var(--card))", 17 | foreground: "hsl(var(--card-foreground))", 18 | }, 19 | popover: { 20 | DEFAULT: "hsl(var(--popover))", 21 | foreground: "hsl(var(--popover-foreground))", 22 | }, 23 | primary: { 24 | DEFAULT: "hsl(var(--primary))", 25 | foreground: "hsl(var(--primary-foreground))", 26 | }, 27 | secondary: { 28 | DEFAULT: "hsl(var(--secondary))", 29 | foreground: "hsl(var(--secondary-foreground))", 30 | }, 31 | muted: { 32 | DEFAULT: "hsl(var(--muted))", 33 | foreground: "hsl(var(--muted-foreground))", 34 | }, 35 | accent: { 36 | DEFAULT: "hsl(var(--accent))", 37 | foreground: "hsl(var(--accent-foreground))", 38 | }, 39 | destructive: { 40 | DEFAULT: "hsl(var(--destructive))", 41 | foreground: "hsl(var(--destructive-foreground))", 42 | }, 43 | border: "hsl(var(--border))", 44 | input: "hsl(var(--input))", 45 | ring: "hsl(var(--ring))", 46 | chart: { 47 | "1": "hsl(var(--chart-1))", 48 | "2": "hsl(var(--chart-2))", 49 | "3": "hsl(var(--chart-3))", 50 | "4": "hsl(var(--chart-4))", 51 | "5": "hsl(var(--chart-5))", 52 | }, 53 | }, 54 | borderRadius: { 55 | lg: "var(--radius)", 56 | md: "calc(var(--radius) - 2px)", 57 | sm: "calc(var(--radius) - 4px)", 58 | }, 59 | }, 60 | }, 61 | plugins: [require("tailwindcss-animate")], 62 | }; 63 | export default config; 64 | -------------------------------------------------------------------------------- /src/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 |

41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /public/empty.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | boolean, 3 | timestamp, 4 | pgTable, 5 | text, 6 | primaryKey, 7 | integer, 8 | uniqueIndex, 9 | } from "drizzle-orm/pg-core"; 10 | import type { AdapterAccountType } from "next-auth/adapters"; 11 | 12 | export const users = pgTable("user", { 13 | id: text("id") 14 | .primaryKey() 15 | .$defaultFn(() => crypto.randomUUID()), 16 | name: text("name"), 17 | email: text("email").unique(), 18 | emailVerified: timestamp("emailVerified", { mode: "date" }), 19 | image: text("image"), 20 | }); 21 | 22 | export const accounts = pgTable( 23 | "account", 24 | { 25 | userId: text("userId") 26 | .notNull() 27 | .references(() => users.id, { onDelete: "cascade" }), 28 | type: text("type").$type().notNull(), 29 | provider: text("provider").notNull(), 30 | providerAccountId: text("providerAccountId").notNull(), 31 | refresh_token: text("refresh_token"), 32 | access_token: text("access_token"), 33 | expires_at: integer("expires_at"), 34 | token_type: text("token_type"), 35 | scope: text("scope"), 36 | id_token: text("id_token"), 37 | session_state: text("session_state"), 38 | }, 39 | (account) => ({ 40 | compoundKey: primaryKey({ 41 | columns: [account.provider, account.providerAccountId], 42 | }), 43 | }) 44 | ); 45 | 46 | export const sessions = pgTable("session", { 47 | sessionToken: text("sessionToken").primaryKey(), 48 | userId: text("userId") 49 | .notNull() 50 | .references(() => users.id, { onDelete: "cascade" }), 51 | expires: timestamp("expires", { mode: "date" }).notNull(), 52 | }); 53 | 54 | export const verificationTokens = pgTable( 55 | "verificationToken", 56 | { 57 | identifier: text("identifier").notNull(), 58 | token: text("token").notNull(), 59 | expires: timestamp("expires", { mode: "date" }).notNull(), 60 | }, 61 | (verificationToken) => ({ 62 | compositePk: primaryKey({ 63 | columns: [verificationToken.identifier, verificationToken.token], 64 | }), 65 | }) 66 | ); 67 | 68 | export const authenticators = pgTable( 69 | "authenticator", 70 | { 71 | credentialID: text("credentialID").notNull().unique(), 72 | userId: text("userId") 73 | .notNull() 74 | .references(() => users.id, { onDelete: "cascade" }), 75 | providerAccountId: text("providerAccountId").notNull(), 76 | credentialPublicKey: text("credentialPublicKey").notNull(), 77 | counter: integer("counter").notNull(), 78 | credentialDeviceType: text("credentialDeviceType").notNull(), 79 | credentialBackedUp: boolean("credentialBackedUp").notNull(), 80 | transports: text("transports"), 81 | }, 82 | (authenticator) => ({ 83 | compositePK: primaryKey({ 84 | columns: [authenticator.userId, authenticator.credentialID], 85 | }), 86 | }) 87 | ); 88 | 89 | export const favorites = pgTable("favorite", { 90 | id: text("id") 91 | .primaryKey() 92 | .$defaultFn(() => crypto.randomUUID()), 93 | userId: text("userId") 94 | .notNull() 95 | .references(() => users.id, { onDelete: "cascade" }), 96 | memeId: text("memeId").notNull(), 97 | filePath: text("filePath").notNull(), 98 | }); 99 | 100 | export const favoriteCounts = pgTable( 101 | "favorite_count", 102 | { 103 | id: text("id") 104 | .primaryKey() 105 | .$defaultFn(() => crypto.randomUUID()), 106 | memeId: text("memeId").notNull(), 107 | count: integer("count").notNull().default(0), 108 | }, 109 | (table) => ({ 110 | memeIdUniqueIndex: uniqueIndex("memeIdUniqueIndex").on(table.memeId), 111 | }) 112 | ); 113 | 114 | export type Favorite = typeof favorites.$inferSelect; 115 | -------------------------------------------------------------------------------- /src/app/customize/[fileId]/text-overlay.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Input } from "@/components/ui/input"; 4 | import { Label } from "@/components/ui/label"; 5 | import { useEffect, useState } from "react"; 6 | import { Slider } from "@/components/ui/slider"; 7 | import { Card } from "@/components/ui/card"; 8 | import { Checkbox } from "@/components/ui/checkbox"; 9 | import { TwitterPicker } from "react-color"; 10 | 11 | export function TextOverlay({ 12 | index, 13 | onUpdate, 14 | }: { 15 | index: number; 16 | onUpdate: ( 17 | index: number, 18 | text: string, 19 | x: number, 20 | y: number, 21 | bgColor?: string 22 | ) => void; 23 | }) { 24 | const [textOverlay, setTextOverlay] = useState(""); 25 | const [textOverlayXPosition, setTextOverlayXPosition] = useState(0); 26 | const [textOverlayYPosition, setTextOverlayYPosition] = useState(0); 27 | const [applyTextBackground, setApplyTextBackground] = useState(false); 28 | const [textBgColor, setTextBgColor] = useState("#FFFFFF"); 29 | 30 | const xPositionDecimal = textOverlayXPosition / 100; 31 | const yPositionDecimal = textOverlayYPosition / 100; 32 | const bgColor = applyTextBackground 33 | ? textBgColor.replace("#", "") 34 | : undefined; 35 | 36 | useEffect(() => { 37 | onUpdate( 38 | index, 39 | textOverlay || " ", 40 | xPositionDecimal, 41 | yPositionDecimal, 42 | bgColor 43 | ); 44 | }, [ 45 | index, 46 | textOverlay, 47 | xPositionDecimal, 48 | yPositionDecimal, 49 | bgColor, 50 | onUpdate, 51 | ]); 52 | 53 | return ( 54 | 55 |
56 |
57 | 58 | { 61 | setTextOverlay(e.target.value); 62 | }} 63 | value={textOverlay} 64 | /> 65 |
66 |
67 |
68 | { 71 | setApplyTextBackground(v as boolean); 72 | }} 73 | id="terms" 74 | /> 75 | 81 |
82 | 83 | {applyTextBackground && ( 84 | { 87 | setTextBgColor(value.hex); 88 | }} 89 | /> 90 | )} 91 | 92 | {textBgColor} 93 |
94 |
95 |
96 | 97 | { 101 | setTextOverlayXPosition(v); 102 | }} 103 | /> 104 |
105 |
106 | 107 | { 111 | setTextOverlayYPosition(v); 112 | }} 113 | /> 114 |
115 |
116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { Cross2Icon } from "@radix-ui/react-icons" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogTrigger, 116 | DialogClose, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /src/app/search/upload-meme-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Dialog, 5 | DialogClose, 6 | DialogContent, 7 | DialogDescription, 8 | DialogFooter, 9 | DialogHeader, 10 | DialogTitle, 11 | DialogTrigger, 12 | } from "@/components/ui/dialog"; 13 | import { Button } from "@/components/ui/button"; 14 | import { IKUpload } from "imagekitio-next"; 15 | import { Input } from "@/components/ui/input"; 16 | import { Label } from "@/components/ui/label"; 17 | import { useRef, useState } from "react"; 18 | import { useRouter } from "next/navigation"; 19 | 20 | export function UploadMemeButton() { 21 | const uploadInputRef = useRef(null); 22 | const router = useRouter(); 23 | const [displayName, setDisplayName] = useState(""); 24 | const [isUploading, setIsUploading] = useState(false); 25 | const [tags, setTags] = useState(""); 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | Upload your meme image 35 | 36 | This is a meme image anyone on the site can build upon. 37 | 38 | 39 |
{ 42 | e.preventDefault(); 43 | setIsUploading(true); 44 | uploadInputRef.current?.click(); 45 | }} 46 | > 47 |
48 |
49 | 50 | setDisplayName(e.target.value)} 57 | /> 58 |
59 | 60 |
61 | 62 | setTags(e.target.value)} 69 | /> 70 |
71 | 72 | { 79 | setIsUploading(false); 80 | console.log("error", error); 81 | }} 82 | onSuccess={(response) => { 83 | setIsUploading(false); 84 | router.push(`/customize/${response.fileId}`); 85 | }} 86 | style={{ display: "none" }} 87 | ref={uploadInputRef} 88 | /> 89 |
90 | 91 | 92 | 93 | 96 | 97 | 98 | 102 | 103 |
104 |
105 |
106 |
107 | ); 108 | } 109 | 110 | function Spinner() { 111 | return ( 112 | 118 | 126 | 131 | 132 | ); 133 | } 134 | -------------------------------------------------------------------------------- /src/app/header.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | DropdownMenu, 4 | DropdownMenuContent, 5 | DropdownMenuItem, 6 | DropdownMenuTrigger, 7 | } from "@/components/ui/dropdown-menu"; 8 | import { ModeToggle } from "@/components/ui/mode-toggle"; 9 | import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; 10 | import { CircleUser, Menu, Package2, Search } from "lucide-react"; 11 | import Link from "next/link"; 12 | import { redirect } from "next/navigation"; 13 | import { SearchInput } from "./search-input"; 14 | import { auth, signIn, signOut } from "@/auth"; 15 | 16 | export async function Header() { 17 | const session = await auth(); 18 | 19 | return ( 20 |
21 | 44 | 45 | 46 | 50 | 51 | 52 | 76 | 77 | 78 |
79 |
{ 81 | "use server"; 82 | const search = formData.get("search"); 83 | redirect(`/search?q=${search}`); 84 | }} 85 | className="ml-auto flex-1 sm:flex-initial" 86 | > 87 |
88 | 89 | 90 |
91 |
92 | 93 | 94 | 95 |
96 |
97 | ); 98 | } 99 | 100 | async function AccountMenu() { 101 | const session = await auth(); 102 | 103 | if (!session) { 104 | return ( 105 |
{ 107 | "use server"; 108 | await signIn(); 109 | }} 110 | > 111 | 112 |
113 | ); 114 | } 115 | 116 | return ( 117 | 118 | 119 | 123 | 124 | 125 | 126 |
{ 128 | "use server"; 129 | await signOut(); 130 | }} 131 | > 132 | 133 |
134 |
135 |
136 |
137 | ); 138 | } 139 | -------------------------------------------------------------------------------- /src/components/ui/sheet.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SheetPrimitive from "@radix-ui/react-dialog"; 5 | import { Cross2Icon } from "@radix-ui/react-icons"; 6 | import { cva, type VariantProps } from "class-variance-authority"; 7 | 8 | import { cn } from "@/lib/utils"; 9 | 10 | const Sheet = SheetPrimitive.Root; 11 | 12 | const SheetTrigger = SheetPrimitive.Trigger; 13 | 14 | const SheetClose = SheetPrimitive.Close; 15 | 16 | const SheetPortal = SheetPrimitive.Portal; 17 | 18 | const SheetOverlay = React.forwardRef< 19 | React.ElementRef, 20 | React.ComponentPropsWithoutRef 21 | >(({ className, ...props }, ref) => ( 22 | 30 | )); 31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; 32 | 33 | const sheetVariants = cva( 34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", 35 | { 36 | variants: { 37 | side: { 38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", 39 | bottom: 40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", 41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", 42 | right: 43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", 44 | }, 45 | }, 46 | defaultVariants: { 47 | side: "right", 48 | }, 49 | } 50 | ); 51 | 52 | interface SheetContentProps 53 | extends React.ComponentPropsWithoutRef, 54 | VariantProps {} 55 | 56 | const SheetContent = React.forwardRef< 57 | React.ElementRef, 58 | SheetContentProps 59 | >(({ side = "right", className, children, ...props }, ref) => ( 60 | 61 | 62 | 67 | 68 | 69 | Close 70 | 71 | {children} 72 | 73 | 74 | )); 75 | SheetContent.displayName = SheetPrimitive.Content.displayName; 76 | 77 | const SheetHeader = ({ 78 | className, 79 | ...props 80 | }: React.HTMLAttributes) => ( 81 |
88 | ); 89 | SheetHeader.displayName = "SheetHeader"; 90 | 91 | const SheetFooter = ({ 92 | className, 93 | ...props 94 | }: React.HTMLAttributes) => ( 95 |
102 | ); 103 | SheetFooter.displayName = "SheetFooter"; 104 | 105 | const SheetTitle = React.forwardRef< 106 | React.ElementRef, 107 | React.ComponentPropsWithoutRef 108 | >(({ className, ...props }, ref) => ( 109 | 114 | )); 115 | SheetTitle.displayName = SheetPrimitive.Title.displayName; 116 | 117 | const SheetDescription = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, ...props }, ref) => ( 121 | 126 | )); 127 | SheetDescription.displayName = SheetPrimitive.Description.displayName; 128 | 129 | export { 130 | Sheet, 131 | SheetPortal, 132 | SheetOverlay, 133 | SheetTrigger, 134 | SheetClose, 135 | SheetContent, 136 | SheetHeader, 137 | SheetFooter, 138 | SheetTitle, 139 | SheetDescription, 140 | }; 141 | -------------------------------------------------------------------------------- /src/app/customize/[fileId]/customize-panel.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | "use client"; 3 | 4 | import { FileObject } from "imagekit/dist/libs/interfaces"; 5 | import { IKImage } from "imagekitio-next"; 6 | import { useCallback, useState } from "react"; 7 | import { TextOverlay } from "./text-overlay"; 8 | import { Button } from "@/components/ui/button"; 9 | import { debounce } from "lodash"; 10 | import { Checkbox } from "@/components/ui/checkbox"; 11 | import { Card } from "@/components/ui/card"; 12 | import { Download } from "lucide-react"; 13 | import { 14 | Tooltip, 15 | TooltipContent, 16 | TooltipProvider, 17 | TooltipTrigger, 18 | } from "@/components/ui/tooltip"; 19 | import { FavoriteButton } from "./favorite-button"; 20 | 21 | export function CustomizePanel({ 22 | file, 23 | isFavorited, 24 | isAuthenticated, 25 | }: { 26 | file: Pick; 27 | isFavorited: boolean; 28 | isAuthenticated: boolean; 29 | }) { 30 | const [textTransformation, setTextTransformations] = useState< 31 | Record 32 | >({}); 33 | const [numberOfOverlays, setNumberOfOverlays] = useState(1); 34 | const [blur, setBlur] = useState(false); 35 | const [sharpen, setSharpen] = useState(false); 36 | const [grayscale, setGrayscale] = useState(false); 37 | 38 | const textTransformationsArray = Object.values(textTransformation); 39 | 40 | const onUpdate = useCallback( 41 | debounce( 42 | (index: number, text: string, x: number, y: number, bgColor?: string) => { 43 | setTextTransformations((current) => ({ 44 | ...current, 45 | [`text${index}`]: { 46 | raw: `l-text,i-${text ?? " "},${ 47 | bgColor ? `bg-${bgColor},pa-10,` : "" 48 | }fs-50,ly-bw_mul_${y.toFixed(2)},lx-bw_mul_${x.toFixed(2)},l-end`, 49 | }, 50 | })); 51 | }, 52 | 250 53 | ), 54 | [] 55 | ); 56 | 57 | return ( 58 | <> 59 |
60 |

Customize

61 | 62 |
63 | {isAuthenticated && ( 64 | 70 | )} 71 | 72 | 73 | 74 | 75 | 91 | 92 | 93 |

Download Image

94 |
95 |
96 |
97 |
98 |
99 | 100 |
101 |
102 |
103 | 104 |

Effects

105 | 106 |
107 |
108 | { 111 | setBlur(v as boolean); 112 | }} 113 | id="blur" 114 | /> 115 | 121 |
122 |
123 | { 126 | setSharpen(v as boolean); 127 | }} 128 | id="sharpen" 129 | /> 130 | 136 |
137 |
138 | { 141 | setGrayscale(v as boolean); 142 | }} 143 | id="grayscale" 144 | /> 145 | 151 |
152 |
153 |
154 |
155 | 156 | {new Array(numberOfOverlays).fill("").map((_, index) => ( 157 | 158 | ))} 159 | 160 |
161 | 164 | 165 | {numberOfOverlays > 1 && ( 166 | 180 | )} 181 |
182 |
183 | 184 |
185 |
186 | 198 |
199 |
200 |
201 | 202 | ); 203 | } 204 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { 6 | CheckIcon, 7 | ChevronRightIcon, 8 | DotFilledIcon, 9 | } from "@radix-ui/react-icons" 10 | 11 | import { cn } from "@/lib/utils" 12 | 13 | const DropdownMenu = DropdownMenuPrimitive.Root 14 | 15 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 16 | 17 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 18 | 19 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 20 | 21 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 22 | 23 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 24 | 25 | const DropdownMenuSubTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef & { 28 | inset?: boolean 29 | } 30 | >(({ className, inset, children, ...props }, ref) => ( 31 | 40 | {children} 41 | 42 | 43 | )) 44 | DropdownMenuSubTrigger.displayName = 45 | DropdownMenuPrimitive.SubTrigger.displayName 46 | 47 | const DropdownMenuSubContent = React.forwardRef< 48 | React.ElementRef, 49 | React.ComponentPropsWithoutRef 50 | >(({ className, ...props }, ref) => ( 51 | 59 | )) 60 | DropdownMenuSubContent.displayName = 61 | DropdownMenuPrimitive.SubContent.displayName 62 | 63 | const DropdownMenuContent = React.forwardRef< 64 | React.ElementRef, 65 | React.ComponentPropsWithoutRef 66 | >(({ className, sideOffset = 4, ...props }, ref) => ( 67 | 68 | 78 | 79 | )) 80 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 81 | 82 | const DropdownMenuItem = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef & { 85 | inset?: boolean 86 | } 87 | >(({ className, inset, ...props }, ref) => ( 88 | 97 | )) 98 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 99 | 100 | const DropdownMenuCheckboxItem = React.forwardRef< 101 | React.ElementRef, 102 | React.ComponentPropsWithoutRef 103 | >(({ className, children, checked, ...props }, ref) => ( 104 | 113 | 114 | 115 | 116 | 117 | 118 | {children} 119 | 120 | )) 121 | DropdownMenuCheckboxItem.displayName = 122 | DropdownMenuPrimitive.CheckboxItem.displayName 123 | 124 | const DropdownMenuRadioItem = React.forwardRef< 125 | React.ElementRef, 126 | React.ComponentPropsWithoutRef 127 | >(({ className, children, ...props }, ref) => ( 128 | 136 | 137 | 138 | 139 | 140 | 141 | {children} 142 | 143 | )) 144 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 145 | 146 | const DropdownMenuLabel = React.forwardRef< 147 | React.ElementRef, 148 | React.ComponentPropsWithoutRef & { 149 | inset?: boolean 150 | } 151 | >(({ className, inset, ...props }, ref) => ( 152 | 161 | )) 162 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 163 | 164 | const DropdownMenuSeparator = React.forwardRef< 165 | React.ElementRef, 166 | React.ComponentPropsWithoutRef 167 | >(({ className, ...props }, ref) => ( 168 | 173 | )) 174 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 175 | 176 | const DropdownMenuShortcut = ({ 177 | className, 178 | ...props 179 | }: React.HTMLAttributes) => { 180 | return ( 181 | 185 | ) 186 | } 187 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 188 | 189 | export { 190 | DropdownMenu, 191 | DropdownMenuTrigger, 192 | DropdownMenuContent, 193 | DropdownMenuItem, 194 | DropdownMenuCheckboxItem, 195 | DropdownMenuRadioItem, 196 | DropdownMenuLabel, 197 | DropdownMenuSeparator, 198 | DropdownMenuShortcut, 199 | DropdownMenuGroup, 200 | DropdownMenuPortal, 201 | DropdownMenuSub, 202 | DropdownMenuSubContent, 203 | DropdownMenuSubTrigger, 204 | DropdownMenuRadioGroup, 205 | } 206 | --------------------------------------------------------------------------------