Skip to content

Commit

Permalink
Feature: Add support for uploading images and automatically inferring…
Browse files Browse the repository at this point in the history
… 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
  • Loading branch information
MohamedBassem authored Mar 19, 2024
1 parent 5495209 commit 785a5b5
Show file tree
Hide file tree
Showing 31 changed files with 2,736 additions and 79 deletions.
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pnpm typecheck
pnpm format
pnpm lint
12 changes: 9 additions & 3 deletions apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "Hoarder App",
"slug": "hoarder",
"scheme": "hoarder",
"version": "1.2.4",
"version": "1.3.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
Expand Down Expand Up @@ -35,13 +35,19 @@
"iosActivationRules": {
"NSExtensionActivationSupportsWebURLWithMaxCount": 1,
"NSExtensionActivationSupportsWebPageWithMaxCount": 1,
"NSExtensionActivationSupportsImageWithMaxCount": 0,
"NSExtensionActivationSupportsImageWithMaxCount": 1,
"NSExtensionActivationSupportsMovieWithMaxCount": 0,
"NSExtensionActivationSupportsText": true
}
}
],
"expo-secure-store"
"expo-secure-store",
[
"expo-image-picker",
{
"photosPermission": "The app access your photo gallary on your request to hoard them."
}
]
],
"extra": {
"router": {
Expand Down
36 changes: 34 additions & 2 deletions apps/mobile/app/dashboard/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,45 @@
import { Platform, SafeAreaView, View } from "react-native";
import * as Haptics from "expo-haptics";
import * as ImagePicker from "expo-image-picker";
import { useRouter } from "expo-router";
import BookmarkList from "@/components/bookmarks/BookmarkList";
import PageTitle from "@/components/ui/PageTitle";
import useAppSettings from "@/lib/settings";
import { useUploadAsset } from "@/lib/upload";
import { MenuView } from "@react-native-menu/menu";
import { SquarePen } from "lucide-react-native";
import { useToast } from "@/components/ui/Toast";

function HeaderRight() {
const {toast} = useToast();
const router = useRouter();
const { settings } = useAppSettings();
const { uploadAsset } = useUploadAsset(settings, {
onError: (e) => {
toast({message: e, variant: "destructive"});
},
});
return (
<MenuView
onPressAction={({ nativeEvent }) => {
onPressAction={async ({ nativeEvent }) => {
Haptics.selectionAsync();
if (nativeEvent.event === "note") {
router.navigate("dashboard/add-note");
} else if (nativeEvent.event === "link") {
router.navigate("dashboard/add-link");
} else if (nativeEvent.event === "library") {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0,
allowsMultipleSelection: false,
});
if (!result.canceled) {
uploadAsset({
type: result.assets[0].mimeType ?? "",
name: result.assets[0].fileName ?? "",
uri: result.assets[0].uri,
});
}
}
}}
actions={[
Expand All @@ -31,10 +55,18 @@ function HeaderRight() {
id: "note",
title: "New Note",
image: Platform.select({
ios: "note",
ios: "note.text",
android: "ic_menu_note",
}),
},
{
id: "library",
title: "Photo Library",
image: Platform.select({
ios: "photo",
android: "ic_menu_photo",
}),
},
]}
shouldOpenOnLongPress={false}
>
Expand Down
34 changes: 28 additions & 6 deletions apps/mobile/app/sharing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,40 @@ import { useEffect, useState } from "react";
import { Text, View } from "react-native";
import { Link, useRouter } from "expo-router";
import { useShareIntentContext } from "expo-share-intent";
import useAppSettings from "@/lib/settings";
import { api } from "@/lib/trpc";
import { useUploadAsset } from "@/lib/upload";
import { z } from "zod";
import type { ZBookmark } from "@hoarder/trpc/types/bookmarks";

type Mode =
| { type: "idle" }
| { type: "success"; bookmarkId: string }
| { type: "error" };

function SaveBookmark({ setMode }: { setMode: (mode: Mode) => void }) {
const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntentContext();
const onSaved = (d: ZBookmark) => {
invalidateAllBookmarks();
setMode({ type: "success", bookmarkId: d.id });
};

const { hasShareIntent, shareIntent, resetShareIntent } =
useShareIntentContext();
const { settings, isLoading } = useAppSettings();
const { uploadAsset } = useUploadAsset(settings, {
onSuccess: onSaved,
onError: () => {
setMode({ type: "error" });
},
});

const invalidateAllBookmarks =
api.useUtils().bookmarks.getBookmarks.invalidate;

useEffect(() => {
if (isLoading) {
return;
}
if (!isPending && shareIntent?.text) {
const val = z.string().url();
if (val.safeParse(shareIntent.text).success) {
Expand All @@ -25,17 +44,20 @@ function SaveBookmark({ setMode }: { setMode: (mode: Mode) => void }) {
} else {
mutate({ type: "text", text: shareIntent.text });
}
} else if (!isPending && shareIntent?.files) {
uploadAsset({
type: shareIntent.files[0].type,
name: shareIntent.files[0].fileName ?? "",
uri: shareIntent.files[0].path,
});
}
if (hasShareIntent) {
resetShareIntent();
}
}, []);
}, [isLoading]);

const { mutate, isPending } = api.bookmarks.createBookmark.useMutation({
onSuccess: (d) => {
invalidateAllBookmarks();
setMode({ type: "success", bookmarkId: d.id });
},
onSuccess: onSaved,
onError: () => {
setMode({ type: "error" });
},
Expand Down
33 changes: 33 additions & 0 deletions apps/mobile/components/bookmarks/BookmarkCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Markdown from "react-native-markdown-display";
import * as Haptics from "expo-haptics";
import { Link } from "expo-router";
import * as WebBrowser from "expo-web-browser";
import useAppSettings from "@/lib/settings";
import { api } from "@/lib/trpc";
import { MenuView } from "@react-native-menu/menu";
import { Ellipsis, Star } from "lucide-react-native";
Expand Down Expand Up @@ -239,6 +240,35 @@ function TextCard({ bookmark }: { bookmark: ZBookmark }) {
);
}

function AssetCard({ bookmark }: { bookmark: ZBookmark }) {
const { settings } = useAppSettings();
if (bookmark.content.type !== "asset") {
throw new Error("Wrong content type rendered");
}

return (
<View className="flex gap-2">
<Image
source={{
uri: `${settings.address}/api/assets/${bookmark.content.assetId}`,
headers: {
Authorization: `Bearer ${settings.apiKey}`,
},
}}
className="h-56 min-h-56 w-full object-cover"
/>
<View className="flex gap-2 p-2">
<TagList bookmark={bookmark} />
<Divider orientation="vertical" className="mt-2 h-0.5 w-full" />
<View className="mt-2 flex flex-row justify-between px-2 pb-2">
<View />
<ActionBar bookmark={bookmark} />
</View>
</View>
</View>
);
}

export default function BookmarkCard({
bookmark: initialData,
}: {
Expand Down Expand Up @@ -272,6 +302,9 @@ export default function BookmarkCard({
case "text":
comp = <TextCard bookmark={bookmark} />;
break;
case "asset":
comp = <AssetCard bookmark={bookmark} />;
break;
}

return <View className="border-b border-gray-300 bg-white">{comp}</View>;
Expand Down
72 changes: 72 additions & 0 deletions apps/mobile/lib/upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useMutation } from "@tanstack/react-query";

import type { Settings } from "./settings";
import { api } from "./trpc";
import type { ZBookmark } from "@hoarder/trpc/types/bookmarks";
import { zUploadResponseSchema, zUploadErrorSchema } from "@hoarder/trpc/types/uploads";

export function useUploadAsset(
settings: Settings,
options: { onSuccess?: (bookmark: ZBookmark) => void; onError?: (e: string) => void },
) {
const invalidateAllBookmarks =
api.useUtils().bookmarks.getBookmarks.invalidate;

const {
mutate: createBookmark,
isPending: isCreatingBookmark,
} = api.bookmarks.createBookmark.useMutation({
onSuccess: (d) => {
invalidateAllBookmarks();
if (options.onSuccess) {
options.onSuccess(d);
}
},
onError: (e) => {
if (options.onError) {
options.onError(e.message);
}
},
});

const {
mutate: uploadAsset,
isPending: isUploading,
} = useMutation({
mutationFn: async (file: { type: string; name: string; uri: string }) => {
const formData = new FormData();
// @ts-expect-error This is a valid api in react native
formData.append("image", {
uri: file.uri,
name: file.name,
type: file.type,
});
const resp = await fetch(`${settings.address}/api/assets`, {
method: "POST",
body: formData,
headers: {
Authorization: `Bearer ${settings.apiKey}`,
},
});
if (!resp.ok) {
throw new Error(await resp.text());
}
return zUploadResponseSchema.parse(await resp.json());
},
onSuccess: (resp) => {
const assetId = resp.assetId;
createBookmark({ type: "asset", assetId, assetType: "image" });
},
onError: (e) => {
if (options.onError) {
const err = zUploadErrorSchema.parse(JSON.parse(e.message));
options.onError(err.error);
}
},
});

return {
uploadAsset,
isPending: isUploading || isCreatingBookmark,
};
}
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"expo-dev-client": "^3.3.9",
"expo-haptics": "^12.8.1",
"expo-image": "^1.10.6",
"expo-image-picker": "^14.7.1",
"expo-linking": "~6.2.2",
"expo-router": "~3.4.8",
"expo-secure-store": "^12.8.1",
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"extends": "expo/tsconfig.base",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
"types": ["nativewind/types"],
"types": ["nativewind/types", "react-native"],
"incremental": true,
"strict": true,
"baseUrl": ".",
Expand Down
29 changes: 29 additions & 0 deletions apps/web/app/api/assets/[assetId]/route.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
}
52 changes: 52 additions & 0 deletions apps/web/app/api/assets/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading

0 comments on commit 785a5b5

Please sign in to comment.