diff options
| author | Mohamed Bassem <me@mbassem.com> | 2024-03-19 00:33:11 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-03-19 00:33:11 +0000 |
| commit | 785a5b574992296e187a66412dd42f7b4a686353 (patch) | |
| tree | 64b608927cc63d7494395f639636fd4b36e5a977 /apps/web | |
| parent | 549520919c482e72cdf7adae5ba852d1b6cbe5aa (diff) | |
| download | karakeep-785a5b574992296e187a66412dd42f7b4a686353.tar.zst | |
Feature: Add support for uploading images and automatically inferring their tags (#2)
* feature: Experimental support for asset uploads
* feature(web): Add new bookmark type asset
* feature: Add support for automatically tagging images
* fix: Add support for image assets in preview page
* use next Image for fetching the images
* Fix auth and error codes in the route handlers
* Add support for image uploads on mobile
* Fix typing of upload requests
* Remove the ugly dragging box
* Bump mobile version to 1.3
* Change the editor card placeholder to mention uploading images
* Fix a typo
* Change ios icon for photo library
* Silence typescript error
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/app/api/assets/[assetId]/route.ts | 29 | ||||
| -rw-r--r-- | apps/web/app/api/assets/route.ts | 52 | ||||
| -rw-r--r-- | apps/web/app/api/trpc/[trpc]/route.ts | 19 | ||||
| -rw-r--r-- | apps/web/app/dashboard/layout.tsx | 3 | ||||
| -rw-r--r-- | apps/web/components/dashboard/UploadDropzone.tsx | 79 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/AssetCard.tsx | 76 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx | 17 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx | 4 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/EditorCard.tsx | 4 | ||||
| -rw-r--r-- | apps/web/package.json | 1 | ||||
| -rw-r--r-- | apps/web/server/api/client.ts | 18 |
11 files changed, 283 insertions, 19 deletions
diff --git a/apps/web/app/api/assets/[assetId]/route.ts b/apps/web/app/api/assets/[assetId]/route.ts new file mode 100644 index 00000000..6b583e51 --- /dev/null +++ b/apps/web/app/api/assets/[assetId]/route.ts @@ -0,0 +1,29 @@ +import { createContextFromRequest } from "@/server/api/client"; +import { and, eq } from "drizzle-orm"; + +import { db } from "@hoarder/db"; +import { assets } from "@hoarder/db/schema"; + +export const dynamic = "force-dynamic"; +export async function GET( + request: Request, + { params }: { params: { assetId: string } }, +) { + const ctx = await createContextFromRequest(request); + if (!ctx.user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const asset = await db.query.assets.findFirst({ + where: and(eq(assets.id, params.assetId), eq(assets.userId, ctx.user.id)), + }); + + if (!asset) { + return Response.json({ error: "Asset not found" }, { status: 404 }); + } + return new Response(asset.blob as string, { + status: 200, + headers: { + "Content-type": asset.contentType, + }, + }); +} diff --git a/apps/web/app/api/assets/route.ts b/apps/web/app/api/assets/route.ts new file mode 100644 index 00000000..2caa4d4c --- /dev/null +++ b/apps/web/app/api/assets/route.ts @@ -0,0 +1,52 @@ +import { createContextFromRequest } from "@/server/api/client"; + +import type { ZUploadResponse } from "@hoarder/trpc/types/uploads"; +import { db } from "@hoarder/db"; +import { assets } from "@hoarder/db/schema"; + +const SUPPORTED_ASSET_TYPES = new Set(["image/jpeg", "image/png"]); + +const MAX_UPLOAD_SIZE_BYTES = 4 * 1024 * 1024; + +export const dynamic = "force-dynamic"; +export async function POST(request: Request) { + const ctx = await createContextFromRequest(request); + if (!ctx.user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const formData = await request.formData(); + const data = formData.get("image"); + let buffer; + let contentType; + if (data instanceof File) { + contentType = data.type; + if (!SUPPORTED_ASSET_TYPES.has(contentType)) { + return Response.json( + { error: "Unsupported asset type" }, + { status: 400 }, + ); + } + if (data.size > MAX_UPLOAD_SIZE_BYTES) { + return Response.json({ error: "Asset is too big" }, { status: 413 }); + } + buffer = Buffer.from(await data.arrayBuffer()); + } else { + return Response.json({ error: "Bad request" }, { status: 400 }); + } + + const [dbRes] = await db + .insert(assets) + .values({ + encoding: "binary", + contentType: contentType, + blob: buffer, + userId: ctx.user.id, + }) + .returning(); + + return Response.json({ + assetId: dbRes.id, + contentType: dbRes.contentType, + size: buffer.byteLength, + } satisfies ZUploadResponse); +} diff --git a/apps/web/app/api/trpc/[trpc]/route.ts b/apps/web/app/api/trpc/[trpc]/route.ts index 23df286f..1afcb886 100644 --- a/apps/web/app/api/trpc/[trpc]/route.ts +++ b/apps/web/app/api/trpc/[trpc]/route.ts @@ -1,8 +1,6 @@ -import { createContext } from "@/server/api/client"; +import { createContextFromRequest } from "@/server/api/client"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; -import { db } from "@hoarder/db"; -import { authenticateApiKey } from "@hoarder/trpc/auth"; import { appRouter } from "@hoarder/trpc/routers/_app"; const handler = (req: Request) => @@ -18,20 +16,7 @@ const handler = (req: Request) => }, createContext: async (opts) => { - // TODO: This is a hack until we offer a proper REST API instead of the trpc based one. - // Check if the request has an Authorization token, if it does, assume that API key authentication is requested. - const authorizationHeader = opts.req.headers.get("Authorization"); - if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) { - const token = authorizationHeader.split(" ")[1]; - try { - const user = await authenticateApiKey(token); - return { user, db }; - } catch (e) { - // Fallthrough to cookie-based auth - } - } - - return createContext(); + return await createContextFromRequest(opts.req); }, }); export { handler as GET, handler as POST }; diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx index dc3af9c7..27e06955 100644 --- a/apps/web/app/dashboard/layout.tsx +++ b/apps/web/app/dashboard/layout.tsx @@ -1,5 +1,6 @@ import MobileSidebar from "@/components/dashboard/sidebar/ModileSidebar"; import Sidebar from "@/components/dashboard/sidebar/Sidebar"; +import UploadDropzone from "@/components/dashboard/UploadDropzone"; import { Separator } from "@/components/ui/separator"; export default async function Dashboard({ @@ -17,7 +18,7 @@ export default async function Dashboard({ <MobileSidebar /> <Separator /> </div> - {children} + <UploadDropzone>{children}</UploadDropzone> </main> </div> ); diff --git a/apps/web/components/dashboard/UploadDropzone.tsx b/apps/web/components/dashboard/UploadDropzone.tsx new file mode 100644 index 00000000..61db8dc5 --- /dev/null +++ b/apps/web/components/dashboard/UploadDropzone.tsx @@ -0,0 +1,79 @@ +"use client"; + +import React, { useState } from "react"; +import { api } from "@/lib/trpc"; +import { useMutation } from "@tanstack/react-query"; +import DropZone from "react-dropzone"; + +import { + zUploadErrorSchema, + zUploadResponseSchema, +} from "@hoarder/trpc/types/uploads"; + +import { toast } from "../ui/use-toast"; + +export default function UploadDropzone({ + children, +}: { + children: React.ReactNode; +}) { + const invalidateAllBookmarks = + api.useUtils().bookmarks.getBookmarks.invalidate; + + const { mutate: createBookmark } = api.bookmarks.createBookmark.useMutation({ + onSuccess: () => { + toast({ description: "Bookmark uploaded" }); + invalidateAllBookmarks(); + }, + onError: () => { + toast({ description: "Something went wrong", variant: "destructive" }); + }, + }); + + const { mutate: uploadAsset } = useMutation({ + mutationFn: async (file: File) => { + const formData = new FormData(); + formData.append("image", file); + const resp = await fetch("/api/assets", { + method: "POST", + body: formData, + }); + if (!resp.ok) { + throw new Error(await resp.text()); + } + return zUploadResponseSchema.parse(await resp.json()); + }, + onSuccess: async (resp) => { + const assetId = resp.assetId; + createBookmark({ type: "asset", assetId, assetType: "image" }); + }, + onError: (error) => { + const err = zUploadErrorSchema.parse(JSON.parse(error.message)); + toast({ description: err.error, variant: "destructive" }); + }, + }); + + const [_isDragging, setDragging] = useState(false); + const onDrop = (acceptedFiles: File[]) => { + const file = acceptedFiles[0]; + setDragging(false); + uploadAsset(file); + }; + + return ( + <DropZone + multiple={false} + noClick + onDrop={onDrop} + onDragEnter={() => setDragging(true)} + onDragLeave={() => setDragging(false)} + > + {({ getRootProps, getInputProps }) => ( + <div {...getRootProps()}> + <input {...getInputProps()} hidden /> + {children} + </div> + )} + </DropZone> + ); +} diff --git a/apps/web/components/dashboard/bookmarks/AssetCard.tsx b/apps/web/components/dashboard/bookmarks/AssetCard.tsx new file mode 100644 index 00000000..460dbe98 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/AssetCard.tsx @@ -0,0 +1,76 @@ +"use client"; + +import Image from "next/image"; +import { isBookmarkStillTagging } from "@/lib/bookmarkUtils"; +import { api } from "@/lib/trpc"; +import { cn } from "@/lib/utils"; + +import type { ZBookmark } from "@hoarder/trpc/types/bookmarks"; + +import BookmarkActionBar from "./BookmarkActionBar"; +import TagList from "./TagList"; + +export default function AssetCard({ + bookmark: initialData, + className, +}: { + bookmark: ZBookmark; + className?: string; +}) { + const { data: bookmark } = api.bookmarks.getBookmark.useQuery( + { + bookmarkId: initialData.id, + }, + { + initialData, + refetchInterval: (query) => { + const data = query.state.data; + if (!data) { + return false; + } + if (isBookmarkStillTagging(data)) { + return 1000; + } + return false; + }, + }, + ); + const bookmarkedAsset = bookmark.content; + if (bookmarkedAsset.type != "asset") { + throw new Error("Unexpected bookmark type"); + } + + return ( + <div + className={cn( + className, + cn( + "flex h-min max-h-96 flex-col gap-y-1 overflow-hidden rounded-lg shadow-md", + ), + )} + > + {bookmarkedAsset.assetType == "image" && ( + <div className="relative h-56 max-h-56"> + <Image + alt="asset" + src={`/api/assets/${bookmarkedAsset.assetId}`} + fill={true} + className="rounded-t-lg object-cover" + /> + </div> + )} + <div className="flex flex-col gap-y-1 overflow-hidden p-2"> + <div className="flex h-full flex-wrap gap-1 overflow-hidden"> + <TagList + bookmark={bookmark} + loading={isBookmarkStillTagging(bookmark)} + /> + </div> + <div className="flex w-full justify-between"> + <div /> + <BookmarkActionBar bookmark={bookmark} /> + </div> + </div> + </div> + ); +} diff --git a/apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx b/apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx index 69aa60a3..4209192e 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx @@ -1,5 +1,6 @@ "use client"; +import Image from "next/image"; import Link from "next/link"; import { BackButton } from "@/components/ui/back-button"; import { Skeleton } from "@/components/ui/skeleton"; @@ -70,6 +71,22 @@ export default function BookmarkPreview({ content = <Markdown className="prose">{bookmark.content.text}</Markdown>; break; } + case "asset": { + switch (bookmark.content.assetType) { + case "image": { + content = ( + <div className="relative w-full"> + <Image + alt="asset" + fill={true} + src={`/api/assets/${bookmark.content.assetId}`} + /> + </div> + ); + } + } + break; + } } return ( diff --git a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx index b689a192..b40e6e42 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx @@ -14,6 +14,7 @@ import type { ZGetBookmarksResponse, } from "@hoarder/trpc/types/bookmarks"; +import AssetCard from "./AssetCard"; import EditorCard from "./EditorCard"; import LinkCard from "./LinkCard"; import TextCard from "./TextCard"; @@ -47,6 +48,9 @@ function renderBookmark(bookmark: ZBookmark) { case "text": comp = <TextCard bookmark={bookmark} />; break; + case "asset": + comp = <AssetCard bookmark={bookmark} />; + break; } return <BookmarkCard key={bookmark.id}>{comp}</BookmarkCard>; } diff --git a/apps/web/components/dashboard/bookmarks/EditorCard.tsx b/apps/web/components/dashboard/bookmarks/EditorCard.tsx index 28e8f41f..44c5889b 100644 --- a/apps/web/components/dashboard/bookmarks/EditorCard.tsx +++ b/apps/web/components/dashboard/bookmarks/EditorCard.tsx @@ -69,7 +69,9 @@ export default function EditorCard({ className }: { className?: string }) { <Textarea disabled={isPending} className="h-full w-full resize-none border-none text-lg focus-visible:ring-0" - placeholder={"Paste a link or write a note ..."} + placeholder={ + "Paste a link, write a note or drag and drop an image in here ..." + } onKeyDown={(e) => { if (e.key === "Enter" && e.metaKey) { form.handleSubmit(onSubmit, onError)(); diff --git a/apps/web/package.json b/apps/web/package.json index 4b0b5e0d..53c7014a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -48,6 +48,7 @@ "prettier": "^3.2.5", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", "react-hook-form": "^7.50.1", "react-markdown": "^9.0.1", "react-masonry-css": "^1.0.16", diff --git a/apps/web/server/api/client.ts b/apps/web/server/api/client.ts index 8b414d39..6a0a8909 100644 --- a/apps/web/server/api/client.ts +++ b/apps/web/server/api/client.ts @@ -2,8 +2,26 @@ import { getServerAuthSession } from "@/server/auth"; import { db } from "@hoarder/db"; import { Context, createCallerFactory } from "@hoarder/trpc"; +import { authenticateApiKey } from "@hoarder/trpc/auth"; import { appRouter } from "@hoarder/trpc/routers/_app"; +export async function createContextFromRequest(req: Request) { + // TODO: This is a hack until we offer a proper REST API instead of the trpc based one. + // Check if the request has an Authorization token, if it does, assume that API key authentication is requested. + const authorizationHeader = req.headers.get("Authorization"); + if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) { + const token = authorizationHeader.split(" ")[1]; + try { + const user = await authenticateApiKey(token); + return { user, db }; + } catch (e) { + // Fallthrough to cookie-based auth + } + } + + return createContext(); +} + export const createContext = async (database?: typeof db): Promise<Context> => { const session = await getServerAuthSession(); return { |
