aboutsummaryrefslogtreecommitdiffstats
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
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
-rw-r--r--.husky/pre-commit1
-rw-r--r--apps/mobile/app.json12
-rw-r--r--apps/mobile/app/dashboard/(tabs)/index.tsx36
-rw-r--r--apps/mobile/app/sharing.tsx34
-rw-r--r--apps/mobile/components/bookmarks/BookmarkCard.tsx33
-rw-r--r--apps/mobile/lib/upload.ts72
-rw-r--r--apps/mobile/package.json1
-rw-r--r--apps/mobile/tsconfig.json2
-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
-rw-r--r--apps/workers/openaiWorker.ts104
-rw-r--r--apps/workers/package.json5
-rw-r--r--packages/db/drizzle/0010_curved_sharon_ventura.sql11
-rw-r--r--packages/db/drizzle/0011_ordinary_phalanx.sql7
-rw-r--r--packages/db/drizzle/meta/0010_snapshot.json960
-rw-r--r--packages/db/drizzle/meta/0011_snapshot.json1017
-rw-r--r--packages/db/drizzle/meta/_journal.json14
-rw-r--r--packages/db/schema.ts95
-rw-r--r--packages/trpc/routers/bookmarks.ts34
-rw-r--r--packages/trpc/types/bookmarks.ts8
-rw-r--r--packages/trpc/types/uploads.ts13
-rw-r--r--pnpm-lock.yaml54
31 files changed, 2736 insertions, 79 deletions
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 (
<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={[
@@ -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 (
+ <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,
}: {
@@ -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>;
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({
<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 {
diff --git a/apps/workers/openaiWorker.ts b/apps/workers/openaiWorker.ts
index 1ec22d32..428f6027 100644
--- a/apps/workers/openaiWorker.ts
+++ b/apps/workers/openaiWorker.ts
@@ -1,19 +1,21 @@
+import { Job, Worker } from "bullmq";
+import { and, eq, inArray } from "drizzle-orm";
+import OpenAI from "openai";
+import { z } from "zod";
+
+import Base64 from "js-base64";
+
import { db } from "@hoarder/db";
-import logger from "@hoarder/shared/logger";
+import { assets, bookmarks, bookmarkTags, tagsOnBookmarks } from "@hoarder/db/schema";
import serverConfig from "@hoarder/shared/config";
+import logger from "@hoarder/shared/logger";
import {
OpenAIQueue,
+ queueConnectionDetails,
SearchIndexingQueue,
ZOpenAIRequest,
- queueConnectionDetails,
zOpenAIRequestSchema,
} from "@hoarder/shared/queues";
-import { Job } from "bullmq";
-import OpenAI from "openai";
-import { z } from "zod";
-import { Worker } from "bullmq";
-import { bookmarkTags, bookmarks, tagsOnBookmarks } from "@hoarder/db/schema";
-import { and, eq, inArray } from "drizzle-orm";
const openAIResponseSchema = z.object({
tags: z.array(z.string()),
@@ -67,7 +69,15 @@ export class OpenAiWorker {
}
}
-const PROMPT_BASE = `
+const IMAGE_PROMPT_BASE = `
+I'm building a read-it-later app and I need your help with automatic tagging.
+Please analyze the attached image and suggest relevant tags that describe its key themes, topics, and main ideas.
+Aim for a variety of tags, including broad categories, specific keywords, and potential sub-genres. If it's a famous website
+you may also include a tag for the website. If the tag is not generic enough, don't include it. Aim for 10-15 tags.
+If there are no good tags, don't emit any. You must respond in valid JSON with the key "tags" and the value is list of tags.
+Don't wrap the response in a markdown code.`;
+
+const TEXT_PROMPT_BASE = `
I'm building a read-it-later app and I need your help with automatic tagging.
Please analyze the text after the sentence "CONTENT START HERE:" and suggest relevant tags that describe its key themes, topics, and main ideas.
Aim for a variety of tags, including broad categories, specific keywords, and potential sub-genres. If it's a famous website
@@ -96,18 +106,18 @@ function buildPrompt(
}
}
return `
-${PROMPT_BASE}
+${TEXT_PROMPT_BASE}
URL: ${bookmark.link.url}
-Title: ${bookmark.link.title || ""}
-Description: ${bookmark.link.description || ""}
-Content: ${content || ""}
+Title: ${bookmark.link.title ?? ""}
+Description: ${bookmark.link.description ?? ""}
+Content: ${content ?? ""}
`;
}
if (bookmark.text) {
// TODO: Ensure that the content doesn't exceed the context length of openai
return `
-${PROMPT_BASE}
+${TEXT_PROMPT_BASE}
${bookmark.text.text}
`;
}
@@ -121,11 +131,55 @@ async function fetchBookmark(linkId: string) {
with: {
link: true,
text: true,
+ asset: true,
},
});
}
-async function inferTags(
+async function inferTagsFromImage(
+ jobId: string,
+ bookmark: NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>,
+ openai: OpenAI,
+) {
+
+ const asset = await db.query.assets.findFirst({
+ where: eq(assets.id, bookmark.asset.assetId),
+ });
+
+ if (!asset) {
+ throw new Error(`[openai][${jobId}] AssetId ${bookmark.asset.assetId} for bookmark ${bookmark.id} not found`);
+ }
+
+ const base64 = Base64.encode(asset.blob as string);
+
+ const chatCompletion = await openai.chat.completions.create({
+ model: "gpt-4-vision-preview",
+ messages: [
+ {
+ role: "user",
+ content: [
+ { type: "text", text: IMAGE_PROMPT_BASE },
+ {
+ type: "image_url",
+ image_url: {
+ url: `data:image/jpeg;base64,${base64}`,
+ detail: "low",
+ },
+ },
+ ],
+ },
+ ],
+ max_tokens: 2000,
+ });
+
+ const response = chatCompletion.choices[0].message.content;
+ if (!response) {
+ throw new Error(`[openai][${jobId}] Got no message content from OpenAI`);
+ }
+ return {response, totalTokens: chatCompletion.usage?.total_tokens};
+}
+
+async function inferTagsFromText(
jobId: string,
bookmark: NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>,
openai: OpenAI,
@@ -140,11 +194,27 @@ async function inferTags(
if (!response) {
throw new Error(`[openai][${jobId}] Got no message content from OpenAI`);
}
+ return {response, totalTokens: chatCompletion.usage?.total_tokens};
+}
+
+async function inferTags(
+ jobId: string,
+ bookmark: NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>,
+ openai: OpenAI,
+) {
+ let response;
+ if (bookmark.link || bookmark.text) {
+ response = await inferTagsFromText(jobId, bookmark, openai);
+ } else if (bookmark.asset) {
+ response = await inferTagsFromImage(jobId, bookmark, openai);
+ } else {
+ throw new Error(`[openai][${jobId}] Unsupported bookmark type`);
+ }
try {
- let tags = openAIResponseSchema.parse(JSON.parse(response)).tags;
+ let tags = openAIResponseSchema.parse(JSON.parse(response.response)).tags;
logger.info(
- `[openai][${jobId}] Inferring tag for bookmark "${bookmark.id}" used ${chatCompletion.usage?.total_tokens} tokens and inferred: ${tags}`,
+ `[openai][${jobId}] Inferring tag for bookmark "${bookmark.id}" used ${response.totalTokens} tokens and inferred: ${tags}`,
);
// Sometimes the tags contain the hashtag symbol, let's strip them out if they do.
diff --git a/apps/workers/package.json b/apps/workers/package.json
index 8c8edc4d..1c3813b8 100644
--- a/apps/workers/package.json
+++ b/apps/workers/package.json
@@ -6,12 +6,15 @@
"dependencies": {
"@hoarder/db": "workspace:^0.1.0",
"@hoarder/shared": "workspace:^0.1.0",
+ "@hoarder/tsconfig": "workspace:^0.1.0",
"@mozilla/readability": "^0.5.0",
+ "@tsconfig/node21": "^21.0.1",
"async-mutex": "^0.4.1",
"bullmq": "^5.1.9",
"dompurify": "^3.0.9",
"dotenv": "^16.4.1",
"drizzle-orm": "^0.29.4",
+ "js-base64": "^3.7.7",
"jsdom": "^24.0.0",
"metascraper": "^5.43.4",
"metascraper-description": "^5.43.4",
@@ -27,8 +30,6 @@
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-adblocker": "^2.13.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
- "@tsconfig/node21": "^21.0.1",
- "@hoarder/tsconfig": "workspace:^0.1.0",
"tsx": "^4.7.1",
"typescript": "^5.3.3",
"zod": "^3.22.4"
diff --git a/packages/db/drizzle/0010_curved_sharon_ventura.sql b/packages/db/drizzle/0010_curved_sharon_ventura.sql
new file mode 100644
index 00000000..e429cdfc
--- /dev/null
+++ b/packages/db/drizzle/0010_curved_sharon_ventura.sql
@@ -0,0 +1,11 @@
+CREATE TABLE `assets` (
+ `id` text PRIMARY KEY NOT NULL,
+ `userId` text NOT NULL,
+ `createdAt` integer NOT NULL,
+ `contentType` text NOT NULL,
+ `encoding` text NOT NULL,
+ `blob` blob NOT NULL,
+ FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE INDEX `assets_userId_idx` ON `assets` (`userId`); \ No newline at end of file
diff --git a/packages/db/drizzle/0011_ordinary_phalanx.sql b/packages/db/drizzle/0011_ordinary_phalanx.sql
new file mode 100644
index 00000000..c6540d20
--- /dev/null
+++ b/packages/db/drizzle/0011_ordinary_phalanx.sql
@@ -0,0 +1,7 @@
+CREATE TABLE `bookmarkAssets` (
+ `id` text PRIMARY KEY NOT NULL,
+ `assetType` text NOT NULL,
+ `assetId` text NOT NULL,
+ FOREIGN KEY (`id`) REFERENCES `bookmarks`(`id`) ON UPDATE no action ON DELETE cascade,
+ FOREIGN KEY (`assetId`) REFERENCES `assets`(`id`) ON UPDATE no action ON DELETE cascade
+);
diff --git a/packages/db/drizzle/meta/0010_snapshot.json b/packages/db/drizzle/meta/0010_snapshot.json
new file mode 100644
index 00000000..f3557a33
--- /dev/null
+++ b/packages/db/drizzle/meta/0010_snapshot.json
@@ -0,0 +1,960 @@
+{
+ "version": "5",
+ "dialect": "sqlite",
+ "id": "3c99cddb-9a13-42a5-a5e3-3b42cc33bad5",
+ "prevId": "11bbea06-c80c-4030-9a54-7dd9f1d13235",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_userId_user_id_fk": {
+ "name": "account_userId_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_providerAccountId_pk": {
+ "columns": [
+ "provider",
+ "providerAccountId"
+ ],
+ "name": "account_provider_providerAccountId_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyId": {
+ "name": "keyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyHash": {
+ "name": "keyHash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "apiKey_keyId_unique": {
+ "name": "apiKey_keyId_unique",
+ "columns": [
+ "keyId"
+ ],
+ "isUnique": true
+ },
+ "apiKey_name_userId_unique": {
+ "name": "apiKey_name_userId_unique",
+ "columns": [
+ "name",
+ "userId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "apiKey_userId_user_id_fk": {
+ "name": "apiKey_userId_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "assets": {
+ "name": "assets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contentType": {
+ "name": "contentType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "encoding": {
+ "name": "encoding",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "blob": {
+ "name": "blob",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "assets_userId_idx": {
+ "name": "assets_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "assets_userId_user_id_fk": {
+ "name": "assets_userId_user_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "bookmarkLinks": {
+ "name": "bookmarkLinks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "imageUrl": {
+ "name": "imageUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon": {
+ "name": "favicon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "htmlContent": {
+ "name": "htmlContent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "crawledAt": {
+ "name": "crawledAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkLinks_id_bookmarks_id_fk": {
+ "name": "bookmarkLinks_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkLinks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "bookmarkLists": {
+ "name": "bookmarkLists",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarkLists_userId_idx": {
+ "name": "bookmarkLists_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarkLists_name_userId_unique": {
+ "name": "bookmarkLists_name_userId_unique",
+ "columns": [
+ "name",
+ "userId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "bookmarkLists_userId_user_id_fk": {
+ "name": "bookmarkLists_userId_user_id_fk",
+ "tableFrom": "bookmarkLists",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "bookmarkTags": {
+ "name": "bookmarkTags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarkTags_name_idx": {
+ "name": "bookmarkTags_name_idx",
+ "columns": [
+ "name"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_userId_idx": {
+ "name": "bookmarkTags_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_userId_name_unique": {
+ "name": "bookmarkTags_userId_name_unique",
+ "columns": [
+ "userId",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "bookmarkTags_userId_user_id_fk": {
+ "name": "bookmarkTags_userId_user_id_fk",
+ "tableFrom": "bookmarkTags",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "bookmarkTexts": {
+ "name": "bookmarkTexts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkTexts_id_bookmarks_id_fk": {
+ "name": "bookmarkTexts_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkTexts",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "bookmarks": {
+ "name": "bookmarks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "archived": {
+ "name": "archived",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "favourited": {
+ "name": "favourited",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "taggingStatus": {
+ "name": "taggingStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ }
+ },
+ "indexes": {
+ "bookmarks_userId_idx": {
+ "name": "bookmarks_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_archived_idx": {
+ "name": "bookmarks_archived_idx",
+ "columns": [
+ "archived"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_favourited_idx": {
+ "name": "bookmarks_favourited_idx",
+ "columns": [
+ "favourited"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_createdAt_idx": {
+ "name": "bookmarks_createdAt_idx",
+ "columns": [
+ "createdAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarks_userId_user_id_fk": {
+ "name": "bookmarks_userId_user_id_fk",
+ "tableFrom": "bookmarks",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "bookmarksInLists": {
+ "name": "bookmarksInLists",
+ "columns": {
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedAt": {
+ "name": "addedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarksInLists_bookmarkId_idx": {
+ "name": "bookmarksInLists_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "bookmarksInLists_listId_idx": {
+ "name": "bookmarksInLists_listId_idx",
+ "columns": [
+ "listId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarksInLists_bookmarkId_bookmarks_id_fk": {
+ "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarksInLists_listId_bookmarkLists_id_fk": {
+ "name": "bookmarksInLists_listId_bookmarkLists_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "listId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "bookmarksInLists_bookmarkId_listId_pk": {
+ "columns": [
+ "bookmarkId",
+ "listId"
+ ],
+ "name": "bookmarksInLists_bookmarkId_listId_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "sessionToken": {
+ "name": "sessionToken",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_userId_user_id_fk": {
+ "name": "session_userId_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "tagsOnBookmarks": {
+ "name": "tagsOnBookmarks",
+ "columns": {
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attachedAt": {
+ "name": "attachedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "attachedBy": {
+ "name": "attachedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tagsOnBookmarks_tagId_idx": {
+ "name": "tagsOnBookmarks_tagId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "tagsOnBookmarks_bookmarkId_idx": {
+ "name": "tagsOnBookmarks_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": {
+ "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "tagsOnBookmarks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tagsOnBookmarks_tagId_bookmarkTags_id_fk": {
+ "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk",
+ "tableFrom": "tagsOnBookmarks",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "tagId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "tagsOnBookmarks_bookmarkId_tagId_pk": {
+ "columns": [
+ "bookmarkId",
+ "tagId"
+ ],
+ "name": "tagsOnBookmarks_bookmarkId_tagId_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'user'"
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "verificationToken": {
+ "name": "verificationToken",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "columns": [
+ "identifier",
+ "token"
+ ],
+ "name": "verificationToken_identifier_token_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ }
+ },
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ }
+} \ No newline at end of file
diff --git a/packages/db/drizzle/meta/0011_snapshot.json b/packages/db/drizzle/meta/0011_snapshot.json
new file mode 100644
index 00000000..f55a875d
--- /dev/null
+++ b/packages/db/drizzle/meta/0011_snapshot.json
@@ -0,0 +1,1017 @@
+{
+ "version": "5",
+ "dialect": "sqlite",
+ "id": "a5520e12-3a85-4783-af68-fc4c14ab5485",
+ "prevId": "3c99cddb-9a13-42a5-a5e3-3b42cc33bad5",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_userId_user_id_fk": {
+ "name": "account_userId_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_providerAccountId_pk": {
+ "columns": [
+ "provider",
+ "providerAccountId"
+ ],
+ "name": "account_provider_providerAccountId_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyId": {
+ "name": "keyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyHash": {
+ "name": "keyHash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "apiKey_keyId_unique": {
+ "name": "apiKey_keyId_unique",
+ "columns": [
+ "keyId"
+ ],
+ "isUnique": true
+ },
+ "apiKey_name_userId_unique": {
+ "name": "apiKey_name_userId_unique",
+ "columns": [
+ "name",
+ "userId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "apiKey_userId_user_id_fk": {
+ "name": "apiKey_userId_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "assets": {
+ "name": "assets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contentType": {
+ "name": "contentType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "encoding": {
+ "name": "encoding",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "blob": {
+ "name": "blob",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "assets_userId_idx": {
+ "name": "assets_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "assets_userId_user_id_fk": {
+ "name": "assets_userId_user_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "bookmarkAssets": {
+ "name": "bookmarkAssets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetType": {
+ "name": "assetType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetId": {
+ "name": "assetId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkAssets_id_bookmarks_id_fk": {
+ "name": "bookmarkAssets_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkAssets",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarkAssets_assetId_assets_id_fk": {
+ "name": "bookmarkAssets_assetId_assets_id_fk",
+ "tableFrom": "bookmarkAssets",
+ "tableTo": "assets",
+ "columnsFrom": [
+ "assetId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "bookmarkLinks": {
+ "name": "bookmarkLinks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "imageUrl": {
+ "name": "imageUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon": {
+ "name": "favicon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "htmlContent": {
+ "name": "htmlContent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "crawledAt": {
+ "name": "crawledAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkLinks_id_bookmarks_id_fk": {
+ "name": "bookmarkLinks_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkLinks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "bookmarkLists": {
+ "name": "bookmarkLists",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarkLists_userId_idx": {
+ "name": "bookmarkLists_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarkLists_name_userId_unique": {
+ "name": "bookmarkLists_name_userId_unique",
+ "columns": [
+ "name",
+ "userId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "bookmarkLists_userId_user_id_fk": {
+ "name": "bookmarkLists_userId_user_id_fk",
+ "tableFrom": "bookmarkLists",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "bookmarkTags": {
+ "name": "bookmarkTags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarkTags_name_idx": {
+ "name": "bookmarkTags_name_idx",
+ "columns": [
+ "name"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_userId_idx": {
+ "name": "bookmarkTags_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_userId_name_unique": {
+ "name": "bookmarkTags_userId_name_unique",
+ "columns": [
+ "userId",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "bookmarkTags_userId_user_id_fk": {
+ "name": "bookmarkTags_userId_user_id_fk",
+ "tableFrom": "bookmarkTags",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "bookmarkTexts": {
+ "name": "bookmarkTexts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkTexts_id_bookmarks_id_fk": {
+ "name": "bookmarkTexts_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkTexts",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "bookmarks": {
+ "name": "bookmarks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "archived": {
+ "name": "archived",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "favourited": {
+ "name": "favourited",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "taggingStatus": {
+ "name": "taggingStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ }
+ },
+ "indexes": {
+ "bookmarks_userId_idx": {
+ "name": "bookmarks_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_archived_idx": {
+ "name": "bookmarks_archived_idx",
+ "columns": [
+ "archived"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_favourited_idx": {
+ "name": "bookmarks_favourited_idx",
+ "columns": [
+ "favourited"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_createdAt_idx": {
+ "name": "bookmarks_createdAt_idx",
+ "columns": [
+ "createdAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarks_userId_user_id_fk": {
+ "name": "bookmarks_userId_user_id_fk",
+ "tableFrom": "bookmarks",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "bookmarksInLists": {
+ "name": "bookmarksInLists",
+ "columns": {
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedAt": {
+ "name": "addedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarksInLists_bookmarkId_idx": {
+ "name": "bookmarksInLists_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "bookmarksInLists_listId_idx": {
+ "name": "bookmarksInLists_listId_idx",
+ "columns": [
+ "listId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarksInLists_bookmarkId_bookmarks_id_fk": {
+ "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarksInLists_listId_bookmarkLists_id_fk": {
+ "name": "bookmarksInLists_listId_bookmarkLists_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "listId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "bookmarksInLists_bookmarkId_listId_pk": {
+ "columns": [
+ "bookmarkId",
+ "listId"
+ ],
+ "name": "bookmarksInLists_bookmarkId_listId_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "sessionToken": {
+ "name": "sessionToken",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_userId_user_id_fk": {
+ "name": "session_userId_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "tagsOnBookmarks": {
+ "name": "tagsOnBookmarks",
+ "columns": {
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attachedAt": {
+ "name": "attachedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "attachedBy": {
+ "name": "attachedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tagsOnBookmarks_tagId_idx": {
+ "name": "tagsOnBookmarks_tagId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "tagsOnBookmarks_bookmarkId_idx": {
+ "name": "tagsOnBookmarks_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": {
+ "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "tagsOnBookmarks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tagsOnBookmarks_tagId_bookmarkTags_id_fk": {
+ "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk",
+ "tableFrom": "tagsOnBookmarks",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "tagId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "tagsOnBookmarks_bookmarkId_tagId_pk": {
+ "columns": [
+ "bookmarkId",
+ "tagId"
+ ],
+ "name": "tagsOnBookmarks_bookmarkId_tagId_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'user'"
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "verificationToken": {
+ "name": "verificationToken",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "columns": [
+ "identifier",
+ "token"
+ ],
+ "name": "verificationToken_identifier_token_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ }
+ },
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ }
+} \ No newline at end of file
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index 93ea2df6..dd2059a8 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -71,6 +71,20 @@
"when": 1710681166089,
"tag": "0009_cuddly_cammi",
"breakpoints": true
+ },
+ {
+ "idx": 10,
+ "version": "5",
+ "when": 1710770092205,
+ "tag": "0010_curved_sharon_ventura",
+ "breakpoints": true
+ },
+ {
+ "idx": 11,
+ "version": "5",
+ "when": 1710778903315,
+ "tag": "0011_ordinary_phalanx",
+ "breakpoints": true
}
]
} \ No newline at end of file
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 6063fccc..769632d9 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -1,14 +1,15 @@
+import type { AdapterAccount } from "@auth/core/adapters";
+import { createId } from "@paralleldrive/cuid2";
+import { relations } from "drizzle-orm";
import {
+ blob,
+ index,
integer,
+ primaryKey,
sqliteTable,
text,
- primaryKey,
unique,
- index,
} from "drizzle-orm/sqlite-core";
-import type { AdapterAccount } from "@auth/core/adapters";
-import { createId } from "@paralleldrive/cuid2";
-import { relations } from "drizzle-orm";
function createdAtField() {
return integer("createdAt", { mode: "timestamp" })
@@ -96,28 +97,32 @@ export const apiKeys = sqliteTable(
}),
);
-export const bookmarks = sqliteTable("bookmarks", {
- id: text("id")
- .notNull()
- .primaryKey()
- .$defaultFn(() => createId()),
- createdAt: createdAtField(),
- archived: integer("archived", { mode: "boolean" }).notNull().default(false),
- favourited: integer("favourited", { mode: "boolean" })
- .notNull()
- .default(false),
- userId: text("userId")
- .notNull()
- .references(() => users.id, { onDelete: "cascade" }),
- taggingStatus: text("taggingStatus", {
- enum: ["pending", "failure", "success"],
- }).default("pending"),
-}, (b) => ({
- userIdIdx: index("bookmarks_userId_idx").on(b.userId),
- archivedIdx: index("bookmarks_archived_idx").on(b.archived),
- favIdx: index("bookmarks_favourited_idx").on(b.favourited),
- createdAtIdx: index("bookmarks_createdAt_idx").on(b.createdAt),
-}));
+export const bookmarks = sqliteTable(
+ "bookmarks",
+ {
+ id: text("id")
+ .notNull()
+ .primaryKey()
+ .$defaultFn(() => createId()),
+ createdAt: createdAtField(),
+ archived: integer("archived", { mode: "boolean" }).notNull().default(false),
+ favourited: integer("favourited", { mode: "boolean" })
+ .notNull()
+ .default(false),
+ userId: text("userId")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ taggingStatus: text("taggingStatus", {
+ enum: ["pending", "failure", "success"],
+ }).default("pending"),
+ },
+ (b) => ({
+ userIdIdx: index("bookmarks_userId_idx").on(b.userId),
+ archivedIdx: index("bookmarks_archived_idx").on(b.archived),
+ favIdx: index("bookmarks_favourited_idx").on(b.favourited),
+ createdAtIdx: index("bookmarks_createdAt_idx").on(b.createdAt),
+ }),
+);
export const bookmarkLinks = sqliteTable("bookmarkLinks", {
id: text("id")
@@ -146,6 +151,18 @@ export const bookmarkTexts = sqliteTable("bookmarkTexts", {
text: text("text"),
});
+export const bookmarkAssets = sqliteTable("bookmarkAssets", {
+ id: text("id")
+ .notNull()
+ .primaryKey()
+ .$defaultFn(() => createId())
+ .references(() => bookmarks.id, { onDelete: "cascade" }),
+ assetType: text("assetType", { enum: ["image"] }).notNull(),
+ assetId: text("assetId")
+ .notNull()
+ .references(() => assets.id, { onDelete: "cascade" }),
+});
+
export const bookmarkTags = sqliteTable(
"bookmarkTags",
{
@@ -208,6 +225,26 @@ export const bookmarkLists = sqliteTable(
}),
);
+export const assets = sqliteTable(
+ "assets",
+ {
+ id: text("id")
+ .notNull()
+ .primaryKey()
+ .$defaultFn(() => createId()),
+ userId: text("userId")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ createdAt: createdAtField(),
+ contentType: text("contentType").notNull(),
+ encoding: text("encoding", { enum: ["binary"] }).notNull(),
+ blob: blob("blob").notNull(),
+ },
+ (a) => ({
+ userIdIdx: index("assets_userId_idx").on(a.userId),
+ }),
+);
+
export const bookmarksInLists = sqliteTable(
"bookmarksInLists",
{
@@ -248,6 +285,10 @@ export const bookmarkRelations = relations(bookmarks, ({ many, one }) => ({
fields: [bookmarks.id],
references: [bookmarkTexts.id],
}),
+ asset: one(bookmarkAssets, {
+ fields: [bookmarks.id],
+ references: [bookmarkAssets.id],
+ }),
tagsOnBookmarks: many(tagsOnBookmarks),
bookmarksInLists: many(bookmarksInLists),
}));
diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts
index c2d78c7f..f0abda84 100644
--- a/packages/trpc/routers/bookmarks.ts
+++ b/packages/trpc/routers/bookmarks.ts
@@ -5,6 +5,7 @@ import { z } from "zod";
import { db as DONT_USE_db } from "@hoarder/db";
import {
+ bookmarkAssets,
bookmarkLinks,
bookmarks,
bookmarksInLists,
@@ -75,6 +76,7 @@ async function dummyDrizzleReturnType() {
},
link: true,
text: true,
+ asset: true,
},
});
if (!x) {
@@ -88,13 +90,19 @@ type BookmarkQueryReturnType = Awaited<
>;
function toZodSchema(bookmark: BookmarkQueryReturnType): ZBookmark {
- const { tagsOnBookmarks, link, text, ...rest } = bookmark;
+ const { tagsOnBookmarks, link, text, asset, ...rest } = bookmark;
let content: ZBookmarkContent;
if (link) {
content = { type: "link", ...link };
} else if (text) {
content = { type: "text", text: text.text ?? "" };
+ } else if (asset) {
+ content = {
+ type: "asset",
+ assetType: asset.assetType,
+ assetId: asset.assetId,
+ };
} else {
throw new Error("Unknown content type");
}
@@ -157,6 +165,22 @@ export const bookmarksAppRouter = router({
};
break;
}
+ case "asset": {
+ const [asset] = await tx
+ .insert(bookmarkAssets)
+ .values({
+ id: bookmark.id,
+ assetType: input.assetType,
+ assetId: input.assetId,
+ })
+ .returning();
+ content = {
+ type: "asset",
+ assetType: asset.assetType,
+ assetId: asset.assetId,
+ };
+ break;
+ }
}
return {
@@ -176,7 +200,8 @@ export const bookmarksAppRouter = router({
});
break;
}
- case "text": {
+ case "text":
+ case "asset": {
await OpenAIQueue.add("openai", {
bookmarkId: bookmark.id,
});
@@ -292,6 +317,7 @@ export const bookmarksAppRouter = router({
},
link: true,
text: true,
+ asset: true,
},
});
if (!bookmark) {
@@ -341,6 +367,7 @@ export const bookmarksAppRouter = router({
},
link: true,
text: true,
+ asset: true,
},
});
@@ -412,6 +439,7 @@ export const bookmarksAppRouter = router({
.leftJoin(bookmarkTags, eq(tagsOnBookmarks.tagId, bookmarkTags.id))
.leftJoin(bookmarkLinks, eq(bookmarkLinks.id, sq.id))
.leftJoin(bookmarkTexts, eq(bookmarkTexts.id, sq.id))
+ .leftJoin(bookmarkAssets, eq(bookmarkAssets.id, sq.id))
.orderBy(desc(sq.createdAt));
const bookmarksRes = results.reduce<Record<string, ZBookmark>>(
@@ -423,6 +451,8 @@ export const bookmarksAppRouter = router({
content = { type: "link", ...row.bookmarkLinks };
} else if (row.bookmarkTexts) {
content = { type: "text", text: row.bookmarkTexts.text ?? "" };
+ } else if (row.bookmarkAssets) {
+ content = { type: "asset", assetId: row.bookmarkAssets.assetId, assetType: row.bookmarkAssets.assetType };
} else {
throw new Error("Unknown content type");
}
diff --git a/packages/trpc/types/bookmarks.ts b/packages/trpc/types/bookmarks.ts
index e366859e..f8848d35 100644
--- a/packages/trpc/types/bookmarks.ts
+++ b/packages/trpc/types/bookmarks.ts
@@ -19,9 +19,17 @@ export const zBookmarkedTextSchema = z.object({
});
export type ZBookmarkedText = z.infer<typeof zBookmarkedTextSchema>;
+export const zBookmarkedAssetSchema = z.object({
+ type: z.literal("asset"),
+ assetType: z.enum(["image"]),
+ assetId: z.string(),
+});
+export type ZBookmarkedAsset = z.infer<typeof zBookmarkedAssetSchema>;
+
export const zBookmarkContentSchema = z.discriminatedUnion("type", [
zBookmarkedLinkSchema,
zBookmarkedTextSchema,
+ zBookmarkedAssetSchema,
]);
export type ZBookmarkContent = z.infer<typeof zBookmarkContentSchema>;
diff --git a/packages/trpc/types/uploads.ts b/packages/trpc/types/uploads.ts
new file mode 100644
index 00000000..a378f802
--- /dev/null
+++ b/packages/trpc/types/uploads.ts
@@ -0,0 +1,13 @@
+import { z } from "zod";
+
+export const zUploadErrorSchema = z.object({
+ error: z.string(),
+});
+
+export const zUploadResponseSchema = z.object({
+ assetId: z.string(),
+ contentType: z.string(),
+ size: z.number(),
+});
+
+export type ZUploadResponse = z.infer<typeof zUploadResponseSchema>;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 72229bc1..1ea75147 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -170,6 +170,9 @@ importers:
expo-image:
specifier: ^1.10.6
version: 1.10.6(expo@50.0.11)
+ expo-image-picker:
+ specifier: ^14.7.1
+ version: 14.7.1(expo@50.0.11)
expo-linking:
specifier: ~6.2.2
version: 6.2.2(expo@50.0.11)
@@ -363,6 +366,9 @@ importers:
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
+ react-dropzone:
+ specifier: ^14.2.3
+ version: 14.2.3(react@18.2.0)
react-hook-form:
specifier: ^7.50.1
version: 7.50.1(react@18.2.0)
@@ -463,6 +469,9 @@ importers:
drizzle-orm:
specifier: ^0.29.4
version: 0.29.4(@types/react@18.2.58)(better-sqlite3@9.4.3)(react@18.2.0)
+ js-base64:
+ specifier: ^3.7.7
+ version: 3.7.7
jsdom:
specifier: ^24.0.0
version: 24.0.0
@@ -6946,6 +6955,11 @@ packages:
engines: {node: '>= 4.0.0'}
dev: false
+ /attr-accept@2.2.2:
+ resolution: {integrity: sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==}
+ engines: {node: '>=4'}
+ dev: false
+
/audio-extensions@0.0.0:
resolution: {integrity: sha512-yj9C819u3ED3/OyRd9mLKMXGy8wsElaf6bkkv6OqZEKrNAT461TjiznS4IfPBy8Mmh6DWaXCQCVYSq3+VHkpjQ==}
engines: {node: '>=0.10.0'}
@@ -9668,6 +9682,23 @@ packages:
expo: 50.0.11(@babel/core@7.23.9)(@react-native/babel-preset@0.73.21)
dev: false
+ /expo-image-loader@4.6.0(expo@50.0.11):
+ resolution: {integrity: sha512-RHQTDak7/KyhWUxikn2yNzXL7i2cs16cMp6gEAgkHOjVhoCJQoOJ0Ljrt4cKQ3IowxgCuOrAgSUzGkqs7omj8Q==}
+ peerDependencies:
+ expo: '*'
+ dependencies:
+ expo: 50.0.11(@babel/core@7.23.9)(@react-native/babel-preset@0.73.21)
+ dev: false
+
+ /expo-image-picker@14.7.1(expo@50.0.11):
+ resolution: {integrity: sha512-ILQVOJgI3aEzrDmCFGDPtpAepYkn8mot8G7vfQ51BfFdQbzL6N3Wm1fS/ofdWlAZJl/qT2DwaIh5xYmf3SyGZA==}
+ peerDependencies:
+ expo: '*'
+ dependencies:
+ expo: 50.0.11(@babel/core@7.23.9)(@react-native/babel-preset@0.73.21)
+ expo-image-loader: 4.6.0(expo@50.0.11)
+ dev: false
+
/expo-image@1.10.6(expo@50.0.11):
resolution: {integrity: sha512-vcnAIym1eU8vQgV1re1E7rVQZStJimBa4aPDhjFfzMzbddAF7heJuagyewiUkTzbZUwYzPaZAie6VJPyWx9Ueg==}
peerDependencies:
@@ -9991,6 +10022,13 @@ packages:
engines: {node: '>=4'}
dev: false
+ /file-selector@0.6.0:
+ resolution: {integrity: sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==}
+ engines: {node: '>= 12'}
+ dependencies:
+ tslib: 2.6.2
+ dev: false
+
/file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
dev: false
@@ -11417,6 +11455,10 @@ packages:
resolution: {integrity: sha512-/WByRr4jDcsKlvMd1dRJnPfS1GVO3WuKyaurJ/vvXcOaUQO8rnNObCQMlv/5uCceVQIq5Q4WLF44ohsdiTohdg==}
dev: false
+ /js-base64@3.7.7:
+ resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==}
+ dev: false
+
/js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -14607,6 +14649,18 @@ packages:
scheduler: 0.23.0
dev: false
+ /react-dropzone@14.2.3(react@18.2.0):
+ resolution: {integrity: sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==}
+ engines: {node: '>= 10.13'}
+ peerDependencies:
+ react: '>= 16.8 || 18.0.0'
+ dependencies:
+ attr-accept: 2.2.2
+ file-selector: 0.6.0
+ prop-types: 15.8.1
+ react: 18.2.0
+ dev: false
+
/react-fast-compare@3.2.2:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
dev: false