aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2024-03-19 00:33:11 +0000
committerGitHub <noreply@github.com>2024-03-19 00:33:11 +0000
commit785a5b574992296e187a66412dd42f7b4a686353 (patch)
tree64b608927cc63d7494395f639636fd4b36e5a977 /apps/web
parent549520919c482e72cdf7adae5ba852d1b6cbe5aa (diff)
downloadkarakeep-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.ts29
-rw-r--r--apps/web/app/api/assets/route.ts52
-rw-r--r--apps/web/app/api/trpc/[trpc]/route.ts19
-rw-r--r--apps/web/app/dashboard/layout.tsx3
-rw-r--r--apps/web/components/dashboard/UploadDropzone.tsx79
-rw-r--r--apps/web/components/dashboard/bookmarks/AssetCard.tsx76
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx17
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx4
-rw-r--r--apps/web/components/dashboard/bookmarks/EditorCard.tsx4
-rw-r--r--apps/web/package.json1
-rw-r--r--apps/web/server/api/client.ts18
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 {