├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── .nvmrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── drizzle.config.ts ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.js ├── public ├── next.svg └── vercel.svg ├── src ├── app │ ├── _components │ │ ├── full-page-dropzone.tsx │ │ ├── image-grid.tsx │ │ ├── loading-spinner.tsx │ │ ├── magic-image.tsx │ │ ├── selection │ │ │ └── store.ts │ │ ├── topnav.tsx │ │ └── use-page-dropzone.ts │ ├── actions │ │ ├── deleteImages.ts │ │ └── reprocessImages.ts │ ├── api │ │ └── uploadthing │ │ │ ├── core.ts │ │ │ ├── make-transparent-cheaper.ts │ │ │ ├── make-transparent-file.ts │ │ │ ├── route.ts │ │ │ └── upload-on-server.ts │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── org │ │ └── [orgId] │ │ │ └── page.tsx │ ├── page.tsx │ └── sandbox │ │ └── page.tsx ├── db │ ├── index.ts │ ├── queries.ts │ └── schema.ts ├── middleware.ts ├── pages │ └── api │ │ └── inngest.ts └── utils │ └── group-images.ts ├── tailwind.config.js └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Get the clerk keys from Clerk.com/dashboard 2 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_YOUR_KEY_HERE 3 | CLERK_SECRET_KEY=sk_test_YOUR_KEY_HERE 4 | 5 | # Get the Database URL from the "prisma" dropdown selector in PlanetScale. Change the query params at the end of the URL to "?ssl={"rejectUnauthorized":true}" 6 | DATABASE_URL='mysql://YOUR_MYSQL_URL_HERE?ssl={"rejectUnauthorized":true}' 7 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | "next", 4 | "prettier", 5 | "plugin:react/recommended", 6 | "plugin:react-hooks/recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | ], 9 | parser: "@typescript-eslint/parser", 10 | plugins: ["@typescript-eslint", "react", "react-hooks"], 11 | rules: { 12 | "@next/next/no-html-link-for-pages": "off", 13 | "react/react-in-jsx-scope": "off", 14 | "@typescript-eslint/no-explicit-any": "off", 15 | "@typescript-eslint/no-unused-vars": "off", 16 | }, 17 | parserOptions: { 18 | babelOptions: { 19 | presets: [require.resolve("next/babel")], 20 | }, 21 | ecmaFeatures: { 22 | jsx: true, 23 | }, 24 | }, 25 | settings: { 26 | react: { 27 | version: "detect", 28 | }, 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /.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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # Theo things 39 | uploadthing.json 40 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.17 -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "dbaeumer.vscode-eslint", 5 | "bradlc.vscode-tailwindcss" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit" 4 | }, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.formatOnSave": true, 7 | "eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }], 8 | "typescript.tsdk": "node_modules/typescript/lib" 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Theo Browne 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | - Virtualized scroll 4 | - Duplicate name detection 5 | - Revalidate every 3 seconds while removed bg is processing 6 | - "Archive" button and view 7 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import type { Config } from "drizzle-kit"; 3 | 4 | export default { 5 | schema: "./src/db/schema.ts", 6 | out: "./migrations", 7 | driver: "turso", 8 | dbCredentials: { 9 | url: process.env.TURSO_CONNECTION_URL!, 10 | authToken: process.env.TURSO_AUTH_TOKEN!, 11 | }, 12 | } satisfies Config; 13 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | domains: ["uploadthing.com", "utfs.io"], 5 | }, 6 | }; 7 | 8 | module.exports = nextConfig; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "imgthing", 3 | "version": "0.1.0", 4 | "private": true, 5 | "engines": { 6 | "node": ">=18.14" 7 | }, 8 | "scripts": { 9 | "dev": "next dev --turbo", 10 | "build": "next build", 11 | "start": "next start", 12 | "lint": "next lint", 13 | "db:push": "drizzle-kit push:sqlite", 14 | "db:studio": "dotenv drizzle-kit studio" 15 | }, 16 | "dependencies": { 17 | "@clerk/nextjs": "5.0.0-beta.35", 18 | "@clerk/themes": "2.0.0-beta.9", 19 | "@libsql/client": "^0.6.0", 20 | "@planetscale/database": "^1.11.0", 21 | "@types/node": "20.2.5", 22 | "@types/react": "18.2.33", 23 | "@types/react-dom": "18.2.14", 24 | "@uploadthing/react": "^6.3.4", 25 | "autoprefixer": "10.4.16", 26 | "better-sqlite3": "^9.4.5", 27 | "clsx": "^1.2.1", 28 | "dayjs": "^1.11.9", 29 | "drizzle-orm": "^0.30.7", 30 | "eslint": "8.44.0", 31 | "eslint-config-next": "14.0.1", 32 | "inngest": "^3.7.4", 33 | "libsql-stateless-easy": "^1.5.8", 34 | "next": "14.1.4", 35 | "postcss": "8.4.25", 36 | "react": "18.2.0", 37 | "react-dom": "18.2.0", 38 | "react-dropzone": "^14.2.3", 39 | "tailwindcss": "3.3.2", 40 | "typescript": "5.2.2", 41 | "undici": "^5.22.1", 42 | "uploadthing": "^6.5.2", 43 | "zustand": "^4.4.5" 44 | }, 45 | "devDependencies": { 46 | "@typescript-eslint/eslint-plugin": "^5.59.9", 47 | "dotenv": "^16.1.4", 48 | "dotenv-cli": "^7.3.0", 49 | "drizzle-kit": "^0.20.14", 50 | "eslint-config-prettier": "^8.8.0", 51 | "eslint-config-react": "^1.1.7", 52 | "prettier": "^2.8.0", 53 | "prettier-plugin-tailwindcss": "^0.3.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // I only have this file because pnpm breaks prettier autoconfigs without it 2 | // See: https://github.com/pnpm/pnpm/issues/4700 3 | 4 | module.exports = { 5 | plugins: [require("prettier-plugin-tailwindcss")], 6 | }; 7 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/_components/full-page-dropzone.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { ImageFromDb } from "@/db/queries"; 4 | import React, { useEffect, useMemo, useState, useTransition } from "react"; 5 | import { usePageDropzone } from "./use-page-dropzone"; 6 | 7 | import { generateReactHelpers } from "@uploadthing/react/hooks"; 8 | 9 | import clsx from "clsx"; 10 | import { OurFileRouter } from "../api/uploadthing/core"; 11 | import { useRouter } from "next/navigation"; 12 | import GroupedImageGrid, { ImageGrid } from "./image-grid"; 13 | import { useSelectStore } from "./selection/store"; 14 | import { deleteImages } from "../actions/deleteImages"; 15 | import { reprocessImages } from "../actions/reprocessImages"; 16 | 17 | const { uploadFiles } = generateReactHelpers(); 18 | 19 | const SelectBar = () => { 20 | const store = useSelectStore(); 21 | 22 | const { refresh } = useRouter(); 23 | 24 | if (store.selectedIds.length === 0) return null; 25 | 26 | return ( 27 |
28 |
29 |
{store.selectedIds.length} selected
30 | 42 | 54 |
55 |
56 | ); 57 | }; 58 | 59 | const UploadingImage = (props: { 60 | file: File; 61 | upload: boolean; 62 | removeImage: () => void; 63 | }) => { 64 | const { refresh } = useRouter(); 65 | const [, startTransition] = useTransition(); 66 | 67 | useEffect(() => { 68 | let cancelled = false; 69 | if (props.upload) { 70 | console.log("PLAN: ", props.file.name); 71 | 72 | // When I upgraded to Next 14, this started to fire twice in close succcession, so I "fixed" it. 73 | const timeout = setTimeout(() => { 74 | if (cancelled) { 75 | console.log("cancelled upload for...", props.file.name); 76 | return; 77 | } 78 | console.log("START: ", props.file.name); 79 | uploadFiles("imageUploader", { 80 | files: [props.file], 81 | skipPolling: true, 82 | }).then(() => { 83 | console.log("done uploading", props.file.name); 84 | startTransition(() => { 85 | props.removeImage(); 86 | refresh(); 87 | }); 88 | }); 89 | }, 400); 90 | 91 | return () => { 92 | cancelled = true; 93 | clearTimeout(timeout); 94 | }; 95 | } 96 | 97 | // eslint-disable-next-line react-hooks/exhaustive-deps 98 | }, [props.file.name, props.upload]); 99 | 100 | useEffect(() => { 101 | return () => { 102 | console.log("unrendering", props.file.name); 103 | }; 104 | }, []); 105 | 106 | return ( 107 |
108 |
109 | {/* eslint-disable-next-line @next/next/no-img-element */} 110 | {props.file.name 121 |
122 |
{props.file.name}
123 |
124 | ); 125 | }; 126 | 127 | export default function FullPageDropzone(props: { images: ImageFromDb[] }) { 128 | const [files, setFiles] = useState([]); 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 |
106 |
Copied!
107 |
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 | {/*
26 | 27 |
28 | 29 |
30 | 31 |
*/} 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 | --------------------------------------------------------------------------------