([]);
129 |
130 | useEffect(() => {
131 | if (files.length === 0) return;
132 | console.log("Files", files);
133 | }, [files]);
134 |
135 | const { isDragging } = usePageDropzone((f) =>
136 | setFiles((oF) => [...oF, ...f])
137 | );
138 |
139 | const genContent = () => {
140 | if (props.images.length === 0 && files.length === 0) {
141 | return (
142 | Upload something
143 | );
144 | }
145 |
146 | return (
147 | <>
148 |
149 | {files.length > 0 && (
150 |
Uploading...
151 | )}
152 |
153 |
154 | {files.map((file, index) => (
155 |
160 | setFiles((fileList) =>
161 | fileList.filter(
162 | (currentFile) => currentFile.name !== file.name
163 | )
164 | )
165 | }
166 | />
167 | ))}
168 |
169 |
170 |
171 |
172 | {isDragging && (
173 |
174 |
175 | UPLOAD FILES
176 |
177 |
178 | )}
179 | >
180 | );
181 | };
182 |
183 | return (
184 |
189 | {genContent()}
190 |
191 | );
192 | }
193 |
--------------------------------------------------------------------------------
/src/app/_components/image-grid.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useMemo, useState, useTransition } from "react";
2 | import { MagicImageRender } from "./magic-image";
3 | import type { ImageFromDb } from "@/db/queries";
4 |
5 | import dayjs from "dayjs";
6 | import relativeTime from "dayjs/plugin/relativeTime";
7 | import isToday from "dayjs/plugin/isToday";
8 | import isYesterday from "dayjs/plugin/isYesterday";
9 | import { groupImagesByDate } from "@/utils/group-images";
10 | dayjs.extend(relativeTime);
11 | dayjs.extend(isToday);
12 | dayjs.extend(isYesterday);
13 |
14 | const genTimestamp = (timestamp: string) => {
15 | const time = dayjs(timestamp);
16 | if (time.isToday()) return "Today";
17 | if (time.isYesterday()) return "Yesterday";
18 | return time.toDate().toLocaleDateString();
19 | };
20 |
21 | export const ImageGrid: React.FC<{ children: React.ReactNode }> = (props) => (
22 |
23 | {props.children}
24 |
25 | );
26 |
27 | export default function GroupedImageGrid(props: { images: ImageFromDb[] }) {
28 | const groupedImages = useMemo(() => {
29 | return groupImagesByDate(props.images);
30 | }, [props.images]);
31 | return (
32 | <>
33 | {Object.keys(groupedImages).map((imageGroupDate) => (
34 |
35 |
36 |
37 |
38 | {genTimestamp(imageGroupDate)}
39 |
40 |
41 |
42 |
43 | {groupedImages[imageGroupDate].map((rn) => (
44 |
45 | ))}
46 |
47 |
48 | ))}
49 | >
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/app/_components/loading-spinner.tsx:
--------------------------------------------------------------------------------
1 | export const Spinner = () => (
2 |
6 |
7 | Loading...
8 |
9 |
10 | );
11 |
--------------------------------------------------------------------------------
/src/app/_components/magic-image.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type { ImageFromDb } from "@/db/queries";
4 | import NextImage from "next/image";
5 | import { useState } from "react";
6 | import { Spinner } from "./loading-spinner";
7 | import { useIdToggle } from "./selection/store";
8 | import clsx from "clsx";
9 | import { useAuth } from "@clerk/nextjs";
10 |
11 | async function downloadAndCopyImageToClipboard(imageUrl: string) {
12 | console.log("dc image", imageUrl);
13 | if (!navigator.clipboard) {
14 | console.error("Clipboard API not supported in this browser.");
15 | return;
16 | }
17 |
18 | try {
19 | const img: HTMLImageElement = await new Promise((resolve) => {
20 | const img = new Image();
21 | img.crossOrigin = "anonymous";
22 | img.onload = () => resolve(img);
23 | img.src = imageUrl;
24 | });
25 |
26 | const canvas = document.createElement("canvas");
27 | const ctx = canvas.getContext("2d");
28 | canvas.width = img.width;
29 | canvas.height = img.height;
30 | if (!ctx) throw new Error("Could not get canvas context.");
31 | ctx.drawImage(img, 0, 0);
32 |
33 | const imageData: Blob = await new Promise((resolve) => {
34 | canvas.toBlob((a) => a && resolve(a));
35 | });
36 |
37 | await navigator.clipboard.write([
38 | new ClipboardItem({
39 | [imageData.type]: imageData,
40 | }),
41 | ]);
42 |
43 | console.log("Image copied to clipboard.");
44 | } catch (error) {
45 | console.error("Error copying image to clipboard:", error);
46 | }
47 | }
48 |
49 | export const MagicImageRender = ({ image }: { image: ImageFromDb }) => {
50 | const { isSignedIn } = useAuth();
51 | // For when doing copying
52 | const [loading, setLoading] = useState(false);
53 | // For when copying is done
54 | const [done, setDone] = useState(false);
55 |
56 | // For selection (for deletion)
57 | const { toggle, isSelected } = useIdToggle(image.fileKey);
58 |
59 | return (
60 |
61 | {isSignedIn && (
62 |
72 | )}
73 |
{
76 | console.log("copying image", image);
77 | setLoading(true);
78 | downloadAndCopyImageToClipboard(
79 | image.removedBgUrl ?? image.originalUrl ?? ""
80 | ).then(() => {
81 | setLoading(false);
82 | setDone(true);
83 | setTimeout(() => {
84 | setDone(false);
85 | }, 1000);
86 | });
87 | }}
88 | >
89 |
96 |
97 |
98 | {loading && (
99 |
100 |
101 |
102 | )}
103 |
104 | {done && (
105 |
108 | )}
109 |
110 |
{image.originalName}
111 |
112 | );
113 | };
114 |
--------------------------------------------------------------------------------
/src/app/_components/selection/store.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | interface SelectState {
4 | selectedIds: string[];
5 | toggleIdSelection: (id: string) => void;
6 | clearAll: () => void;
7 | }
8 |
9 | export const useSelectStore = create()((set) => ({
10 | selectedIds: [],
11 | toggleIdSelection: (id) =>
12 | set((state) => {
13 | if (state.selectedIds.includes(id)) {
14 | return {
15 | selectedIds: state.selectedIds.filter(
16 | (selectedId) => selectedId !== id
17 | ),
18 | };
19 | }
20 | return {
21 | selectedIds: [...state.selectedIds, id],
22 | };
23 | }),
24 | clearAll: () => set({ selectedIds: [] }),
25 | }));
26 |
27 | export const useSelectedIds = () =>
28 | useSelectStore((state) => state.selectedIds);
29 |
30 | export const useIdToggle = (id: string) => {
31 | const toggleIdSelection = useSelectStore((state) => state.toggleIdSelection);
32 | const isSelected = useSelectStore((state) => state.selectedIds.includes(id));
33 |
34 | return { toggle: () => toggleIdSelection(id), isSelected };
35 | };
36 |
--------------------------------------------------------------------------------
/src/app/_components/topnav.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | OrganizationSwitcher,
3 | SignInButton,
4 | SignedIn,
5 | SignedOut,
6 | UserButton,
7 | } from "@clerk/nextjs";
8 | import { dark } from "@clerk/themes";
9 | import React from "react";
10 |
11 | const TopNavLayout = ({ children }: { children: React.ReactNode }) => {
12 | return (
13 |
14 |
15 |
26 |
27 | {children}
28 |
29 | );
30 | };
31 |
32 | export default TopNavLayout;
33 |
--------------------------------------------------------------------------------
/src/app/_components/use-page-dropzone.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | type DropEvent = DragEvent & { dataTransfer: DataTransfer | null };
4 |
5 | export const usePageDropzone = (onFilesDropped: (files: File[]) => void) => {
6 | const [isDragging, setIsDragging] = React.useState(false);
7 | const dragCounter = React.useRef(0);
8 |
9 | const handleDrag = React.useCallback((event: DropEvent) => {
10 | event.preventDefault();
11 | event.stopPropagation();
12 | }, []);
13 | const handleDragIn = React.useCallback((event: DropEvent) => {
14 | event.preventDefault();
15 | event.stopPropagation();
16 | dragCounter.current++;
17 | if (event.dataTransfer?.items && event.dataTransfer?.items.length > 0) {
18 | setIsDragging(true);
19 | }
20 | }, []);
21 | const handleDragOut = React.useCallback((event: DropEvent) => {
22 | event.preventDefault();
23 | event.stopPropagation();
24 | dragCounter.current--;
25 | if (dragCounter.current > 0) return;
26 | setIsDragging(false);
27 | }, []);
28 | const handleDrop = React.useCallback((event: DropEvent) => {
29 | event.preventDefault();
30 | event.stopPropagation();
31 | setIsDragging(false);
32 | if (event.dataTransfer?.files && event.dataTransfer?.files.length > 0) {
33 | dragCounter.current = 0;
34 | console.log(event.dataTransfer.files);
35 | onFilesDropped(Array.from(event.dataTransfer.files));
36 | event.dataTransfer.clearData();
37 | }
38 | }, []);
39 |
40 | React.useEffect(() => {
41 | window.addEventListener("dragenter", handleDragIn);
42 | window.addEventListener("dragleave", handleDragOut);
43 | window.addEventListener("dragover", handleDrag);
44 | window.addEventListener("drop", handleDrop);
45 | return function cleanUp() {
46 | window.removeEventListener("dragenter", handleDragIn);
47 | window.removeEventListener("dragleave", handleDragOut);
48 | window.removeEventListener("dragover", handleDrag);
49 | window.removeEventListener("drop", handleDrop);
50 | };
51 | });
52 |
53 | return { isDragging };
54 | };
55 |
--------------------------------------------------------------------------------
/src/app/actions/deleteImages.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { db } from "@/db";
4 | import { uploadedImage } from "@/db/schema";
5 | import { auth } from "@clerk/nextjs/server";
6 | import { and, eq, inArray } from "drizzle-orm";
7 |
8 | export async function deleteImages(images: string[]) {
9 | const user = await auth();
10 | if (!user.userId) throw new Error("not logged in");
11 |
12 | const orgOrUser = user.orgId
13 | ? eq(uploadedImage.orgId, user.orgId)
14 | : eq(uploadedImage.userId, user.userId);
15 |
16 | const deleted = await db
17 | .delete(uploadedImage)
18 | .where(and(orgOrUser, inArray(uploadedImage.fileKey, images)));
19 |
20 | return "done!";
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/actions/reprocessImages.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { db } from "@/db";
4 | import { uploadedImage } from "@/db/schema";
5 | import { auth } from "@clerk/nextjs/server";
6 | import { and, eq, inArray } from "drizzle-orm";
7 | import { inngest } from "@/pages/api/inngest";
8 |
9 | export async function reprocessImages(images: string[]) {
10 | const user = await auth();
11 | if (!user.userId) throw new Error("not logged in");
12 |
13 | const orgOrUser = user.orgId
14 | ? eq(uploadedImage.orgId, user.orgId)
15 | : eq(uploadedImage.userId, user.userId);
16 |
17 | const toProcess = await db
18 | .select()
19 | .from(uploadedImage)
20 | .where(and(orgOrUser, inArray(uploadedImage.fileKey, images)));
21 |
22 | const inputs = toProcess.map((image) => {
23 | console.log("Sending image", image.id, image.fileKey);
24 | return {
25 | name: "gen/transparent",
26 | data: { imageUrl: image.originalUrl, fileKey: image.fileKey },
27 | };
28 | });
29 |
30 | const results = await inngest.send(inputs);
31 |
32 | console.log("RESULTS", results);
33 |
34 | return "done!";
35 | }
36 |
--------------------------------------------------------------------------------
/src/app/api/uploadthing/core.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/db";
2 | import { uploadedImage } from "@/db/schema";
3 | import { auth, currentUser } from "@clerk/nextjs/server";
4 | import { createUploadthing, type FileRouter } from "uploadthing/next";
5 | import { inngest } from "@/pages/api/inngest";
6 |
7 | const f = createUploadthing();
8 |
9 | export const ourFileRouter = {
10 | imageUploader: f({ image: { maxFileSize: "4MB", maxFileCount: 20 } })
11 | .middleware(async (req) => {
12 | const user = await currentUser();
13 | const sesh = await auth();
14 |
15 | const orgId = sesh.orgId;
16 |
17 | if (!user || !user.id || !user.privateMetadata.enabled)
18 | throw new Error("Unauthorized");
19 |
20 | return { userId: user.id, orgId: orgId };
21 | })
22 | .onUploadComplete(async ({ metadata, file }) => {
23 | console.log("Upload complete for userId:", metadata.userId);
24 |
25 | console.log("file url", file.url);
26 | await db.insert(uploadedImage).values({
27 | userId: metadata.userId,
28 | orgId: metadata.orgId,
29 | fileKey: file.key,
30 | originalName: file.name,
31 | originalUrl: file.url,
32 | });
33 |
34 | await inngest.send({
35 | name: "gen/transparent",
36 | data: { imageUrl: file.url, fileKey: file.key },
37 | });
38 | }),
39 | } satisfies FileRouter;
40 |
41 | export type OurFileRouter = typeof ourFileRouter;
42 |
--------------------------------------------------------------------------------
/src/app/api/uploadthing/make-transparent-cheaper.ts:
--------------------------------------------------------------------------------
1 | import { uploadFileOnServer } from "./upload-on-server";
2 |
3 | export const uploadTransparentAlt = async (inputUrl: string) => {
4 | if (!process.env.PROCESSOR_KEY) throw new Error("no key?");
5 |
6 | const url = inputUrl.replace("uploadthing.com", "utfs.io");
7 |
8 | console.log("resolving to url", url);
9 |
10 | const form = new FormData();
11 | form.append("image_url", url);
12 | form.append("sync", "1");
13 |
14 | const response = await fetch(
15 | "https://techhk.aoscdn.com/api/tasks/visual/segmentation",
16 | {
17 | method: "POST",
18 | headers: {
19 | "X-API-KEY": process.env.PROCESSOR_KEY,
20 | },
21 | body: form,
22 | }
23 | );
24 |
25 | const contents = (await response.json()) as { data: { image: string } };
26 | console.log("image url:", contents);
27 |
28 | const fileUploaded = await uploadFileOnServer(contents.data.image);
29 |
30 | console.log("uploaded?", fileUploaded);
31 |
32 | return fileUploaded;
33 | };
34 |
--------------------------------------------------------------------------------
/src/app/api/uploadthing/make-transparent-file.ts:
--------------------------------------------------------------------------------
1 | import { UTApi } from "uploadthing/server";
2 |
3 | const utapi = new UTApi();
4 |
5 | export const uploadTransparent = async (inputUrl: string) => {
6 | if (!process.env.REMOVEBG_KEY) throw new Error("No removebg key");
7 |
8 | const formData = new FormData();
9 | formData.append("size", "auto");
10 | formData.append("image_url", inputUrl);
11 |
12 | const res = await fetch("https://api.remove.bg/v1.0/removebg", {
13 | method: "POST",
14 | body: formData,
15 | headers: {
16 | "X-Api-Key": process.env.REMOVEBG_KEY,
17 | },
18 | next: {
19 | revalidate: 0,
20 | },
21 | });
22 |
23 | if (!res.ok) {
24 | console.error("Got an error in response", res.status, res.statusText);
25 | console.error(await res.text());
26 | return { error: "unable to process image at this time" };
27 | }
28 |
29 | const fileBlob = await res.blob();
30 |
31 | console.log("got file from remove bg");
32 |
33 | const mockFile = Object.assign(fileBlob, { name: "transparent.png" });
34 |
35 | const uploadedFile = await utapi.uploadFiles(mockFile);
36 |
37 | return uploadedFile;
38 | };
39 |
--------------------------------------------------------------------------------
/src/app/api/uploadthing/route.ts:
--------------------------------------------------------------------------------
1 | import { createNextRouteHandler } from "uploadthing/next";
2 |
3 | import { ourFileRouter } from "./core";
4 |
5 | export const revalidate = 0;
6 | export const fetchCache = "force-no-store";
7 | export const runtime = "nodejs";
8 |
9 | // Export routes for Next App Router
10 | export const { GET, POST } = createNextRouteHandler({
11 | router: ourFileRouter,
12 | });
13 |
--------------------------------------------------------------------------------
/src/app/api/uploadthing/upload-on-server.ts:
--------------------------------------------------------------------------------
1 | import { UTApi } from "uploadthing/server";
2 |
3 | const utapi = new UTApi();
4 |
5 | export const uploadFileOnServer = async (url: string) => {
6 | console.log("uploading file from url on server");
7 | const res = await fetch(url, {
8 | next: {
9 | revalidate: 0,
10 | },
11 | });
12 |
13 | const fileBlob = await res.blob();
14 | const mockFile = Object.assign(fileBlob, { name: "transparent.png" });
15 | const response = await utapi.uploadFiles(mockFile);
16 | return response;
17 | };
18 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/t3dotgg/imgmanager/d352b464e795711901874b1a40cde0805ce48b98/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html {
6 | @apply bg-zinc-950 text-zinc-100;
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | // app/layout.tsx
2 | import "./globals.css";
3 | import { ClerkProvider } from "@clerk/nextjs";
4 | import TopNavLayout from "./_components/topnav";
5 |
6 | export const metadata = {
7 | title: "ImgThing",
8 | description: "A Thing For Images",
9 | };
10 |
11 | // export const runtime = "edge";
12 |
13 | export default function RootLayout({
14 | children,
15 | }: {
16 | children: React.ReactNode;
17 | }) {
18 | return (
19 |
20 |
21 |
22 | {children}
23 |
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/org/[orgId]/page.tsx:
--------------------------------------------------------------------------------
1 | import { getImagesForOrg } from "@/db/queries";
2 | import dynamic from "next/dynamic";
3 |
4 | const LazyGroupedImageGrid = dynamic(
5 | () => import("../../_components/full-page-dropzone"),
6 | {
7 | ssr: false,
8 | }
9 | );
10 |
11 | export const runtime = "edge";
12 |
13 | export default async function Home(props: { params: { orgId: string } }) {
14 | const data = await getImagesForOrg(props.params.orgId);
15 | return (
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignedIn, SignedOut } from "@clerk/nextjs";
2 | import { getImagesForUser } from "@/db/queries";
3 | import dynamic from "next/dynamic";
4 |
5 | const LazyFullPageDropzone = dynamic(
6 | () => import("./_components/full-page-dropzone"),
7 | {
8 | ssr: false,
9 | }
10 | );
11 |
12 | async function Images() {
13 | const data = await getImagesForUser();
14 | return ;
15 | }
16 |
17 | export const runtime = "edge";
18 |
19 | export default async function Home() {
20 | return (
21 |
22 |
23 |
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/sandbox/page.tsx:
--------------------------------------------------------------------------------
1 | import { db } from "@/db";
2 | import fs from "fs/promises";
3 | import { uploadedImage } from "@/db/schema";
4 |
5 | // // Run this while on old DB
6 | // async function dumpImagesFromDB() {
7 | // "use server";
8 | // const allImages = await db.query.uploadedImage.findMany();
9 |
10 | // await fs.writeFile("db-dump.json", JSON.stringify(allImages));
11 | // }
12 |
13 | // // Run this while on new DB
14 | // async function updateDBFromDump() {
15 | // "use server";
16 | // const imagesFromFile = await fs.readFile("db-dump.json", "utf8");
17 |
18 | // await db.insert(uploadedImage).values(JSON.parse(imagesFromFile));
19 | // }
20 |
21 | export default function SandboxPage() {
22 | return (
23 |
24 | Empty sandbox
25 | {/*
28 |
29 | */}
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/db/index.ts:
--------------------------------------------------------------------------------
1 | import { drizzle } from "drizzle-orm/libsql";
2 | import { createClient } from "libsql-stateless-easy";
3 |
4 | import * as schema from "./schema";
5 |
6 | const client = createClient({
7 | url: process.env.TURSO_CONNECTION_URL!,
8 | authToken: process.env.TURSO_AUTH_TOKEN!,
9 | });
10 |
11 | export const db = drizzle(client, { schema });
12 |
--------------------------------------------------------------------------------
/src/db/queries.ts:
--------------------------------------------------------------------------------
1 | import { auth } from "@clerk/nextjs/server";
2 | import { db } from ".";
3 | import { uploadedImage } from "./schema";
4 | import { and, desc, eq, isNull } from "drizzle-orm";
5 |
6 | export const getImagesForUser = async () => {
7 | const user = await auth();
8 | if (!user.userId) return [];
9 |
10 | if (user.orgId) {
11 | return await db.query.uploadedImage.findMany({
12 | where: eq(uploadedImage.orgId, user.orgId),
13 | orderBy: desc(uploadedImage.id),
14 | });
15 | }
16 |
17 | return await db.query.uploadedImage.findMany({
18 | where: and(
19 | eq(uploadedImage.userId, user.userId),
20 | isNull(uploadedImage.orgId)
21 | ),
22 | orderBy: desc(uploadedImage.id),
23 | });
24 | };
25 |
26 | export const getImagesForOrg = async (orgId: string) => {
27 | return await db.query.uploadedImage.findMany({
28 | where: eq(uploadedImage.orgId, orgId),
29 | orderBy: desc(uploadedImage.id),
30 | });
31 | };
32 |
33 | type Awaited = T extends PromiseLike ? Awaited : T;
34 |
35 | export type ImageFromDb = Awaited>[0];
36 |
--------------------------------------------------------------------------------
/src/db/schema.ts:
--------------------------------------------------------------------------------
1 | // Example model schema from the Drizzle docs
2 | // https://orm.drizzle.team/docs/sql-schema-declaration
3 |
4 | // import {
5 | // mysqlTable,
6 | // serial,
7 | // uniqueIndex,
8 | // index,
9 | // varchar,
10 | // timestamp,
11 | // } from "drizzle-orm/mysql-core";
12 |
13 | // declaring enum in database
14 | // export const utOld = mysqlTable(
15 | // "uploaded_image",
16 | // {
17 | // id: serial("id").primaryKey(),
18 |
19 | // // Always useful info
20 | // createdAt: timestamp("createdAt").notNull().defaultNow(),
21 | // completedAt: timestamp("completedAt"),
22 | // userId: varchar("userId", { length: 256 }).notNull(),
23 |
24 | // orgId: varchar("orgId", { length: 256 }),
25 |
26 | // // "on upload start" info
27 | // originalName: varchar("original_name", { length: 256 }).notNull(),
28 | // fileKey: varchar("file_key", { length: 256 }).notNull(),
29 |
30 | // // Added afterwards
31 | // originalUrl: varchar("ogUrl", { length: 800 }),
32 | // removedBgUrl: varchar("transparentUrl", { length: 800 }),
33 | // },
34 | // (randomNumber) => ({
35 | // fileKeyIndex: uniqueIndex("file_key_IDX").on(randomNumber.fileKey),
36 | // userIdIndex: index("user_id_index").on(randomNumber.userId),
37 | // })
38 | // );
39 |
40 | import { sql } from "drizzle-orm";
41 | import {
42 | index,
43 | integer,
44 | sqliteTable,
45 | text,
46 | uniqueIndex,
47 | } from "drizzle-orm/sqlite-core";
48 |
49 | export const uploadedImage = sqliteTable(
50 | "uploaded_image",
51 | {
52 | id: integer("id").primaryKey(),
53 | userId: text("userId", { length: 256 }).notNull(),
54 |
55 | orgId: text("orgId", { length: 256 }),
56 |
57 | // "on upload start" info
58 | originalName: text("original_name", { length: 256 }).notNull(),
59 | fileKey: text("file_key", { length: 256 }).notNull(),
60 |
61 | // Added afterwards
62 | originalUrl: text("ogUrl", { length: 800 }),
63 | removedBgUrl: text("transparentUrl", { length: 800 }),
64 |
65 | createdAt: text("created_at")
66 | .default(sql`CURRENT_TIMESTAMP`)
67 | .notNull(),
68 | completedAt: text("completed_at"),
69 | },
70 | (randomNumber) => ({
71 | fileKeyIndex: uniqueIndex("file_key_IDX").on(randomNumber.fileKey),
72 | userIdIndex: index("user_id_index").on(randomNumber.userId),
73 | })
74 | );
75 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
2 |
3 | const isProtectedRoute = createRouteMatcher(["/dashboard(.*)"]);
4 |
5 | export default clerkMiddleware((auth, request) => {
6 | if (isProtectedRoute(request)) auth().protect();
7 | });
8 |
9 | export const config = {
10 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
11 | };
12 |
--------------------------------------------------------------------------------
/src/pages/api/inngest.ts:
--------------------------------------------------------------------------------
1 | import { uploadTransparent } from "@/app/api/uploadthing/make-transparent-file";
2 | import { db } from "@/db";
3 | import { uploadedImage } from "@/db/schema";
4 | import { eq } from "drizzle-orm";
5 | import { Inngest, NonRetriableError, slugify, RetryAfterError } from "inngest";
6 | import { serve } from "inngest/next";
7 |
8 | // Create a client to send and receive events
9 | export const inngest = new Inngest({ id: slugify("Img Manager") });
10 |
11 | const makeImageTransparent = inngest.createFunction(
12 | {
13 | id: "gen/transparent",
14 |
15 | // Sadly, inngest's understanding of "ratelimit" varies greatly from
16 | // the industry standard, so I can't use this :(
17 | // rateLimit: {
18 | // period: "60s",
19 | // limit: 50,
20 | // },
21 |
22 | // I don't want concurrency instead but if I don't do this we get fucked
23 | concurrency: 3,
24 |
25 | onFailure: (e) => {
26 | console.log("gen/transparent failed", e.error);
27 | },
28 | },
29 | { event: "gen/transparent" },
30 | async ({ event, step }) => {
31 | console.log("image?", event.data.imageUrl, event.data.fileKey);
32 |
33 | if (!event.data.imageUrl || typeof event.data.imageUrl !== "string")
34 | throw new NonRetriableError("No image url");
35 |
36 | const transparent = await uploadTransparent(event.data.imageUrl);
37 | console.log("transparent response generated and uploaded", transparent);
38 |
39 | if (transparent.error !== null) {
40 | console.error("UNABLE TO UPLOAD TRANSPARENT IMAGE FILE", event);
41 | throw new RetryAfterError(
42 | "Unable to process and upload transparent image, retrying",
43 | 60 * 1000
44 | );
45 | }
46 |
47 | if (!transparent.data?.url) {
48 | console.error("UNABLE TO UPLOAD TRANSPARENT IMAGE FILE", event);
49 | throw new Error(
50 | "Unable to process and upload transparent image, retrying"
51 | );
52 | }
53 |
54 | await db
55 | .update(uploadedImage)
56 | .set({
57 | removedBgUrl: transparent.data?.url,
58 | })
59 | .where(eq(uploadedImage.fileKey, event.data.fileKey));
60 |
61 | return { event, body: transparent.data.url };
62 | }
63 | );
64 |
65 | export default serve({ client: inngest, functions: [makeImageTransparent] });
66 |
--------------------------------------------------------------------------------
/src/utils/group-images.ts:
--------------------------------------------------------------------------------
1 | import type { ImageFromDb } from "@/db/queries";
2 |
3 | export const groupImagesByDate = (images: ImageFromDb[]) => {
4 | const imagesByDate = images.reduce(
5 | (acc: Record, image) => {
6 | const date = new Date(image.createdAt).toDateString();
7 | if (!acc[date]) acc[date] = [];
8 | acc[date].push(image);
9 | return acc;
10 | },
11 | {}
12 | );
13 |
14 | return imagesByDate;
15 | };
16 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
5 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
7 | ],
8 | theme: {
9 | extend: {
10 | backgroundImage: {
11 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
12 | "gradient-conic":
13 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
14 | },
15 | gridTemplateColumns: {
16 | fluid: "repeat(auto-fit, minmax(380px, 1fr))",
17 | },
18 | keyframes: {
19 | "fade-in-down": {
20 | "0%": {
21 | opacity: "0",
22 | transform: "translateY(-10px)",
23 | },
24 | "100%": {
25 | opacity: "1",
26 | transform: "translateY(0)",
27 | },
28 | },
29 | },
30 | animation: {
31 | "fade-in-down": "fade-in-down 0.5s ease-out",
32 | },
33 | },
34 | },
35 | plugins: [],
36 | };
37 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./src/*"]
24 | }
25 | },
26 | "include": [
27 | "next-env.d.ts",
28 | "**/*.ts",
29 | "**/*.tsx",
30 | ".next/types/**/*.ts",
31 | "src/types.d.ts"
32 | ],
33 | "exclude": ["node_modules"]
34 | }
35 |
--------------------------------------------------------------------------------