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/components/dashboard | |
| 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/components/dashboard')
5 files changed, 179 insertions, 1 deletions
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)(); |
