diff --git a/.husky/pre-commit b/.husky/pre-commit index d466e587..09be6810 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,3 @@ +pnpm typecheck pnpm format pnpm lint diff --git a/apps/mobile/app.json b/apps/mobile/app.json index e674f8b5..9df4c895 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -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", @@ -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": { diff --git a/apps/mobile/app/dashboard/(tabs)/index.tsx b/apps/mobile/app/dashboard/(tabs)/index.tsx index 7f70af6b..a840ca93 100644 --- a/apps/mobile/app/dashboard/(tabs)/index.tsx +++ b/apps/mobile/app/dashboard/(tabs)/index.tsx @@ -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 ( { + 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={[ @@ -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} > diff --git a/apps/mobile/app/sharing.tsx b/apps/mobile/app/sharing.tsx index e8b0ad09..2f9dbb27 100644 --- a/apps/mobile/app/sharing.tsx +++ b/apps/mobile/app/sharing.tsx @@ -2,8 +2,11 @@ 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" } @@ -11,12 +14,28 @@ type Mode = | { 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) { @@ -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" }); }, diff --git a/apps/mobile/components/bookmarks/BookmarkCard.tsx b/apps/mobile/components/bookmarks/BookmarkCard.tsx index a969bc8b..ac6eaea4 100644 --- a/apps/mobile/components/bookmarks/BookmarkCard.tsx +++ b/apps/mobile/components/bookmarks/BookmarkCard.tsx @@ -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"; @@ -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 ( + + + + + + + + + + + + ); +} + export default function BookmarkCard({ bookmark: initialData, }: { @@ -272,6 +302,9 @@ export default function BookmarkCard({ case "text": comp = ; break; + case "asset": + comp = ; + break; } return {comp}; diff --git a/apps/mobile/lib/upload.ts b/apps/mobile/lib/upload.ts new file mode 100644 index 00000000..d511becc --- /dev/null +++ b/apps/mobile/lib/upload.ts @@ -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, + }; +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b35c420c..cf097c09 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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", diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 3bcf5741..77379cd1 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -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": ".", 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({ - {children} + {children} ); 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 ( + setDragging(true)} + onDragLeave={() => setDragging(false)} + > + {({ getRootProps, getInputProps }) => ( +
+ + {children} +
+ )} +
+ ); +} 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 ( +
+ {bookmarkedAsset.assetType == "image" && ( +
+ asset +
+ )} +
+
+ +
+
+
+ +
+
+
+ ); +} 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 = {bookmark.content.text}; break; } + case "asset": { + switch (bookmark.content.assetType) { + case "image": { + content = ( +
+ asset +
+ ); + } + } + 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 = ; break; + case "asset": + comp = ; + break; } return {comp}; } 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 }) {