aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorMohamedBassem <me@mbassem.com>2024-03-13 21:43:44 +0000
committerMohamed Bassem <me@mbassem.com>2024-03-14 16:40:45 +0000
commit04572a8e5081b1e4871e273cde9dbaaa44c52fe0 (patch)
tree8e993acb732a50d1306d4d6953df96c165c57f57 /apps
parent2df08ed08c065e8b91bc8df0266bd4bcbb062be4 (diff)
downloadkarakeep-04572a8e5081b1e4871e273cde9dbaaa44c52fe0.tar.zst
structure: Create apps dir and copy tooling dir from t3-turbo repo
Diffstat (limited to 'apps')
-rw-r--r--apps/browser-extension/.eslintrc.cjs3
-rw-r--r--apps/browser-extension/.gitignore24
-rw-r--r--apps/browser-extension/index.html13
-rw-r--r--apps/browser-extension/manifest.json19
-rw-r--r--apps/browser-extension/package.json55
-rw-r--r--apps/browser-extension/postcss.config.js6
-rw-r--r--apps/browser-extension/public/logo-128.pngbin0 -> 2362 bytes
-rw-r--r--apps/browser-extension/public/logo-16.pngbin0 -> 287 bytes
-rw-r--r--apps/browser-extension/public/logo-48.pngbin0 -> 780 bytes
-rw-r--r--apps/browser-extension/public/logo.pngbin0 -> 412 bytes
-rw-r--r--apps/browser-extension/src/BookmarkDeletedPage.tsx3
-rw-r--r--apps/browser-extension/src/BookmarkSavedPage.tsx62
-rw-r--r--apps/browser-extension/src/Layout.tsx51
-rw-r--r--apps/browser-extension/src/Logo.tsx10
-rw-r--r--apps/browser-extension/src/NotConfiguredPage.tsx47
-rw-r--r--apps/browser-extension/src/OptionsPage.tsx60
-rw-r--r--apps/browser-extension/src/SavePage.tsx60
-rw-r--r--apps/browser-extension/src/SignInPage.tsx89
-rw-r--r--apps/browser-extension/src/Spinner.tsx18
-rw-r--r--apps/browser-extension/src/assets/react.svg1
-rw-r--r--apps/browser-extension/src/index.css72
-rw-r--r--apps/browser-extension/src/main.tsx40
-rw-r--r--apps/browser-extension/src/utils/providers.tsx46
-rw-r--r--apps/browser-extension/src/utils/settings.ts22
-rw-r--r--apps/browser-extension/src/utils/trpc.ts4
-rw-r--r--apps/browser-extension/src/vite-env.d.ts1
-rw-r--r--apps/browser-extension/tailwind.config.js10
-rw-r--r--apps/browser-extension/tsconfig.json25
-rw-r--r--apps/browser-extension/tsconfig.node.json11
-rw-r--r--apps/browser-extension/vite.config.ts9
-rw-r--r--apps/mobile/.eslintrc.js4
-rw-r--r--apps/mobile/.gitignore39
-rw-r--r--apps/mobile/.npmrc1
-rw-r--r--apps/mobile/app.json57
-rw-r--r--apps/mobile/app/+not-found.tsx6
-rw-r--r--apps/mobile/app/_layout.tsx53
-rw-r--r--apps/mobile/app/dashboard/(tabs)/_layout.tsx38
-rw-r--r--apps/mobile/app/dashboard/(tabs)/index.tsx31
-rw-r--r--apps/mobile/app/dashboard/(tabs)/lists.tsx67
-rw-r--r--apps/mobile/app/dashboard/(tabs)/search.tsx35
-rw-r--r--apps/mobile/app/dashboard/(tabs)/settings.tsx41
-rw-r--r--apps/mobile/app/dashboard/_layout.tsx38
-rw-r--r--apps/mobile/app/dashboard/add-link.tsx57
-rw-r--r--apps/mobile/app/dashboard/add-note.tsx53
-rw-r--r--apps/mobile/app/dashboard/archive.tsx11
-rw-r--r--apps/mobile/app/dashboard/favourites.tsx11
-rw-r--r--apps/mobile/app/dashboard/lists/[slug].tsx31
-rw-r--r--apps/mobile/app/error.tsx9
-rw-r--r--apps/mobile/app/index.tsx20
-rw-r--r--apps/mobile/app/sharing.tsx99
-rw-r--r--apps/mobile/app/signin.tsx101
-rw-r--r--apps/mobile/assets/blur.jpegbin0 -> 178818 bytes
-rw-r--r--apps/mobile/assets/icon.pngbin0 -> 2362 bytes
-rw-r--r--apps/mobile/assets/splash.pngbin0 -> 117993 bytes
-rw-r--r--apps/mobile/babel.config.js9
-rw-r--r--apps/mobile/components/Logo.tsx11
-rw-r--r--apps/mobile/components/bookmarks/BookmarkCard.tsx243
-rw-r--r--apps/mobile/components/bookmarks/BookmarkList.tsx61
-rw-r--r--apps/mobile/components/ui/ActionButton.tsx21
-rw-r--r--apps/mobile/components/ui/Button.tsx81
-rw-r--r--apps/mobile/components/ui/Divider.tsx28
-rw-r--r--apps/mobile/components/ui/FullPageSpinner.tsx9
-rw-r--r--apps/mobile/components/ui/Input.tsx28
-rw-r--r--apps/mobile/components/ui/Skeleton.tsx38
-rw-r--r--apps/mobile/components/ui/Toast.tsx183
-rw-r--r--apps/mobile/eas.json19
-rw-r--r--apps/mobile/globals.css80
-rw-r--r--apps/mobile/lib/last-shared-intent.ts15
-rw-r--r--apps/mobile/lib/providers.tsx54
-rw-r--r--apps/mobile/lib/session.ts20
-rw-r--r--apps/mobile/lib/settings.ts29
-rw-r--r--apps/mobile/lib/storage-state.ts51
-rw-r--r--apps/mobile/lib/trpc.ts4
-rw-r--r--apps/mobile/lib/utils.ts6
-rw-r--r--apps/mobile/metro.config.js58
-rw-r--r--apps/mobile/nativewind-env.d.ts1
-rw-r--r--apps/mobile/package.json71
-rw-r--r--apps/mobile/tailwind.config.ts73
-rw-r--r--apps/mobile/tsconfig.json14
-rw-r--r--apps/web/README.md36
-rw-r--r--apps/web/app/api/auth/[...nextauth]/route.tsx3
-rw-r--r--apps/web/app/api/trpc/[trpc]/route.ts36
-rw-r--r--apps/web/app/dashboard/admin/page.tsx203
-rw-r--r--apps/web/app/dashboard/archive/page.tsx9
-rw-r--r--apps/web/app/dashboard/bookmarks/layout.tsx23
-rw-r--r--apps/web/app/dashboard/bookmarks/loading.tsx11
-rw-r--r--apps/web/app/dashboard/bookmarks/page.tsx5
-rw-r--r--apps/web/app/dashboard/error.tsx9
-rw-r--r--apps/web/app/dashboard/favourites/page.tsx14
-rw-r--r--apps/web/app/dashboard/layout.tsx24
-rw-r--r--apps/web/app/dashboard/lists/[listId]/page.tsx44
-rw-r--r--apps/web/app/dashboard/lists/page.tsx14
-rw-r--r--apps/web/app/dashboard/not-found.tsx7
-rw-r--r--apps/web/app/dashboard/preview/[bookmarkId]/page.tsx14
-rw-r--r--apps/web/app/dashboard/search/page.tsx41
-rw-r--r--apps/web/app/dashboard/settings/page.tsx9
-rw-r--r--apps/web/app/dashboard/tags/[tagName]/page.tsx55
-rw-r--r--apps/web/app/dashboard/tags/page.tsx56
-rw-r--r--apps/web/app/favicon.icobin0 -> 15406 bytes
-rw-r--r--apps/web/app/globals.css76
-rw-r--r--apps/web/app/layout.tsx51
-rw-r--r--apps/web/app/page.tsx12
-rw-r--r--apps/web/app/signin/page.tsx25
-rw-r--r--apps/web/components.json17
-rw-r--r--apps/web/components/dashboard/bookmarks/AddLinkButton.tsx102
-rw-r--r--apps/web/components/dashboard/bookmarks/AddToListModal.tsx168
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkCardSkeleton.tsx30
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx185
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx101
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx109
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkedTextViewer.tsx20
-rw-r--r--apps/web/components/dashboard/bookmarks/Bookmarks.tsx32
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx64
-rw-r--r--apps/web/components/dashboard/bookmarks/LinkCard.tsx114
-rw-r--r--apps/web/components/dashboard/bookmarks/TagList.tsx39
-rw-r--r--apps/web/components/dashboard/bookmarks/TagModal.tsx207
-rw-r--r--apps/web/components/dashboard/bookmarks/TextCard.tsx94
-rw-r--r--apps/web/components/dashboard/bookmarks/TopNav.tsx43
-rw-r--r--apps/web/components/dashboard/lists/AllListsView.tsx66
-rw-r--r--apps/web/components/dashboard/lists/DeleteListButton.tsx77
-rw-r--r--apps/web/components/dashboard/lists/ListView.tsx25
-rw-r--r--apps/web/components/dashboard/search/SearchInput.tsx25
-rw-r--r--apps/web/components/dashboard/settings/AddApiKey.tsx167
-rw-r--r--apps/web/components/dashboard/settings/ApiKeySettings.tsx49
-rw-r--r--apps/web/components/dashboard/settings/DeleteApiKey.tsx74
-rw-r--r--apps/web/components/dashboard/sidebar/AllLists.tsx60
-rw-r--r--apps/web/components/dashboard/sidebar/ModileSidebar.tsx24
-rw-r--r--apps/web/components/dashboard/sidebar/ModileSidebarItem.tsx27
-rw-r--r--apps/web/components/dashboard/sidebar/NewListModal.tsx170
-rw-r--r--apps/web/components/dashboard/sidebar/Sidebar.tsx66
-rw-r--r--apps/web/components/dashboard/sidebar/SidebarItem.tsx33
-rw-r--r--apps/web/components/dashboard/sidebar/SidebarProfileOptions.tsx35
-rw-r--r--apps/web/components/signin/CredentialsForm.tsx222
-rw-r--r--apps/web/components/signin/SignInForm.tsx37
-rw-r--r--apps/web/components/signin/SignInProviderButton.tsx21
-rw-r--r--apps/web/components/ui/action-button.tsx25
-rw-r--r--apps/web/components/ui/back-button.tsx9
-rw-r--r--apps/web/components/ui/badge.tsx36
-rw-r--r--apps/web/components/ui/button.tsx56
-rw-r--r--apps/web/components/ui/card.tsx86
-rw-r--r--apps/web/components/ui/dialog.tsx122
-rw-r--r--apps/web/components/ui/dropdown-menu.tsx200
-rw-r--r--apps/web/components/ui/form.tsx177
-rw-r--r--apps/web/components/ui/imageCard.tsx70
-rw-r--r--apps/web/components/ui/input.tsx25
-rw-r--r--apps/web/components/ui/label.tsx26
-rw-r--r--apps/web/components/ui/popover.tsx31
-rw-r--r--apps/web/components/ui/scroll-area.tsx48
-rw-r--r--apps/web/components/ui/select.tsx160
-rw-r--r--apps/web/components/ui/separator.tsx31
-rw-r--r--apps/web/components/ui/skeleton.tsx15
-rw-r--r--apps/web/components/ui/spinner.tsx20
-rw-r--r--apps/web/components/ui/table.tsx117
-rw-r--r--apps/web/components/ui/tabs.tsx55
-rw-r--r--apps/web/components/ui/textarea.tsx24
-rw-r--r--apps/web/components/ui/toast.tsx127
-rw-r--r--apps/web/components/ui/toaster.tsx35
-rw-r--r--apps/web/components/ui/use-toast.ts189
-rw-r--r--apps/web/lib/bookmarkUtils.tsx22
-rw-r--r--apps/web/lib/hooks/bookmark-search.ts73
-rw-r--r--apps/web/lib/providers.tsx75
-rw-r--r--apps/web/lib/trpc.tsx5
-rw-r--r--apps/web/lib/utils.ts6
-rw-r--r--apps/web/next.config.mjs53
-rw-r--r--apps/web/package.json89
-rw-r--r--apps/web/postcss.config.cjs6
-rw-r--r--apps/web/public/blur.avifbin0 -> 52746 bytes
-rw-r--r--apps/web/public/icons/logo-128.pngbin0 -> 2362 bytes
-rw-r--r--apps/web/public/icons/logo-16.pngbin0 -> 287 bytes
-rw-r--r--apps/web/public/icons/logo-48.pngbin0 -> 780 bytes
-rw-r--r--apps/web/public/manifest.json25
-rw-r--r--apps/web/server/api/client.ts16
-rw-r--r--apps/web/server/auth.ts96
-rw-r--r--apps/web/tailwind.config.ts89
-rw-r--r--apps/web/tsconfig.json17
-rw-r--r--apps/web/vitest.config.ts14
-rw-r--r--apps/workers/crawlerWorker.ts201
-rw-r--r--apps/workers/index.ts16
-rw-r--r--apps/workers/openaiWorker.ts263
-rw-r--r--apps/workers/package.json56
-rw-r--r--apps/workers/searchWorker.ts116
-rw-r--r--apps/workers/tsconfig.json10
182 files changed, 8982 insertions, 0 deletions
diff --git a/apps/browser-extension/.eslintrc.cjs b/apps/browser-extension/.eslintrc.cjs
new file mode 100644
index 00000000..450106a4
--- /dev/null
+++ b/apps/browser-extension/.eslintrc.cjs
@@ -0,0 +1,3 @@
+module.exports = {
+ ignorePatterns: ["dist/"],
+};
diff --git a/apps/browser-extension/.gitignore b/apps/browser-extension/.gitignore
new file mode 100644
index 00000000..a547bf36
--- /dev/null
+++ b/apps/browser-extension/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/apps/browser-extension/index.html b/apps/browser-extension/index.html
new file mode 100644
index 00000000..e4b78eae
--- /dev/null
+++ b/apps/browser-extension/index.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Vite + React + TS</title>
+ </head>
+ <body>
+ <div id="root"></div>
+ <script type="module" src="/src/main.tsx"></script>
+ </body>
+</html>
diff --git a/apps/browser-extension/manifest.json b/apps/browser-extension/manifest.json
new file mode 100644
index 00000000..d3a27e7b
--- /dev/null
+++ b/apps/browser-extension/manifest.json
@@ -0,0 +1,19 @@
+{
+ "manifest_version": 3,
+ "name": "Hoarder",
+ "description": "An extension to bookmark links to hoarder.app",
+ "version": "1.1",
+ "icons": {
+ "16": "public/logo-16.png",
+ "48": "public/logo-48.png",
+ "128": "public/logo-128.png"
+ },
+ "action": {
+ "default_popup": "index.html"
+ },
+ "options_ui": {
+ "page": "index.html#options",
+ "open_in_tab": false
+ },
+ "permissions": ["storage", "tabs"]
+}
diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json
new file mode 100644
index 00000000..21cffb50
--- /dev/null
+++ b/apps/browser-extension/package.json
@@ -0,0 +1,55 @@
+{
+ "name": "browser-extension",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@hoarder/trpc": "0.1.0",
+ "@tanstack/react-query": "^5.24.8",
+ "@trpc/client": "11.0.0-next-beta.308",
+ "@trpc/next": "11.0.0-next-beta.308",
+ "@trpc/react-query": "11.0.0-next-beta.308",
+ "@trpc/server": "11.0.0-next-beta.308",
+ "lucide-react": "^0.330.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.22.0",
+ "use-chrome-storage": "^1.2.2",
+ "superjson": "^2.2.1",
+ "zod": "^3.22.4"
+ },
+ "devDependencies": {
+ "@hoarder/eslint-config": "workspace:^0.2.0",
+ "@hoarder/prettier-config": "workspace:^0.1.0",
+ "@hoarder/tailwind-config": "workspace:^0.1.0",
+ "@hoarder/tsconfig": "workspace:^0.1.0",
+ "@crxjs/vite-plugin": "^1.0.14",
+ "@types/react": "^18.2.55",
+ "@types/react-dom": "^18.2.19",
+ "@types/chrome": "^0.0.260",
+ "@vitejs/plugin-react-swc": "^3.5.0",
+ "autoprefixer": "^10.4.17",
+ "eslint": "^8.57.0",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "eslint-plugin-react-refresh": "^0.4.5",
+ "postcss": "^8.4.35",
+ "tailwindcss": "^3.4.1",
+ "typescript": "^5.3.3",
+ "vite": "^5.1.0"
+ },
+ "eslintConfig": {
+ "root": true,
+ "extends": [
+ "@hoarder/eslint-config/base",
+ "@hoarder/eslint-config/react"
+ ]
+ },
+ "prettier": "@hoarder/prettier-config"
+}
diff --git a/apps/browser-extension/postcss.config.js b/apps/browser-extension/postcss.config.js
new file mode 100644
index 00000000..2aa7205d
--- /dev/null
+++ b/apps/browser-extension/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/apps/browser-extension/public/logo-128.png b/apps/browser-extension/public/logo-128.png
new file mode 100644
index 00000000..71ead90c
--- /dev/null
+++ b/apps/browser-extension/public/logo-128.png
Binary files differ
diff --git a/apps/browser-extension/public/logo-16.png b/apps/browser-extension/public/logo-16.png
new file mode 100644
index 00000000..dd864d44
--- /dev/null
+++ b/apps/browser-extension/public/logo-16.png
Binary files differ
diff --git a/apps/browser-extension/public/logo-48.png b/apps/browser-extension/public/logo-48.png
new file mode 100644
index 00000000..7ba1cd49
--- /dev/null
+++ b/apps/browser-extension/public/logo-48.png
Binary files differ
diff --git a/apps/browser-extension/public/logo.png b/apps/browser-extension/public/logo.png
new file mode 100644
index 00000000..ebe0a6a3
--- /dev/null
+++ b/apps/browser-extension/public/logo.png
Binary files differ
diff --git a/apps/browser-extension/src/BookmarkDeletedPage.tsx b/apps/browser-extension/src/BookmarkDeletedPage.tsx
new file mode 100644
index 00000000..23e1d9da
--- /dev/null
+++ b/apps/browser-extension/src/BookmarkDeletedPage.tsx
@@ -0,0 +1,3 @@
+export default function BookmarkDeletedPage() {
+ return <p className="text-lg">Bookmark Deleted!</p>;
+}
diff --git a/apps/browser-extension/src/BookmarkSavedPage.tsx b/apps/browser-extension/src/BookmarkSavedPage.tsx
new file mode 100644
index 00000000..f25a83ba
--- /dev/null
+++ b/apps/browser-extension/src/BookmarkSavedPage.tsx
@@ -0,0 +1,62 @@
+import { Link, useNavigate, useParams } from "react-router-dom";
+import { api } from "./utils/trpc";
+import usePluginSettings from "./utils/settings";
+import { ArrowUpRightFromSquare, Trash } from "lucide-react";
+import Spinner from "./Spinner";
+import { useState } from "react";
+
+export default function BookmarkSavedPage() {
+ const { bookmarkId } = useParams();
+ const navigate = useNavigate();
+ const [error, setError] = useState("");
+
+ const { mutate: deleteBookmark, isPending } =
+ api.bookmarks.deleteBookmark.useMutation({
+ onSuccess: () => {
+ navigate("/bookmarkdeleted");
+ },
+ onError: (e) => {
+ setError(e.message);
+ },
+ });
+
+ const { settings } = usePluginSettings();
+
+ if (!bookmarkId) {
+ return <div>NOT FOUND</div>;
+ }
+
+ return (
+ <div className="flex flex-col gap-2">
+ {error && <p className="text-red-500">{error}</p>}
+ <div className="flex items-center justify-between gap-2">
+ <p className="text-lg">Bookmarked!</p>
+ <div className="flex gap-2">
+ <Link
+ className="flex gap-2 rounded-md p-3 text-black hover:text-black"
+ target="_blank"
+ to={`${settings.address}/dashboard/preview/${bookmarkId}`}
+ >
+ <ArrowUpRightFromSquare className="my-auto" size="20" />
+ <p className="my-auto">Open</p>
+ </Link>
+ <button
+ onClick={() => deleteBookmark({ bookmarkId: bookmarkId })}
+ className="flex gap-2 bg-transparent text-red-500 hover:text-red-500"
+ >
+ {!isPending ? (
+ <>
+ <Trash className="my-auto" size="20" />
+ <p className="my-auto">Delete</p>
+ </>
+ ) : (
+ <span className="m-auto">
+ <Spinner />
+ </span>
+ )}
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/browser-extension/src/Layout.tsx b/apps/browser-extension/src/Layout.tsx
new file mode 100644
index 00000000..f8279a18
--- /dev/null
+++ b/apps/browser-extension/src/Layout.tsx
@@ -0,0 +1,51 @@
+import { Outlet } from "react-router-dom";
+import { Home, RefreshCw, Settings, X } from "lucide-react";
+import { useNavigate } from "react-router-dom";
+import usePluginSettings from "./utils/settings";
+
+export default function Layout() {
+ const navigate = useNavigate();
+ const { settings, isPending: isInit } = usePluginSettings();
+ if (!isInit) {
+ return <div className="p-4">Loading ... </div>;
+ }
+
+ if (!settings.apiKey || !settings.address) {
+ navigate("/notconfigured");
+ return;
+ }
+
+ return (
+ <div className="flex flex-col space-y-2">
+ <div className="rounded-md bg-yellow-100 p-4">
+ <Outlet />
+ </div>
+ <hr />
+ <div className="flex justify-between space-x-3">
+ <div className="my-auto">
+ <a
+ className="flex gap-2 text-black"
+ target="_blank"
+ href={`${settings.address}/dashboard/bookmarks`}
+ >
+ <Home />
+ <span className="text-md my-auto">Bookmarks</span>
+ </a>
+ </div>
+ <div className="flex space-x-3">
+ {process.env.NODE_ENV == "development" && (
+ <button onClick={() => navigate(0)}>
+ <RefreshCw className="w-4" />
+ </button>
+ )}
+ <button onClick={() => navigate("/options")}>
+ <Settings className="w-4" />
+ </button>
+ <button onClick={() => window.close()}>
+ <X className="w-4" />
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/browser-extension/src/Logo.tsx b/apps/browser-extension/src/Logo.tsx
new file mode 100644
index 00000000..6b29e68c
--- /dev/null
+++ b/apps/browser-extension/src/Logo.tsx
@@ -0,0 +1,10 @@
+import { PackageOpen } from "lucide-react";
+
+export default function Logo() {
+ return (
+ <span className="mx-auto flex gap-2">
+ <PackageOpen size="40" className="my-auto" />
+ <p className="text-4xl">Hoarder</p>
+ </span>
+ );
+}
diff --git a/apps/browser-extension/src/NotConfiguredPage.tsx b/apps/browser-extension/src/NotConfiguredPage.tsx
new file mode 100644
index 00000000..fc5c8f47
--- /dev/null
+++ b/apps/browser-extension/src/NotConfiguredPage.tsx
@@ -0,0 +1,47 @@
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import usePluginSettings from "./utils/settings";
+import Logo from "./Logo";
+
+export default function NotConfiguredPage() {
+ const navigate = useNavigate();
+
+ const { settings, setSettings } = usePluginSettings();
+
+ const [error, setError] = useState("");
+ const [serverAddress, setServerAddress] = useState(settings.address);
+ useEffect(() => {
+ setServerAddress(settings.address);
+ }, [settings.address]);
+
+ const onSave = () => {
+ if (serverAddress == "") {
+ setError("Server address is required");
+ return;
+ }
+ setSettings((s) => ({ ...s, address: serverAddress }));
+ navigate("/signin");
+ };
+
+ return (
+ <div className="flex flex-col space-y-2">
+ <Logo />
+ <span className="pt-3">
+ To use the plugin, you need to configure it first.
+ </span>
+ <p className="text-red-500">{error}</p>
+ <div className="flex gap-2">
+ <label className="my-auto">Server Address</label>
+ <input
+ name="address"
+ value={serverAddress}
+ className="h-8 flex-1 rounded-lg border border-gray-300 p-2"
+ onChange={(e) => setServerAddress(e.target.value)}
+ />
+ </div>
+ <button className="bg-black text-white" onClick={onSave}>
+ Configure
+ </button>
+ </div>
+ );
+}
diff --git a/apps/browser-extension/src/OptionsPage.tsx b/apps/browser-extension/src/OptionsPage.tsx
new file mode 100644
index 00000000..6407b3cc
--- /dev/null
+++ b/apps/browser-extension/src/OptionsPage.tsx
@@ -0,0 +1,60 @@
+import React, { useEffect } from "react";
+import usePluginSettings from "./utils/settings";
+import { api } from "./utils/trpc";
+import Spinner from "./Spinner";
+import { useNavigate } from "react-router-dom";
+import Logo from "./Logo";
+
+export default function OptionsPage() {
+ const navigate = useNavigate();
+ const { settings, setSettings } = usePluginSettings();
+
+ const { data: whoami, error: whoAmIError } = api.users.whoami.useQuery(
+ undefined,
+ {
+ enabled: settings.address != "",
+ },
+ );
+
+ const invalidateWhoami = api.useUtils().users.whoami.refetch;
+
+ useEffect(() => {
+ invalidateWhoami();
+ }, [settings, invalidateWhoami]);
+
+ let loggedInMessage: React.ReactNode;
+ if (whoAmIError) {
+ if (whoAmIError.data?.code == "UNAUTHORIZED") {
+ loggedInMessage = <span>Not logged in</span>;
+ } else {
+ loggedInMessage = (
+ <span>Something went wrong: {whoAmIError.message}</span>
+ );
+ }
+ } else if (whoami) {
+ loggedInMessage = <span>{whoami.email}</span>;
+ } else {
+ loggedInMessage = <Spinner />;
+ }
+
+ const onLogout = () => {
+ setSettings((s) => ({ ...s, apiKey: "" }));
+ invalidateWhoami();
+ navigate("/notconfigured");
+ };
+
+ return (
+ <div className="flex flex-col space-y-2">
+ <Logo />
+ <span className="text-lg">Settings</span>
+ <hr />
+ <div className="flex gap-2">
+ <span className="my-auto">Logged in as:</span>
+ {loggedInMessage}
+ </div>
+ <button className="rounded-lg border border-gray-200" onClick={onLogout}>
+ Logout
+ </button>
+ </div>
+ );
+}
diff --git a/apps/browser-extension/src/SavePage.tsx b/apps/browser-extension/src/SavePage.tsx
new file mode 100644
index 00000000..638af149
--- /dev/null
+++ b/apps/browser-extension/src/SavePage.tsx
@@ -0,0 +1,60 @@
+import { useEffect, useState } from "react";
+import Spinner from "./Spinner";
+import { api } from "./utils/trpc";
+import { Navigate } from "react-router-dom";
+
+export default function SavePage() {
+ const [error, setError] = useState<string | undefined>(undefined);
+
+ const {
+ data,
+ mutate: createBookmark,
+ status,
+ } = api.bookmarks.createBookmark.useMutation({
+ onError: (e) => {
+ setError("Something went wrong: " + e.message);
+ },
+ });
+
+ useEffect(() => {
+ async function runSave() {
+ let currentUrl;
+ const [currentTab] = await chrome.tabs.query({
+ active: true,
+ lastFocusedWindow: true,
+ });
+ if (currentTab?.url) {
+ currentUrl = currentTab.url;
+ } else {
+ setError("Couldn't find the URL of the current tab");
+ return;
+ }
+
+ createBookmark({
+ type: "link",
+ url: currentUrl,
+ });
+ }
+ runSave();
+ }, [createBookmark]);
+
+ switch (status) {
+ case "error": {
+ return <div className="text-red-500">{error}</div>;
+ }
+ case "success": {
+ return <Navigate to={`/bookmark/${data.id}`} />;
+ }
+ case "pending": {
+ return (
+ <div className="flex justify-between text-lg">
+ <span>Saving Bookmark </span>
+ <Spinner />
+ </div>
+ );
+ }
+ case "idle": {
+ return <div />;
+ }
+ }
+}
diff --git a/apps/browser-extension/src/SignInPage.tsx b/apps/browser-extension/src/SignInPage.tsx
new file mode 100644
index 00000000..6db7c348
--- /dev/null
+++ b/apps/browser-extension/src/SignInPage.tsx
@@ -0,0 +1,89 @@
+import { useState } from "react";
+import { api } from "./utils/trpc";
+import usePluginSettings from "./utils/settings";
+import { useNavigate } from "react-router-dom";
+import Logo from "./Logo";
+
+export default function SignInPage() {
+ const navigate = useNavigate();
+ const { setSettings } = usePluginSettings();
+
+ const {
+ mutate: login,
+ error,
+ isPending,
+ } = api.apiKeys.exchange.useMutation({
+ onSuccess: (resp) => {
+ setSettings((s) => ({ ...s, apiKey: resp.key }));
+ navigate("/options");
+ },
+ onError: () => {},
+ });
+
+ const [formData, setFormData] = useState<{
+ email: string;
+ password: string;
+ }>({
+ email: "",
+ password: "",
+ });
+
+ const onSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ const randStr = (Math.random() + 1).toString(36).substring(5);
+ login({ ...formData, keyName: `Browser extension: (${randStr})` });
+ };
+
+ let errorMessage = "";
+ if (error) {
+ if (error.data?.code == "UNAUTHORIZED") {
+ errorMessage = "Wrong username or password";
+ } else {
+ errorMessage = error.message;
+ }
+ }
+
+ return (
+ <div className="flex flex-col space-y-2">
+ <Logo />
+ <p className="text-lg">Login</p>
+ <p className="text-red-500">{errorMessage}</p>
+ <form className="flex flex-col gap-y-2" onSubmit={onSubmit}>
+ <div className="flex flex-col gap-y-1">
+ <label className="my-auto font-bold">Email</label>
+ <input
+ value={formData.email}
+ onChange={(e) =>
+ setFormData((f) => ({ ...f, email: e.target.value }))
+ }
+ type="text"
+ name="email"
+ className="h-8 flex-1 rounded-lg border border-gray-300 p-2"
+ />
+ </div>
+ <div className="flex flex-col gap-y-1">
+ <label className="my-auto font-bold">Password</label>
+ <input
+ value={formData.password}
+ onChange={(e) =>
+ setFormData((f) => ({
+ ...f,
+ password: e.target.value,
+ }))
+ }
+ type="password"
+ name="password"
+ className="h-8 flex-1 rounded-lg border border-gray-300 p-2"
+ />
+ </div>
+ <button
+ className="bg-black text-white"
+ type="submit"
+ disabled={isPending}
+ >
+ Login
+ </button>
+ </form>
+ </div>
+ );
+}
diff --git a/apps/browser-extension/src/Spinner.tsx b/apps/browser-extension/src/Spinner.tsx
new file mode 100644
index 00000000..9fd8839b
--- /dev/null
+++ b/apps/browser-extension/src/Spinner.tsx
@@ -0,0 +1,18 @@
+export default function Spinner() {
+ return (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="24"
+ height="24"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ className="animate-spin"
+ >
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
+ </svg>
+ );
+}
diff --git a/apps/browser-extension/src/assets/react.svg b/apps/browser-extension/src/assets/react.svg
new file mode 100644
index 00000000..6c87de9b
--- /dev/null
+++ b/apps/browser-extension/src/assets/react.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg> \ No newline at end of file
diff --git a/apps/browser-extension/src/index.css b/apps/browser-extension/src/index.css
new file mode 100644
index 00000000..e7d4bb2f
--- /dev/null
+++ b/apps/browser-extension/src/index.css
@@ -0,0 +1,72 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+a {
+ font-weight: 500;
+ color: #646cff;
+ text-decoration: inherit;
+}
+a:hover {
+ color: #535bf2;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ place-items: center;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+h1 {
+ font-size: 3.2em;
+ line-height: 1.1;
+}
+
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ transition: border-color 0.25s;
+}
+button:hover {
+ border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ a:hover {
+ color: #747bff;
+ }
+ button {
+ background-color: #f9f9f9;
+ }
+}
diff --git a/apps/browser-extension/src/main.tsx b/apps/browser-extension/src/main.tsx
new file mode 100644
index 00000000..085a5a69
--- /dev/null
+++ b/apps/browser-extension/src/main.tsx
@@ -0,0 +1,40 @@
+import ReactDOM from "react-dom/client";
+import "./index.css";
+import OptionsPage from "./OptionsPage.tsx";
+import NotConfiguredPage from "./NotConfiguredPage.tsx";
+import { Providers } from "./utils/providers.tsx";
+import BookmarkSavedPage from "./BookmarkSavedPage.tsx";
+import { HashRouter, Routes, Route } from "react-router-dom";
+import Layout from "./Layout.tsx";
+import SavePage from "./SavePage.tsx";
+import BookmarkDeletedPage from "./BookmarkDeletedPage.tsx";
+import SignInPage from "./SignInPage.tsx";
+
+function App() {
+ return (
+ <div className="w-96 p-4">
+ <Providers>
+ <HashRouter>
+ <Routes>
+ <Route element={<Layout />}>
+ <Route path="/" element={<SavePage />} />
+ <Route
+ path="/bookmark/:bookmarkId"
+ element={<BookmarkSavedPage />}
+ />
+ <Route
+ path="/bookmarkdeleted"
+ element={<BookmarkDeletedPage />}
+ />
+ </Route>
+ <Route path="/notconfigured" element={<NotConfiguredPage />} />
+ <Route path="/options" element={<OptionsPage />} />
+ <Route path="/signin" element={<SignInPage />} />
+ </Routes>
+ </HashRouter>
+ </Providers>
+ </div>
+ );
+}
+
+ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
diff --git a/apps/browser-extension/src/utils/providers.tsx b/apps/browser-extension/src/utils/providers.tsx
new file mode 100644
index 00000000..d20f2512
--- /dev/null
+++ b/apps/browser-extension/src/utils/providers.tsx
@@ -0,0 +1,46 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { httpBatchLink } from "@trpc/client";
+import { useEffect, useState } from "react";
+import { api } from "./trpc";
+import usePluginSettings, { getPluginSettings } from "./settings";
+import superjson from "superjson";
+
+function getTRPCClient(address: string) {
+ return api.createClient({
+ links: [
+ httpBatchLink({
+ url: `${address}/api/trpc`,
+ async headers() {
+ const settings = await getPluginSettings();
+ return {
+ Authorization: `Bearer ${settings.apiKey}`,
+ };
+ },
+ transformer: superjson,
+ }),
+ ],
+ });
+}
+
+export function Providers({ children }: { children: React.ReactNode }) {
+ const { settings } = usePluginSettings();
+ const [queryClient] = useState(() => new QueryClient());
+
+ const [trpcClient, setTrpcClient] = useState<
+ ReturnType<typeof getTRPCClient>
+ >(getTRPCClient(settings.address));
+
+ useEffect(() => {
+ setTrpcClient(getTRPCClient(settings.address));
+ }, [settings.address]);
+
+ return (
+ <api.Provider
+ key={settings.address}
+ client={trpcClient}
+ queryClient={queryClient}
+ >
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+ </api.Provider>
+ );
+}
diff --git a/apps/browser-extension/src/utils/settings.ts b/apps/browser-extension/src/utils/settings.ts
new file mode 100644
index 00000000..37f474c0
--- /dev/null
+++ b/apps/browser-extension/src/utils/settings.ts
@@ -0,0 +1,22 @@
+import { useChromeStorageSync } from "use-chrome-storage";
+
+export type Settings = {
+ apiKey: string;
+ address: string;
+};
+
+export default function usePluginSettings() {
+ const [settings, setSettings, _1, _2, isInit] = useChromeStorageSync(
+ "settings",
+ {
+ apiKey: "",
+ address: "",
+ } as Settings,
+ );
+
+ return { settings, setSettings, isPending: isInit };
+}
+
+export async function getPluginSettings() {
+ return (await chrome.storage.sync.get("settings")).settings as Settings;
+}
diff --git a/apps/browser-extension/src/utils/trpc.ts b/apps/browser-extension/src/utils/trpc.ts
new file mode 100644
index 00000000..da21a55a
--- /dev/null
+++ b/apps/browser-extension/src/utils/trpc.ts
@@ -0,0 +1,4 @@
+import { createTRPCReact } from "@trpc/react-query";
+import type { AppRouter } from "@hoarder/trpc/routers/_app";
+
+export const api = createTRPCReact<AppRouter>();
diff --git a/apps/browser-extension/src/vite-env.d.ts b/apps/browser-extension/src/vite-env.d.ts
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ b/apps/browser-extension/src/vite-env.d.ts
@@ -0,0 +1 @@
+/// <reference types="vite/client" />
diff --git a/apps/browser-extension/tailwind.config.js b/apps/browser-extension/tailwind.config.js
new file mode 100644
index 00000000..1c0c7c87
--- /dev/null
+++ b/apps/browser-extension/tailwind.config.js
@@ -0,0 +1,10 @@
+/** @type {import('tailwindcss').Config} */
+const tailwindConfig = {
+ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};
+
+export default tailwindConfig;
diff --git a/apps/browser-extension/tsconfig.json b/apps/browser-extension/tsconfig.json
new file mode 100644
index 00000000..a7fc6fbf
--- /dev/null
+++ b/apps/browser-extension/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/apps/browser-extension/tsconfig.node.json b/apps/browser-extension/tsconfig.node.json
new file mode 100644
index 00000000..97ede7ee
--- /dev/null
+++ b/apps/browser-extension/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/apps/browser-extension/vite.config.ts b/apps/browser-extension/vite.config.ts
new file mode 100644
index 00000000..29c6bc6e
--- /dev/null
+++ b/apps/browser-extension/vite.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react-swc";
+import { crx } from "@crxjs/vite-plugin";
+import manifest from "./manifest.json";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react(), crx({ manifest })],
+});
diff --git a/apps/mobile/.eslintrc.js b/apps/mobile/.eslintrc.js
new file mode 100644
index 00000000..53beac49
--- /dev/null
+++ b/apps/mobile/.eslintrc.js
@@ -0,0 +1,4 @@
+module.exports = {
+ root: true,
+ extends: ["universe/native"],
+};
diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore
new file mode 100644
index 00000000..2920e5a8
--- /dev/null
+++ b/apps/mobile/.gitignore
@@ -0,0 +1,39 @@
+# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
+
+# dependencies
+node_modules/
+
+# Expo
+.expo/
+dist/
+web-build/
+
+# Native
+*.orig.*
+*.jks
+*.p8
+*.p12
+*.key
+*.mobileprovision
+
+# Metro
+.metro-health-check*
+
+# debug
+npm-debug.*
+yarn-debug.*
+yarn-error.*
+
+# macOS
+.DS_Store
+*.pem
+
+# local env files
+.env*.local
+
+# typescript
+*.tsbuildinfo
+
+#build files
+ios/
+android/
diff --git a/apps/mobile/.npmrc b/apps/mobile/.npmrc
new file mode 100644
index 00000000..d67f3748
--- /dev/null
+++ b/apps/mobile/.npmrc
@@ -0,0 +1 @@
+node-linker=hoisted
diff --git a/apps/mobile/app.json b/apps/mobile/app.json
new file mode 100644
index 00000000..e16baa37
--- /dev/null
+++ b/apps/mobile/app.json
@@ -0,0 +1,57 @@
+{
+ "expo": {
+ "name": "Hoarder App",
+ "slug": "hoarder",
+ "scheme": "hoarder",
+ "version": "1.2.0",
+ "orientation": "portrait",
+ "icon": "./assets/icon.png",
+ "userInterfaceStyle": "light",
+ "splash": {
+ "image": "./assets/splash.png",
+ "resizeMode": "contain",
+ "backgroundColor": "#ffffff"
+ },
+ "assetBundlePatterns": [
+ "**/*"
+ ],
+ "ios": {
+ "supportsTablet": true,
+ "bundleIdentifier": "app.hoarder.hoardermobile",
+ "config": {
+ "usesNonExemptEncryption": false
+ }
+ },
+ "android": {
+ "adaptiveIcon": {
+ "foregroundImage": "./assets/icon.png",
+ "backgroundColor": "#ffffff"
+ },
+ "package": "app.hoarder.hoardermobile"
+ },
+ "plugins": [
+ "expo-router",
+ [
+ "expo-share-intent",
+ {
+ "iosActivationRules": {
+ "NSExtensionActivationSupportsWebURLWithMaxCount": 1,
+ "NSExtensionActivationSupportsWebPageWithMaxCount": 0,
+ "NSExtensionActivationSupportsImageWithMaxCount": 0,
+ "NSExtensionActivationSupportsMovieWithMaxCount": 0,
+ "NSExtensionActivationSupportsText": true
+ }
+ }
+ ],
+ "expo-secure-store"
+ ],
+ "extra": {
+ "router": {
+ "origin": false
+ },
+ "eas": {
+ "projectId": "d6d14643-ad43-4cd3-902a-92c5944d5e45"
+ }
+ }
+ }
+}
diff --git a/apps/mobile/app/+not-found.tsx b/apps/mobile/app/+not-found.tsx
new file mode 100644
index 00000000..466505b6
--- /dev/null
+++ b/apps/mobile/app/+not-found.tsx
@@ -0,0 +1,6 @@
+import { View } from "react-native";
+
+// This is kinda important given that the sharing modal always resolve to an unknown route
+export default function NotFound() {
+ return <View />;
+}
diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx
new file mode 100644
index 00000000..6304ced5
--- /dev/null
+++ b/apps/mobile/app/_layout.tsx
@@ -0,0 +1,53 @@
+import "@/globals.css";
+import "expo-dev-client";
+
+import { useRouter } from "expo-router";
+import { Stack } from "expo-router/stack";
+import { useShareIntent } from "expo-share-intent";
+import { StatusBar } from "expo-status-bar";
+import { useEffect } from "react";
+import { View } from "react-native";
+
+import { useLastSharedIntent } from "@/lib/last-shared-intent";
+import { Providers } from "@/lib/providers";
+
+export default function RootLayout() {
+ const router = useRouter();
+ const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntent();
+
+ const lastSharedIntent = useLastSharedIntent();
+
+ useEffect(() => {
+ const intentJson = JSON.stringify(shareIntent);
+ if (hasShareIntent && !lastSharedIntent.isPreviouslyShared(intentJson)) {
+ // TODO: Remove once https://github.com/achorein/expo-share-intent/issues/14 is fixed
+ lastSharedIntent.setIntent(intentJson);
+ router.replace({
+ pathname: "sharing",
+ params: { shareIntent: intentJson },
+ });
+ resetShareIntent();
+ }
+ }, [hasShareIntent]);
+
+ return (
+ <Providers>
+ <View className="h-full w-full bg-white">
+ <Stack
+ screenOptions={{
+ headerShown: false,
+ }}
+ >
+ <Stack.Screen name="index" />
+ <Stack.Screen
+ name="sharing"
+ options={{
+ presentation: "modal",
+ }}
+ />
+ </Stack>
+ <StatusBar style="auto" />
+ </View>
+ </Providers>
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/_layout.tsx
new file mode 100644
index 00000000..5b2d810a
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/_layout.tsx
@@ -0,0 +1,38 @@
+import { Tabs } from "expo-router";
+import { ClipboardList, Home, Search, Settings } from "lucide-react-native";
+import React from "react";
+
+export default function TabLayout() {
+ return (
+ <Tabs screenOptions={{ tabBarActiveTintColor: "blue" }}>
+ <Tabs.Screen
+ name="index"
+ options={{
+ title: "Home",
+ tabBarIcon: ({ color }) => <Home color={color} />,
+ }}
+ />
+ <Tabs.Screen
+ name="search"
+ options={{
+ title: "Search",
+ tabBarIcon: ({ color }) => <Search color={color} />,
+ }}
+ />
+ <Tabs.Screen
+ name="lists"
+ options={{
+ title: "Lists",
+ tabBarIcon: ({ color }) => <ClipboardList color={color} />,
+ }}
+ />
+ <Tabs.Screen
+ name="settings"
+ options={{
+ title: "Settings",
+ tabBarIcon: ({ color }) => <Settings color={color} />,
+ }}
+ />
+ </Tabs>
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/index.tsx b/apps/mobile/app/dashboard/(tabs)/index.tsx
new file mode 100644
index 00000000..b2349525
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/index.tsx
@@ -0,0 +1,31 @@
+import { Link, Stack } from "expo-router";
+import { SquarePen, Link as LinkIcon } from "lucide-react-native";
+import { View } from "react-native";
+
+import BookmarkList from "@/components/bookmarks/BookmarkList";
+
+function HeaderRight() {
+ return (
+ <View className="flex flex-row">
+ <Link href="dashboard/add-link" className="mt-2 px-2">
+ <LinkIcon />
+ </Link>
+ <Link href="dashboard/add-note" className="mt-2 px-2">
+ <SquarePen />
+ </Link>
+ </View>
+ );
+}
+
+export default function Home() {
+ return (
+ <>
+ <Stack.Screen
+ options={{
+ headerRight: () => <HeaderRight />,
+ }}
+ />
+ <BookmarkList archived={false} />
+ </>
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/lists.tsx b/apps/mobile/app/dashboard/(tabs)/lists.tsx
new file mode 100644
index 00000000..b534ddda
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/lists.tsx
@@ -0,0 +1,67 @@
+import { Link } from "expo-router";
+import { useEffect, useState } from "react";
+import { FlatList, View } from "react-native";
+
+import { api } from "@/lib/trpc";
+
+export default function Lists() {
+ const [refreshing, setRefreshing] = useState(false);
+ const { data: lists, isPending } = api.lists.list.useQuery();
+ const apiUtils = api.useUtils();
+
+ useEffect(() => {
+ setRefreshing(isPending);
+ }, [isPending]);
+
+ if (!lists) {
+ // Add spinner
+ return <View />;
+ }
+
+ const onRefresh = () => {
+ apiUtils.lists.list.invalidate();
+ };
+
+ const links = [
+ {
+ id: "fav",
+ logo: "⭐️",
+ name: "Favourites",
+ href: "/dashboard/favourites",
+ },
+ {
+ id: "arch",
+ logo: "🗄️",
+ name: "Archive",
+ href: "/dashboard/archive",
+ },
+ ];
+
+ links.push(
+ ...lists.lists.map((l) => ({
+ id: l.id,
+ logo: l.icon,
+ name: l.name,
+ href: `/dashboard/lists/${l.id}`,
+ })),
+ );
+
+ return (
+ <FlatList
+ contentContainerStyle={{
+ gap: 10,
+ marginTop: 10,
+ }}
+ renderItem={(l) => (
+ <View className="mx-2 block rounded-xl border border-gray-100 bg-white px-4 py-2">
+ <Link key={l.item.id} href={l.item.href} className="text-lg">
+ {l.item.logo} {l.item.name}
+ </Link>
+ </View>
+ )}
+ data={links}
+ refreshing={refreshing}
+ onRefresh={onRefresh}
+ />
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/search.tsx b/apps/mobile/app/dashboard/(tabs)/search.tsx
new file mode 100644
index 00000000..980cab36
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/search.tsx
@@ -0,0 +1,35 @@
+import { keepPreviousData } from "@tanstack/react-query";
+import { useState } from "react";
+import { View } from "react-native";
+import { useDebounce } from "use-debounce";
+
+import BookmarkList from "@/components/bookmarks/BookmarkList";
+import { Divider } from "@/components/ui/Divider";
+import { Input } from "@/components/ui/Input";
+import { api } from "@/lib/trpc";
+
+export default function Search() {
+ const [search, setSearch] = useState("");
+
+ const [query] = useDebounce(search, 200);
+
+ const { data } = api.bookmarks.searchBookmarks.useQuery(
+ { text: query },
+ { placeholderData: keepPreviousData },
+ );
+
+ return (
+ <View>
+ <Input
+ placeholder="Search"
+ className="mx-4 mt-4 bg-white"
+ value={search}
+ onChangeText={setSearch}
+ autoFocus
+ autoCapitalize="none"
+ />
+ <Divider orientation="horizontal" className="mb-1 mt-4 w-full" />
+ {data && <BookmarkList ids={data.bookmarks.map((b) => b.id)} />}
+ </View>
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/settings.tsx b/apps/mobile/app/dashboard/(tabs)/settings.tsx
new file mode 100644
index 00000000..9f86d5ec
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/settings.tsx
@@ -0,0 +1,41 @@
+import { useRouter } from "expo-router";
+import { useEffect } from "react";
+import { Text, View } from "react-native";
+
+import Logo from "@/components/Logo";
+import { Button } from "@/components/ui/Button";
+import { useSession } from "@/lib/session";
+import { api } from "@/lib/trpc";
+
+export default function Dashboard() {
+ const router = useRouter();
+
+ const { isLoggedIn, logout } = useSession();
+
+ useEffect(() => {
+ if (isLoggedIn !== undefined && !isLoggedIn) {
+ router.replace("signin");
+ }
+ }, [isLoggedIn]);
+
+ const { data, error, isLoading } = api.users.whoami.useQuery();
+
+ useEffect(() => {
+ if (error?.data?.code === "UNAUTHORIZED") {
+ logout();
+ }
+ }, [error]);
+
+ return (
+ <View className="flex h-full w-full items-center gap-4 p-4">
+ <Logo />
+ <View className="w-full rounded-lg bg-white px-4 py-2">
+ <Text className="text-lg">
+ {isLoading ? "Loading ..." : data?.email}
+ </Text>
+ </View>
+
+ <Button className="w-full" label="Log Out" onPress={logout} />
+ </View>
+ );
+}
diff --git a/apps/mobile/app/dashboard/_layout.tsx b/apps/mobile/app/dashboard/_layout.tsx
new file mode 100644
index 00000000..ff2384d2
--- /dev/null
+++ b/apps/mobile/app/dashboard/_layout.tsx
@@ -0,0 +1,38 @@
+import { Stack } from "expo-router/stack";
+
+export default function Dashboard() {
+ return (
+ <Stack>
+ <Stack.Screen
+ name="(tabs)"
+ options={{ headerShown: false, title: "Home" }}
+ />
+ <Stack.Screen
+ name="favourites"
+ options={{
+ title: "⭐️ Favourites",
+ }}
+ />
+ <Stack.Screen
+ name="archive"
+ options={{
+ title: "🗄️ Archive",
+ }}
+ />
+ <Stack.Screen
+ name="add-link"
+ options={{
+ title: "New link",
+ presentation: "modal",
+ }}
+ />
+ <Stack.Screen
+ name="add-note"
+ options={{
+ title: "New Note",
+ presentation: "modal",
+ }}
+ />
+ </Stack>
+ );
+}
diff --git a/apps/mobile/app/dashboard/add-link.tsx b/apps/mobile/app/dashboard/add-link.tsx
new file mode 100644
index 00000000..69a9c7a2
--- /dev/null
+++ b/apps/mobile/app/dashboard/add-link.tsx
@@ -0,0 +1,57 @@
+import { useRouter } from "expo-router";
+import { useState } from "react";
+import { View, Text } from "react-native";
+
+import { Button } from "@/components/ui/Button";
+import { Input } from "@/components/ui/Input";
+import { api } from "@/lib/trpc";
+
+export default function AddNote() {
+ const [text, setText] = useState("");
+ const [error, setError] = useState<string | undefined>();
+ const router = useRouter();
+ const invalidateAllBookmarks =
+ api.useUtils().bookmarks.getBookmarks.invalidate;
+
+ const { mutate } = api.bookmarks.createBookmark.useMutation({
+ onSuccess: () => {
+ invalidateAllBookmarks();
+ if (router.canGoBack()) {
+ router.replace("../");
+ } else {
+ router.replace("dashboard");
+ }
+ },
+ onError: (e) => {
+ let message;
+ if (e.data?.code === "BAD_REQUEST") {
+ const error = JSON.parse(e.message)[0];
+ message = error.message;
+ } else {
+ message = `Something went wrong: ${e.message}`;
+ }
+ setError(message);
+ },
+ });
+
+ return (
+ <View className="flex gap-2 p-4">
+ {error && (
+ <Text className="w-full text-center text-red-500">{error}</Text>
+ )}
+ <Input
+ className="bg-white"
+ value={text}
+ onChangeText={setText}
+ placeholder="Link"
+ autoCapitalize="none"
+ inputMode="url"
+ autoFocus
+ />
+ <Button
+ onPress={() => mutate({ type: "link", url: text })}
+ label="Add Link"
+ />
+ </View>
+ );
+}
diff --git a/apps/mobile/app/dashboard/add-note.tsx b/apps/mobile/app/dashboard/add-note.tsx
new file mode 100644
index 00000000..cf775a15
--- /dev/null
+++ b/apps/mobile/app/dashboard/add-note.tsx
@@ -0,0 +1,53 @@
+import { useRouter } from "expo-router";
+import { useState } from "react";
+import { View, Text } from "react-native";
+
+import { Button } from "@/components/ui/Button";
+import { Input } from "@/components/ui/Input";
+import { api } from "@/lib/trpc";
+
+export default function AddNote() {
+ const [text, setText] = useState("");
+ const [error, setError] = useState<string | undefined>();
+ const router = useRouter();
+ const invalidateAllBookmarks =
+ api.useUtils().bookmarks.getBookmarks.invalidate;
+
+ const { mutate } = api.bookmarks.createBookmark.useMutation({
+ onSuccess: () => {
+ invalidateAllBookmarks();
+ if (router.canGoBack()) {
+ router.replace("../");
+ } else {
+ router.replace("dashboard");
+ }
+ },
+ onError: (e) => {
+ let message;
+ if (e.data?.code === "BAD_REQUEST") {
+ const error = JSON.parse(e.message)[0];
+ message = error.message;
+ } else {
+ message = `Something went wrong: ${e.message}`;
+ }
+ setError(message);
+ },
+ });
+
+ return (
+ <View className="flex gap-2 p-4">
+ {error && (
+ <Text className="w-full text-center text-red-500">{error}</Text>
+ )}
+ <Input
+ className="bg-white"
+ value={text}
+ onChangeText={setText}
+ multiline
+ placeholder="What's on your mind?"
+ autoFocus
+ />
+ <Button onPress={() => mutate({ type: "text", text })} label="Add Note" />
+ </View>
+ );
+}
diff --git a/apps/mobile/app/dashboard/archive.tsx b/apps/mobile/app/dashboard/archive.tsx
new file mode 100644
index 00000000..d75cfe22
--- /dev/null
+++ b/apps/mobile/app/dashboard/archive.tsx
@@ -0,0 +1,11 @@
+import { View } from "react-native";
+
+import BookmarkList from "@/components/bookmarks/BookmarkList";
+
+export default function Archive() {
+ return (
+ <View>
+ <BookmarkList archived />
+ </View>
+ );
+}
diff --git a/apps/mobile/app/dashboard/favourites.tsx b/apps/mobile/app/dashboard/favourites.tsx
new file mode 100644
index 00000000..90374f18
--- /dev/null
+++ b/apps/mobile/app/dashboard/favourites.tsx
@@ -0,0 +1,11 @@
+import { View } from "react-native";
+
+import BookmarkList from "@/components/bookmarks/BookmarkList";
+
+export default function Favourites() {
+ return (
+ <View>
+ <BookmarkList archived={false} favourited />
+ </View>
+ );
+}
diff --git a/apps/mobile/app/dashboard/lists/[slug].tsx b/apps/mobile/app/dashboard/lists/[slug].tsx
new file mode 100644
index 00000000..54744874
--- /dev/null
+++ b/apps/mobile/app/dashboard/lists/[slug].tsx
@@ -0,0 +1,31 @@
+import { useLocalSearchParams, Stack } from "expo-router";
+import { View } from "react-native";
+
+import BookmarkList from "@/components/bookmarks/BookmarkList";
+import FullPageSpinner from "@/components/ui/FullPageSpinner";
+import { api } from "@/lib/trpc";
+
+export default function ListView() {
+ const { slug } = useLocalSearchParams();
+ if (typeof slug !== "string") {
+ throw new Error("Unexpected param type");
+ }
+ const { data: list } = api.lists.get.useQuery({ listId: slug });
+
+ if (!list) {
+ return <FullPageSpinner />;
+ }
+
+ return (
+ <>
+ <Stack.Screen
+ options={{
+ headerTitle: `${list.icon} ${list.name}`,
+ }}
+ />
+ <View>
+ <BookmarkList archived={false} ids={list.bookmarks} />
+ </View>
+ </>
+ );
+}
diff --git a/apps/mobile/app/error.tsx b/apps/mobile/app/error.tsx
new file mode 100644
index 00000000..2ca227a4
--- /dev/null
+++ b/apps/mobile/app/error.tsx
@@ -0,0 +1,9 @@
+import { View, Text } from "react-native";
+
+export default function ErrorPage() {
+ return (
+ <View className="flex-1 items-center justify-center gap-4">
+ <Text className="text-4xl">Error!</Text>
+ </View>
+ );
+}
diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx
new file mode 100644
index 00000000..5ce20cda
--- /dev/null
+++ b/apps/mobile/app/index.tsx
@@ -0,0 +1,20 @@
+import { useRouter } from "expo-router";
+import { useEffect } from "react";
+import { View } from "react-native";
+
+import { useSession } from "@/lib/session";
+
+export default function App() {
+ const router = useRouter();
+ const { isLoggedIn } = useSession();
+ useEffect(() => {
+ if (isLoggedIn === undefined) {
+ // Wait until it's loaded
+ } else if (isLoggedIn) {
+ router.replace("dashboard");
+ } else {
+ router.replace("signin");
+ }
+ }, [isLoggedIn]);
+ return <View />;
+}
diff --git a/apps/mobile/app/sharing.tsx b/apps/mobile/app/sharing.tsx
new file mode 100644
index 00000000..64bbd933
--- /dev/null
+++ b/apps/mobile/app/sharing.tsx
@@ -0,0 +1,99 @@
+import { Link, useLocalSearchParams, useRouter } from "expo-router";
+import { ShareIntent, useShareIntent } from "expo-share-intent";
+import { useEffect, useMemo, useState } from "react";
+import { View, Text } from "react-native";
+import { z } from "zod";
+
+import { api } from "@/lib/trpc";
+
+type Mode =
+ | { type: "idle" }
+ | { type: "success"; bookmarkId: string }
+ | { type: "error" };
+
+function SaveBookmark({ setMode }: { setMode: (mode: Mode) => void }) {
+ // Desperate attempt to fix sharing duplication
+ const { hasShareIntent, resetShareIntent } = useShareIntent();
+
+ const params = useLocalSearchParams();
+
+ const shareIntent = useMemo(() => {
+ if (params && params.shareIntent) {
+ if (typeof params.shareIntent === "string") {
+ return JSON.parse(params.shareIntent) as ShareIntent;
+ }
+ }
+ return null;
+ }, [params]);
+
+ const invalidateAllBookmarks =
+ api.useUtils().bookmarks.getBookmarks.invalidate;
+
+ useEffect(() => {
+ if (!isPending && shareIntent?.text) {
+ const val = z.string().url();
+ if (val.safeParse(shareIntent.text).success) {
+ // This is a URL, else treated as text
+ mutate({ type: "link", url: shareIntent.text });
+ } else {
+ mutate({ type: "text", text: shareIntent.text });
+ }
+ }
+ if (hasShareIntent) {
+ resetShareIntent();
+ }
+ }, []);
+
+ const { mutate, isPending } = api.bookmarks.createBookmark.useMutation({
+ onSuccess: (d) => {
+ invalidateAllBookmarks();
+ setMode({ type: "success", bookmarkId: d.id });
+ },
+ onError: () => {
+ setMode({ type: "error" });
+ },
+ });
+
+ return <Text className="text-4xl">Hoarding ...</Text>;
+}
+
+export default function Sharing() {
+ const router = useRouter();
+ const [mode, setMode] = useState<Mode>({ type: "idle" });
+
+ let comp;
+ switch (mode.type) {
+ case "idle": {
+ comp = <SaveBookmark setMode={setMode} />;
+ break;
+ }
+ case "success": {
+ comp = <Text className="text-4xl">Hoarded!</Text>;
+ break;
+ }
+ case "error": {
+ comp = <Text className="text-4xl">Error!</Text>;
+ break;
+ }
+ }
+
+ // Auto dismiss the modal after saving.
+ useEffect(() => {
+ if (mode.type === "idle") {
+ return;
+ }
+
+ const timeoutId = setTimeout(() => {
+ router.replace("dashboard");
+ }, 2000);
+
+ return () => clearTimeout(timeoutId);
+ }, [mode.type]);
+
+ return (
+ <View className="flex-1 items-center justify-center gap-4">
+ {comp}
+ <Link href="dashboard">Dismiss</Link>
+ </View>
+ );
+}
diff --git a/apps/mobile/app/signin.tsx b/apps/mobile/app/signin.tsx
new file mode 100644
index 00000000..a89b0087
--- /dev/null
+++ b/apps/mobile/app/signin.tsx
@@ -0,0 +1,101 @@
+import { useRouter } from "expo-router";
+import { useEffect, useState } from "react";
+import { View, Text } from "react-native";
+
+import Logo from "@/components/Logo";
+import { Button } from "@/components/ui/Button";
+import { Input } from "@/components/ui/Input";
+import useAppSettings from "@/lib/settings";
+import { api } from "@/lib/trpc";
+
+export default function Signin() {
+ const router = useRouter();
+
+ const { settings, setSettings } = useAppSettings();
+
+ const [error, setError] = useState<string | undefined>();
+
+ const { mutate: login, isPending } = api.apiKeys.exchange.useMutation({
+ onSuccess: (resp) => {
+ setSettings({ ...settings, apiKey: resp.key });
+ router.replace("dashboard");
+ },
+ onError: (e) => {
+ if (e.data?.code === "UNAUTHORIZED") {
+ setError("Wrong username or password");
+ } else {
+ setError(`${e.message}`);
+ }
+ },
+ });
+
+ const [formData, setFormData] = useState<{
+ email: string;
+ password: string;
+ }>({
+ email: "",
+ password: "",
+ });
+
+ useEffect(() => {
+ if (settings.apiKey) {
+ router.navigate("dashboard");
+ }
+ }, [settings]);
+
+ const onSignin = () => {
+ const randStr = (Math.random() + 1).toString(36).substring(5);
+ login({ ...formData, keyName: `Mobile App: (${randStr})` });
+ };
+
+ return (
+ <View className="flex h-full flex-col justify-center gap-2 px-4">
+ <View className="items-center">
+ <Logo />
+ </View>
+ {error && (
+ <Text className="w-full text-center text-red-500">{error}</Text>
+ )}
+ <View className="gap-2">
+ <Text className="font-bold">Server Address</Text>
+ <Input
+ className="w-full"
+ placeholder="Server Address"
+ value={settings.address}
+ autoCapitalize="none"
+ keyboardType="url"
+ onEndEditing={(e) =>
+ setSettings({ ...settings, address: e.nativeEvent.text })
+ }
+ />
+ </View>
+ <View className="gap-2">
+ <Text className="font-bold">Email</Text>
+ <Input
+ className="w-full"
+ placeholder="Email"
+ keyboardType="email-address"
+ autoCapitalize="none"
+ value={formData.email}
+ onChangeText={(e) => setFormData((s) => ({ ...s, email: e }))}
+ />
+ </View>
+ <View className="gap-2">
+ <Text className="font-bold">Password</Text>
+ <Input
+ className="w-full"
+ placeholder="Password"
+ secureTextEntry
+ value={formData.password}
+ onChangeText={(e) => setFormData((s) => ({ ...s, password: e }))}
+ />
+ </View>
+ <Button
+ className="w-full"
+ label="Sign In"
+ onPress={onSignin}
+ disabled={isPending}
+ />
+ </View>
+ );
+}
diff --git a/apps/mobile/assets/blur.jpeg b/apps/mobile/assets/blur.jpeg
new file mode 100644
index 00000000..387ce697
--- /dev/null
+++ b/apps/mobile/assets/blur.jpeg
Binary files differ
diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png
new file mode 100644
index 00000000..71ead90c
--- /dev/null
+++ b/apps/mobile/assets/icon.png
Binary files differ
diff --git a/apps/mobile/assets/splash.png b/apps/mobile/assets/splash.png
new file mode 100644
index 00000000..3759c518
--- /dev/null
+++ b/apps/mobile/assets/splash.png
Binary files differ
diff --git a/apps/mobile/babel.config.js b/apps/mobile/babel.config.js
new file mode 100644
index 00000000..f3c649bb
--- /dev/null
+++ b/apps/mobile/babel.config.js
@@ -0,0 +1,9 @@
+module.exports = function (api) {
+ api.cache(true);
+ return {
+ presets: [
+ ["babel-preset-expo", { jsxImportSource: "nativewind" }],
+ "nativewind/babel",
+ ],
+ };
+};
diff --git a/apps/mobile/components/Logo.tsx b/apps/mobile/components/Logo.tsx
new file mode 100644
index 00000000..57f7a5c3
--- /dev/null
+++ b/apps/mobile/components/Logo.tsx
@@ -0,0 +1,11 @@
+import { PackageOpen } from "lucide-react-native";
+import { View, Text } from "react-native";
+
+export default function Logo() {
+ return (
+ <View className="flex flex-row items-center justify-center gap-2 ">
+ <PackageOpen color="black" size={70} />
+ <Text className="text-5xl">Hoarder</Text>
+ </View>
+ );
+}
diff --git a/apps/mobile/components/bookmarks/BookmarkCard.tsx b/apps/mobile/components/bookmarks/BookmarkCard.tsx
new file mode 100644
index 00000000..25947790
--- /dev/null
+++ b/apps/mobile/components/bookmarks/BookmarkCard.tsx
@@ -0,0 +1,243 @@
+import { ZBookmark } from "@hoarder/trpc/types/bookmarks";
+import * as WebBrowser from "expo-web-browser";
+import { Star, Archive, Trash, ArchiveRestore } from "lucide-react-native";
+import { View, Text, Image, ScrollView, Pressable } from "react-native";
+import Markdown from "react-native-markdown-display";
+
+import { ActionButton } from "../ui/ActionButton";
+import { Divider } from "../ui/Divider";
+import { Skeleton } from "../ui/Skeleton";
+import { useToast } from "../ui/Toast";
+
+import { api } from "@/lib/trpc";
+
+const MAX_LOADING_MSEC = 30 * 1000;
+
+export function isBookmarkStillCrawling(bookmark: ZBookmark) {
+ return (
+ bookmark.content.type === "link" &&
+ !bookmark.content.crawledAt &&
+ Date.now().valueOf() - bookmark.createdAt.valueOf() < MAX_LOADING_MSEC
+ );
+}
+
+export function isBookmarkStillTagging(bookmark: ZBookmark) {
+ return (
+ bookmark.taggingStatus === "pending" &&
+ Date.now().valueOf() - bookmark.createdAt.valueOf() < MAX_LOADING_MSEC
+ );
+}
+
+export function isBookmarkStillLoading(bookmark: ZBookmark) {
+ return isBookmarkStillTagging(bookmark) || isBookmarkStillCrawling(bookmark);
+}
+
+function ActionBar({ bookmark }: { bookmark: ZBookmark }) {
+ const { toast } = useToast();
+ const apiUtils = api.useUtils();
+
+ const { mutate: deleteBookmark, isPending: isDeletionPending } =
+ api.bookmarks.deleteBookmark.useMutation({
+ onSuccess: () => {
+ apiUtils.bookmarks.getBookmarks.invalidate();
+ },
+ onError: () => {
+ toast({
+ message: "Something went wrong",
+ variant: "destructive",
+ showProgress: false,
+ });
+ },
+ });
+ const {
+ mutate: updateBookmark,
+ variables,
+ isPending: isUpdatePending,
+ } = api.bookmarks.updateBookmark.useMutation({
+ onSuccess: () => {
+ apiUtils.bookmarks.getBookmarks.invalidate();
+ apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: bookmark.id });
+ },
+ onError: () => {
+ toast({
+ message: "Something went wrong",
+ variant: "destructive",
+ showProgress: false,
+ });
+ },
+ });
+
+ return (
+ <View className="flex flex-row gap-4">
+ <Pressable
+ onPress={() =>
+ updateBookmark({
+ bookmarkId: bookmark.id,
+ favourited: !bookmark.favourited,
+ })
+ }
+ >
+ {(variables ? variables.favourited : bookmark.favourited) ? (
+ <Star fill="#ebb434" color="#ebb434" />
+ ) : (
+ <Star color="gray" />
+ )}
+ </Pressable>
+ <ActionButton
+ loading={isUpdatePending}
+ onPress={() =>
+ updateBookmark({
+ bookmarkId: bookmark.id,
+ archived: !bookmark.archived,
+ })
+ }
+ >
+ {bookmark.archived ? (
+ <ArchiveRestore color="gray" />
+ ) : (
+ <Archive color="gray" />
+ )}
+ </ActionButton>
+ <ActionButton
+ loading={isDeletionPending}
+ onPress={() =>
+ deleteBookmark({
+ bookmarkId: bookmark.id,
+ })
+ }
+ >
+ <Trash color="gray" />
+ </ActionButton>
+ </View>
+ );
+}
+
+function TagList({ bookmark }: { bookmark: ZBookmark }) {
+ const tags = bookmark.tags;
+
+ if (isBookmarkStillTagging(bookmark)) {
+ return (
+ <>
+ <Skeleton className="h-4 w-full" />
+ <Skeleton className="h-4 w-full" />
+ </>
+ );
+ }
+
+ return (
+ <ScrollView horizontal showsHorizontalScrollIndicator={false}>
+ <View className="flex flex-row gap-2">
+ {tags.map((t) => (
+ <View
+ key={t.id}
+ className="rounded-full border border-gray-200 px-2.5 py-0.5 text-xs font-semibold"
+ >
+ <Text>{t.name}</Text>
+ </View>
+ ))}
+ </View>
+ </ScrollView>
+ );
+}
+
+function LinkCard({ bookmark }: { bookmark: ZBookmark }) {
+ if (bookmark.content.type !== "link") {
+ throw new Error("Wrong content type rendered");
+ }
+
+ const url = bookmark.content.url;
+ const parsedUrl = new URL(url);
+
+ const imageComp = bookmark.content.imageUrl ? (
+ <Image
+ source={{ uri: bookmark.content.imageUrl }}
+ className="h-56 min-h-56 w-full rounded-t-lg object-cover"
+ />
+ ) : (
+ <Image
+ source={require("@/assets/blur.jpeg")}
+ className="h-56 w-full rounded-t-lg"
+ />
+ );
+
+ return (
+ <View className="flex gap-2">
+ {imageComp}
+ <View className="flex gap-2 p-2">
+ <Text
+ className="line-clamp-2 text-xl font-bold"
+ onPress={() => WebBrowser.openBrowserAsync(url)}
+ >
+ {bookmark.content.title || parsedUrl.host}
+ </Text>
+ <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">
+ <Text className="my-auto line-clamp-1">{parsedUrl.host}</Text>
+ <ActionBar bookmark={bookmark} />
+ </View>
+ </View>
+ </View>
+ );
+}
+
+function TextCard({ bookmark }: { bookmark: ZBookmark }) {
+ if (bookmark.content.type !== "text") {
+ throw new Error("Wrong content type rendered");
+ }
+ return (
+ <View className="flex max-h-96 gap-2 p-2">
+ <View className="max-h-56 overflow-hidden p-2">
+ <Markdown>{bookmark.content.text}</Markdown>
+ </View>
+ <TagList bookmark={bookmark} />
+ <Divider orientation="vertical" className="mt-2 h-0.5 w-full" />
+ <View className="flex flex-row justify-between p-2">
+ <View />
+ <ActionBar bookmark={bookmark} />
+ </View>
+ </View>
+ );
+}
+
+export default function BookmarkCard({
+ bookmark: initialData,
+}: {
+ bookmark: ZBookmark;
+}) {
+ const { data: bookmark } = api.bookmarks.getBookmark.useQuery(
+ {
+ bookmarkId: initialData.id,
+ },
+ {
+ initialData,
+ refetchInterval: (query) => {
+ const data = query.state.data;
+ if (!data) {
+ return false;
+ }
+ // If the link is not crawled or not tagged
+ if (isBookmarkStillLoading(data)) {
+ return 1000;
+ }
+ return false;
+ },
+ },
+ );
+
+ let comp;
+ switch (bookmark.content.type) {
+ case "link":
+ comp = <LinkCard bookmark={bookmark} />;
+ break;
+ case "text":
+ comp = <TextCard bookmark={bookmark} />;
+ break;
+ }
+
+ return (
+ <View className="w-96 rounded-lg border border-gray-300 bg-white shadow-sm">
+ {comp}
+ </View>
+ );
+}
diff --git a/apps/mobile/components/bookmarks/BookmarkList.tsx b/apps/mobile/components/bookmarks/BookmarkList.tsx
new file mode 100644
index 00000000..8e408709
--- /dev/null
+++ b/apps/mobile/components/bookmarks/BookmarkList.tsx
@@ -0,0 +1,61 @@
+import { useEffect, useState } from "react";
+import { Text, View } from "react-native";
+import Animated, { LinearTransition } from "react-native-reanimated";
+
+import BookmarkCard from "./BookmarkCard";
+import FullPageSpinner from "../ui/FullPageSpinner";
+
+import { api } from "@/lib/trpc";
+
+export default function BookmarkList({
+ favourited,
+ archived,
+ ids,
+}: {
+ favourited?: boolean;
+ archived?: boolean;
+ ids?: string[];
+}) {
+ const apiUtils = api.useUtils();
+ const [refreshing, setRefreshing] = useState(false);
+ const { data, isPending, isPlaceholderData } =
+ api.bookmarks.getBookmarks.useQuery({
+ favourited,
+ archived,
+ ids,
+ });
+
+ useEffect(() => {
+ setRefreshing(isPending || isPlaceholderData);
+ }, [isPending, isPlaceholderData]);
+
+ if (isPending || !data) {
+ return <FullPageSpinner />;
+ }
+
+ const onRefresh = () => {
+ apiUtils.bookmarks.getBookmarks.invalidate();
+ apiUtils.bookmarks.getBookmark.invalidate();
+ };
+
+ return (
+ <Animated.FlatList
+ itemLayoutAnimation={LinearTransition}
+ contentContainerStyle={{
+ gap: 15,
+ marginVertical: 15,
+ alignItems: "center",
+ }}
+ renderItem={(b) => <BookmarkCard bookmark={b.item} />}
+ ListEmptyComponent={
+ <View className="h-full items-center justify-center">
+ <Text className="text-xl">No Bookmarks</Text>
+ </View>
+ }
+ data={data.bookmarks}
+ refreshing={refreshing}
+ onRefresh={onRefresh}
+ keyExtractor={(b) => b.id}
+ />
+ );
+}
diff --git a/apps/mobile/components/ui/ActionButton.tsx b/apps/mobile/components/ui/ActionButton.tsx
new file mode 100644
index 00000000..c51eb332
--- /dev/null
+++ b/apps/mobile/components/ui/ActionButton.tsx
@@ -0,0 +1,21 @@
+import { ActivityIndicator, Pressable, PressableProps } from "react-native";
+
+export function ActionButton({
+ children,
+ loading,
+ disabled,
+ ...props
+}: PressableProps & {
+ loading: boolean;
+}) {
+ if (disabled !== undefined) {
+ disabled ||= loading;
+ } else if (loading) {
+ disabled = true;
+ }
+ return (
+ <Pressable {...props} disabled={disabled}>
+ {loading ? <ActivityIndicator /> : children}
+ </Pressable>
+ );
+}
diff --git a/apps/mobile/components/ui/Button.tsx b/apps/mobile/components/ui/Button.tsx
new file mode 100644
index 00000000..4c3cbc69
--- /dev/null
+++ b/apps/mobile/components/ui/Button.tsx
@@ -0,0 +1,81 @@
+import { type VariantProps, cva } from "class-variance-authority";
+import { Text, TouchableOpacity } from "react-native";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "flex flex-row items-center justify-center rounded-md",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary",
+ secondary: "bg-secondary",
+ destructive: "bg-destructive",
+ ghost: "bg-slate-700",
+ link: "text-primary underline-offset-4",
+ },
+ size: {
+ default: "h-10 px-4",
+ sm: "h-8 px-2",
+ lg: "h-12 px-8",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+const buttonTextVariants = cva("text-center font-medium", {
+ variants: {
+ variant: {
+ default: "text-primary-foreground",
+ secondary: "text-secondary-foreground",
+ destructive: "text-destructive-foreground",
+ ghost: "text-primary-foreground",
+ link: "text-primary-foreground underline",
+ },
+ size: {
+ default: "text-base",
+ sm: "text-sm",
+ lg: "text-xl",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+});
+
+interface ButtonProps
+ extends React.ComponentPropsWithoutRef<typeof TouchableOpacity>,
+ VariantProps<typeof buttonVariants> {
+ label: string;
+ labelClasses?: string;
+}
+function Button({
+ label,
+ labelClasses,
+ className,
+ variant,
+ size,
+ ...props
+}: ButtonProps) {
+ return (
+ <TouchableOpacity
+ className={cn(buttonVariants({ variant, size, className }))}
+ {...props}
+ >
+ <Text
+ className={cn(
+ buttonTextVariants({ variant, size, className: labelClasses }),
+ )}
+ >
+ {label}
+ </Text>
+ </TouchableOpacity>
+ );
+}
+
+export { Button, buttonVariants, buttonTextVariants };
diff --git a/apps/mobile/components/ui/Divider.tsx b/apps/mobile/components/ui/Divider.tsx
new file mode 100644
index 00000000..1da0a71e
--- /dev/null
+++ b/apps/mobile/components/ui/Divider.tsx
@@ -0,0 +1,28 @@
+import { View } from "react-native";
+
+import { cn } from "@/lib/utils";
+
+function Divider({
+ color = "#DFE4EA",
+ className,
+ orientation,
+ ...props
+}: {
+ color?: string;
+ orientation: "horizontal" | "vertical";
+} & React.ComponentPropsWithoutRef<typeof View>) {
+ const dividerStyles = [{ backgroundColor: color }];
+
+ return (
+ <View
+ className={cn(
+ orientation === "horizontal" ? "h-0.5" : "w-0.5",
+ className,
+ )}
+ style={dividerStyles}
+ {...props}
+ />
+ );
+}
+
+export { Divider };
diff --git a/apps/mobile/components/ui/FullPageSpinner.tsx b/apps/mobile/components/ui/FullPageSpinner.tsx
new file mode 100644
index 00000000..01187f11
--- /dev/null
+++ b/apps/mobile/components/ui/FullPageSpinner.tsx
@@ -0,0 +1,9 @@
+import { View, ActivityIndicator } from "react-native";
+
+export default function FullPageSpinner() {
+ return (
+ <View className="h-full w-full items-center justify-center">
+ <ActivityIndicator />
+ </View>
+ );
+}
diff --git a/apps/mobile/components/ui/Input.tsx b/apps/mobile/components/ui/Input.tsx
new file mode 100644
index 00000000..2fcb2764
--- /dev/null
+++ b/apps/mobile/components/ui/Input.tsx
@@ -0,0 +1,28 @@
+import { forwardRef } from "react";
+import { Text, TextInput, View } from "react-native";
+
+import { cn } from "@/lib/utils";
+
+export interface InputProps
+ extends React.ComponentPropsWithoutRef<typeof TextInput> {
+ label?: string;
+ labelClasses?: string;
+ inputClasses?: string;
+}
+
+const Input = forwardRef<React.ElementRef<typeof TextInput>, InputProps>(
+ ({ className, label, labelClasses, inputClasses, ...props }, ref) => (
+ <View className={cn("flex flex-col gap-1.5", className)}>
+ {label && <Text className={cn("text-base", labelClasses)}>{label}</Text>}
+ <TextInput
+ className={cn(
+ inputClasses,
+ "border-input rounded-lg border px-4 py-2.5",
+ )}
+ {...props}
+ />
+ </View>
+ ),
+);
+
+export { Input };
diff --git a/apps/mobile/components/ui/Skeleton.tsx b/apps/mobile/components/ui/Skeleton.tsx
new file mode 100644
index 00000000..68b22e1e
--- /dev/null
+++ b/apps/mobile/components/ui/Skeleton.tsx
@@ -0,0 +1,38 @@
+import { useEffect, useRef } from "react";
+import { Animated, type View } from "react-native";
+
+import { cn } from "@/lib/utils";
+
+function Skeleton({
+ className,
+ ...props
+}: { className?: string } & React.ComponentPropsWithoutRef<typeof View>) {
+ const fadeAnim = useRef(new Animated.Value(0.5)).current;
+
+ useEffect(() => {
+ Animated.loop(
+ Animated.sequence([
+ Animated.timing(fadeAnim, {
+ toValue: 1,
+ duration: 1000,
+ useNativeDriver: true,
+ }),
+ Animated.timing(fadeAnim, {
+ toValue: 0.5,
+ duration: 1000,
+ useNativeDriver: true,
+ }),
+ ]),
+ ).start();
+ }, [fadeAnim]);
+
+ return (
+ <Animated.View
+ className={cn("bg-muted rounded-md", className)}
+ style={[{ opacity: fadeAnim }]}
+ {...props}
+ />
+ );
+}
+
+export { Skeleton };
diff --git a/apps/mobile/components/ui/Toast.tsx b/apps/mobile/components/ui/Toast.tsx
new file mode 100644
index 00000000..fb319f84
--- /dev/null
+++ b/apps/mobile/components/ui/Toast.tsx
@@ -0,0 +1,183 @@
+import { createContext, useContext, useEffect, useRef, useState } from "react";
+import { Animated, Text, View } from "react-native";
+
+import { cn } from "@/lib/utils";
+
+const toastVariants = {
+ default: "bg-foreground",
+ destructive: "bg-destructive",
+ success: "bg-green-500",
+ info: "bg-blue-500",
+};
+
+interface ToastProps {
+ id: number;
+ message: string;
+ onHide: (id: number) => void;
+ variant?: keyof typeof toastVariants;
+ duration?: number;
+ showProgress?: boolean;
+}
+function Toast({
+ id,
+ message,
+ onHide,
+ variant = "default",
+ duration = 3000,
+ showProgress = true,
+}: ToastProps) {
+ const opacity = useRef(new Animated.Value(0)).current;
+ const progress = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ Animated.sequence([
+ Animated.timing(opacity, {
+ toValue: 1,
+ duration: 500,
+ useNativeDriver: true,
+ }),
+ Animated.timing(progress, {
+ toValue: 1,
+ duration: duration - 1000,
+ useNativeDriver: false,
+ }),
+ Animated.timing(opacity, {
+ toValue: 0,
+ duration: 500,
+ useNativeDriver: true,
+ }),
+ ]).start(() => onHide(id));
+ }, [duration]);
+
+ return (
+ <Animated.View
+ className={`
+ ${toastVariants[variant]}
+ m-2 mb-1 transform rounded-lg p-4 shadow-md transition-all
+ `}
+ style={{
+ opacity,
+ transform: [
+ {
+ translateY: opacity.interpolate({
+ inputRange: [0, 1],
+ outputRange: [-20, 0],
+ }),
+ },
+ ],
+ }}
+ >
+ <Text className="text-background text-left font-semibold">{message}</Text>
+ {showProgress && (
+ <View className="mt-2 rounded">
+ <Animated.View
+ className="h-2 rounded bg-white opacity-30 dark:bg-black"
+ style={{
+ width: progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: ["0%", "100%"],
+ }),
+ }}
+ />
+ </View>
+ )}
+ </Animated.View>
+ );
+}
+
+type ToastVariant = keyof typeof toastVariants;
+
+interface ToastMessage {
+ id: number;
+ text: string;
+ variant: ToastVariant;
+ duration?: number;
+ position?: string;
+ showProgress?: boolean;
+}
+interface ToastContextProps {
+ toast: (t: {
+ message: string;
+ variant?: keyof typeof toastVariants;
+ duration?: number;
+ position?: "top" | "bottom";
+ showProgress?: boolean;
+ }) => void;
+ removeToast: (id: number) => void;
+}
+const ToastContext = createContext<ToastContextProps | undefined>(undefined);
+
+// TODO: refactor to pass position to Toast instead of ToastProvider
+function ToastProvider({
+ children,
+ position = "top",
+}: {
+ children: React.ReactNode;
+ position?: "top" | "bottom";
+}) {
+ const [messages, setMessages] = useState<ToastMessage[]>([]);
+
+ const toast: ToastContextProps["toast"] = ({
+ message,
+ variant = "default",
+ duration = 3000,
+ position = "top",
+ showProgress = true,
+ }: {
+ message: string;
+ variant?: ToastVariant;
+ duration?: number;
+ position?: "top" | "bottom";
+ showProgress?: boolean;
+ }) => {
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: Date.now(),
+ text: message,
+ variant,
+ duration,
+ position,
+ showProgress,
+ },
+ ]);
+ };
+
+ const removeToast = (id: number) => {
+ setMessages((prev) => prev.filter((message) => message.id !== id));
+ };
+
+ return (
+ <ToastContext.Provider value={{ toast, removeToast }}>
+ {children}
+ <View
+ className={cn("absolute left-0 right-0", {
+ "top-[45px]": position === "top",
+ "bottom-0": position === "bottom",
+ })}
+ >
+ {messages.map((message) => (
+ <Toast
+ key={message.id}
+ id={message.id}
+ message={message.text}
+ variant={message.variant}
+ duration={message.duration}
+ showProgress={message.showProgress}
+ onHide={removeToast}
+ />
+ ))}
+ </View>
+ </ToastContext.Provider>
+ );
+}
+
+function useToast() {
+ const context = useContext(ToastContext);
+ if (!context) {
+ throw new Error("useToast must be used within ToastProvider");
+ }
+ return context;
+}
+
+export { ToastProvider, ToastVariant, Toast, toastVariants, useToast };
diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json
new file mode 100644
index 00000000..0897755d
--- /dev/null
+++ b/apps/mobile/eas.json
@@ -0,0 +1,19 @@
+{
+ "cli": {
+ "version": ">= 7.5.0",
+ "promptToConfigurePushNotifications": false
+ },
+ "build": {
+ "development": {
+ "developmentClient": true,
+ "distribution": "internal"
+ },
+ "preview": {
+ "distribution": "internal"
+ },
+ "production": {}
+ },
+ "submit": {
+ "production": {}
+ }
+}
diff --git a/apps/mobile/globals.css b/apps/mobile/globals.css
new file mode 100644
index 00000000..de1cf559
--- /dev/null
+++ b/apps/mobile/globals.css
@@ -0,0 +1,80 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 222.2 47.4% 11.2%;
+
+ --muted: 210 40% 96.1%;
+ --muted-foreground: 215.4 16.3% 46.9%;
+
+ --popover: 0 0% 100%;
+ --popover-foreground: 222.2 47.4% 11.2%;
+
+ --border: 214.3 31.8% 91.4%;
+ --input: 214.3 31.8% 91.4%;
+
+ --card: 0 0% 100%;
+ --card-foreground: 222.2 47.4% 11.2%;
+
+ --primary: 222.2 47.4% 11.2%;
+ --primary-foreground: 210 40% 98%;
+
+ --secondary: 210 40% 96.1%;
+ --secondary-foreground: 222.2 47.4% 11.2%;
+
+ --accent: 210 40% 96.1%;
+ --accent-foreground: 222.2 47.4% 11.2%;
+
+ --destructive: 0 100% 50%;
+ --destructive-foreground: 210 40% 98%;
+
+ --ring: 215 20.2% 65.1%;
+
+ --radius: 0.5rem;
+ }
+
+ .dark:root {
+ --background: 224 71% 4%;
+ --foreground: 213 31% 91%;
+
+ --muted: 223 47% 11%;
+ --muted-foreground: 215.4 16.3% 56.9%;
+
+ --accent: 216 34% 17%;
+ --accent-foreground: 210 40% 98%;
+
+ --popover: 224 71% 4%;
+ --popover-foreground: 215 20.2% 65.1%;
+
+ --border: 216 34% 17%;
+ --input: 216 34% 17%;
+
+ --card: 224 71% 4%;
+ --card-foreground: 213 31% 91%;
+
+ --primary: 210 40% 98%;
+ --primary-foreground: 222.2 47.4% 1.2%;
+
+ --secondary: 222.2 47.4% 11.2%;
+ --secondary-foreground: 210 40% 98%;
+
+ --destructive: 0 63% 31%;
+ --destructive-foreground: 210 40% 98%;
+
+ --ring: 216 34% 17%;
+
+ --radius: 0.5rem;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/apps/mobile/lib/last-shared-intent.ts b/apps/mobile/lib/last-shared-intent.ts
new file mode 100644
index 00000000..951bcf74
--- /dev/null
+++ b/apps/mobile/lib/last-shared-intent.ts
@@ -0,0 +1,15 @@
+import { create } from "zustand";
+
+interface LastSharedIntent {
+ lastIntent: string;
+ setIntent: (intent: string) => void;
+ isPreviouslyShared: (intent: string) => boolean;
+}
+
+export const useLastSharedIntent = create<LastSharedIntent>((set, get) => ({
+ lastIntent: "",
+ setIntent: (intent: string) => set({ lastIntent: intent }),
+ isPreviouslyShared: (intent: string) => {
+ return get().lastIntent === intent;
+ },
+}));
diff --git a/apps/mobile/lib/providers.tsx b/apps/mobile/lib/providers.tsx
new file mode 100644
index 00000000..1717afb2
--- /dev/null
+++ b/apps/mobile/lib/providers.tsx
@@ -0,0 +1,54 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { httpBatchLink } from "@trpc/client";
+import { useEffect, useState } from "react";
+import superjson from "superjson";
+
+import useAppSettings, { getAppSettings } from "./settings";
+import { api } from "./trpc";
+
+import { ToastProvider } from "@/components/ui/Toast";
+
+function getTRPCClient(address: string) {
+ return api.createClient({
+ links: [
+ httpBatchLink({
+ url: `${address}/api/trpc`,
+ async headers() {
+ const settings = await getAppSettings();
+ return {
+ Authorization:
+ settings && settings.apiKey
+ ? `Bearer ${settings.apiKey}`
+ : undefined,
+ };
+ },
+ transformer: superjson,
+ }),
+ ],
+ });
+}
+
+export function Providers({ children }: { children: React.ReactNode }) {
+ const { settings } = useAppSettings();
+ const [queryClient] = useState(() => new QueryClient());
+
+ const [trpcClient, setTrpcClient] = useState<
+ ReturnType<typeof getTRPCClient>
+ >(getTRPCClient(settings.address));
+
+ useEffect(() => {
+ setTrpcClient(getTRPCClient(settings.address));
+ }, [settings.address]);
+
+ return (
+ <api.Provider
+ key={settings.address}
+ client={trpcClient}
+ queryClient={queryClient}
+ >
+ <QueryClientProvider client={queryClient}>
+ <ToastProvider>{children}</ToastProvider>
+ </QueryClientProvider>
+ </api.Provider>
+ );
+}
diff --git a/apps/mobile/lib/session.ts b/apps/mobile/lib/session.ts
new file mode 100644
index 00000000..e2ab245b
--- /dev/null
+++ b/apps/mobile/lib/session.ts
@@ -0,0 +1,20 @@
+import { useCallback, useMemo } from "react";
+
+import useAppSettings from "./settings";
+
+export function useSession() {
+ const { settings, isLoading, setSettings } = useAppSettings();
+ const isLoggedIn = useMemo(() => {
+ return isLoading ? undefined : !!settings.apiKey;
+ }, [isLoading, settings]);
+
+ const logout = useCallback(() => {
+ setSettings({ ...settings, apiKey: undefined });
+ }, [settings]);
+
+ return {
+ isLoggedIn,
+ isLoading,
+ logout,
+ };
+}
diff --git a/apps/mobile/lib/settings.ts b/apps/mobile/lib/settings.ts
new file mode 100644
index 00000000..21f40528
--- /dev/null
+++ b/apps/mobile/lib/settings.ts
@@ -0,0 +1,29 @@
+import * as SecureStore from "expo-secure-store";
+
+import { useStorageState } from "./storage-state";
+
+const SETTING_NAME = "settings";
+
+export type Settings = {
+ apiKey?: string;
+ address: string;
+};
+
+export default function useAppSettings() {
+ let [[isLoading, settings], setSettings] =
+ useStorageState<Settings>(SETTING_NAME);
+
+ settings ||= {
+ address: "https://demo.hoarder.app",
+ };
+
+ return { settings, setSettings, isLoading };
+}
+
+export async function getAppSettings() {
+ const val = await SecureStore.getItemAsync(SETTING_NAME);
+ if (!val) {
+ return null;
+ }
+ return JSON.parse(val) as Settings;
+}
diff --git a/apps/mobile/lib/storage-state.ts b/apps/mobile/lib/storage-state.ts
new file mode 100644
index 00000000..4988f0e0
--- /dev/null
+++ b/apps/mobile/lib/storage-state.ts
@@ -0,0 +1,51 @@
+import * as SecureStore from "expo-secure-store";
+import * as React from "react";
+
+type UseStateHook<T> = [[boolean, T | null], (value: T | null) => void];
+
+function useAsyncState<T>(
+ initialValue: [boolean, T | null] = [true, null],
+): UseStateHook<T> {
+ return React.useReducer(
+ (
+ state: [boolean, T | null],
+ action: T | null = null,
+ ): [boolean, T | null] => [false, action],
+ initialValue,
+ ) as UseStateHook<T>;
+}
+
+export async function setStorageItemAsync(key: string, value: string | null) {
+ if (value == null) {
+ await SecureStore.deleteItemAsync(key);
+ } else {
+ await SecureStore.setItemAsync(key, value);
+ }
+}
+
+export function useStorageState<T>(key: string): UseStateHook<T> {
+ // Public
+ const [state, setState] = useAsyncState<T>();
+
+ // Get
+ React.useEffect(() => {
+ SecureStore.getItemAsync(key).then((value) => {
+ if (!value) {
+ setState(null);
+ return null;
+ }
+ setState(JSON.parse(value));
+ });
+ }, [key]);
+
+ // Set
+ const setValue = React.useCallback(
+ (value: T | null) => {
+ setState(value);
+ setStorageItemAsync(key, JSON.stringify(value));
+ },
+ [key],
+ );
+
+ return [state, setValue];
+}
diff --git a/apps/mobile/lib/trpc.ts b/apps/mobile/lib/trpc.ts
new file mode 100644
index 00000000..6b428bd9
--- /dev/null
+++ b/apps/mobile/lib/trpc.ts
@@ -0,0 +1,4 @@
+import type { AppRouter } from "@hoarder/trpc/routers/_app";
+import { createTRPCReact } from "@trpc/react-query";
+
+export const api = createTRPCReact<AppRouter>();
diff --git a/apps/mobile/lib/utils.ts b/apps/mobile/lib/utils.ts
new file mode 100644
index 00000000..365058ce
--- /dev/null
+++ b/apps/mobile/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js
new file mode 100644
index 00000000..c5630a83
--- /dev/null
+++ b/apps/mobile/metro.config.js
@@ -0,0 +1,58 @@
+// Learn more: https://docs.expo.dev/guides/monorepos/
+const { getDefaultConfig } = require("expo/metro-config");
+const { FileStore } = require("metro-cache");
+const { withNativeWind } = require("nativewind/metro");
+
+const path = require("path");
+
+module.exports = withTurborepoManagedCache(
+ withMonorepoPaths(
+ withNativeWind(getDefaultConfig(__dirname), {
+ input: "./globals.css",
+ configPath: "./tailwind.config.ts",
+ }),
+ ),
+);
+
+/**
+ * Add the monorepo paths to the Metro config.
+ * This allows Metro to resolve modules from the monorepo.
+ *
+ * @see https://docs.expo.dev/guides/monorepos/#modify-the-metro-config
+ * @param {import('expo/metro-config').MetroConfig} config
+ * @returns {import('expo/metro-config').MetroConfig}
+ */
+function withMonorepoPaths(config) {
+ const projectRoot = __dirname;
+ const workspaceRoot = path.resolve(projectRoot, "../..");
+
+ // #1 - Watch all files in the monorepo
+ config.watchFolders = [workspaceRoot];
+
+ // #2 - Resolve modules within the project's `node_modules` first, then all monorepo modules
+ config.resolver.nodeModulesPaths = [
+ path.resolve(projectRoot, "node_modules"),
+ path.resolve(workspaceRoot, "node_modules"),
+ ];
+
+ return config;
+}
+
+/**
+ * Move the Metro cache to the `node_modules/.cache/metro` folder.
+ * This repository configured Turborepo to use this cache location as well.
+ * If you have any environment variables, you can configure Turborepo to invalidate it when needed.
+ *
+ * @see https://turbo.build/repo/docs/reference/configuration#env
+ * @param {import('expo/metro-config').MetroConfig} config
+ * @returns {import('expo/metro-config').MetroConfig}
+ */
+function withTurborepoManagedCache(config) {
+ config.cacheStores = [
+ new FileStore({ root: path.join(__dirname, "node_modules/.cache/metro") }),
+ ];
+ return config;
+}
+
+
+
diff --git a/apps/mobile/nativewind-env.d.ts b/apps/mobile/nativewind-env.d.ts
new file mode 100644
index 00000000..a13e3136
--- /dev/null
+++ b/apps/mobile/nativewind-env.d.ts
@@ -0,0 +1 @@
+/// <reference types="nativewind/types" />
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
new file mode 100644
index 00000000..80036724
--- /dev/null
+++ b/apps/mobile/package.json
@@ -0,0 +1,71 @@
+{
+ "name": "@hoarder/mobile",
+ "version": "1.0.0",
+ "main": "expo-router/entry",
+ "scripts": {
+ "clean": "git clean -xdf .expo .turbo node_modules",
+ "start": "expo start",
+ "android": "expo run:android",
+ "ios": "expo run:ios",
+ "web": "expo start --web",
+ "lint": "eslint .",
+ "format": "prettier --check . --ignore-path .gitignore",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@hoarder/trpc": "0.1.0",
+ "@tanstack/react-query": "^5.24.8",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.0",
+ "expo": "~50.0.11",
+ "expo-config-plugin-ios-share-extension": "^0.0.4",
+ "expo-constants": "~15.4.5",
+ "expo-dev-client": "^3.3.9",
+ "expo-image": "^1.10.6",
+ "expo-linking": "~6.2.2",
+ "expo-router": "~3.4.8",
+ "expo-secure-store": "^12.8.1",
+ "expo-share-intent": "^1.0.1",
+ "expo-status-bar": "~1.11.1",
+ "expo-web-browser": "^12.8.2",
+ "lucide-react-native": "^0.354.0",
+ "nativewind": "^4.0.1",
+ "react": "^18.2.0",
+ "react-native": "0.73.4",
+ "react-native-markdown-display": "^7.0.2",
+ "react-native-reanimated": "^3.8.0",
+ "react-native-safe-area-context": "4.8.2",
+ "react-native-screens": "~3.29.0",
+ "react-native-svg": "^15.1.0",
+ "tailwind-merge": "^2.2.1",
+ "use-debounce": "^10.0.0",
+ "zod": "^3.22.4",
+ "zustand": "^4.5.1"
+ },
+ "devDependencies": {
+ "@hoarder/eslint-config": "workspace:^0.2.0",
+ "@hoarder/prettier-config": "workspace:^0.1.0",
+ "@hoarder/tailwind-config": "workspace:^0.1.0",
+ "@hoarder/tsconfig": "workspace:^0.1.0",
+ "@babel/core": "^7.20.0",
+ "@types/react": "^18.2.55",
+ "ajv": "latest",
+ "eslint": "^8.57.0",
+ "eslint-config-universe": "^12.0.0",
+ "prettier": "^3.2.5",
+ "tailwindcss": "^3.4.1",
+ "typescript": "^5.3.3"
+ },
+ "private": true,
+ "eslintConfig": {
+ "root": true,
+ "extends": [
+ "@hoarder/eslint-config/base",
+ "@hoarder/eslint-config/react"
+ ],
+ "ignorePatterns": [
+ "expo-plugins/**"
+ ]
+ },
+ "prettier": "@hoarder/prettier-config"
+}
diff --git a/apps/mobile/tailwind.config.ts b/apps/mobile/tailwind.config.ts
new file mode 100644
index 00000000..ce0059d4
--- /dev/null
+++ b/apps/mobile/tailwind.config.ts
@@ -0,0 +1,73 @@
+import type { Config } from "tailwindcss";
+import { hairlineWidth } from "nativewind/theme";
+
+const config = {
+ content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"],
+ plugins: [],
+ presets: [require("nativewind/preset")],
+ theme: {
+ container: {
+ center: true,
+ padding: "2rem",
+ screens: {
+ "2xl": "1400px",
+ },
+ },
+ extend: {
+ colors: {
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ },
+ borderWidth: {
+ hairline: hairlineWidth(),
+ },
+ keyframes: {
+ "accordion-down": {
+ from: { height: "0" },
+ to: { height: "var(--radix-accordion-content-height)" },
+ },
+ "accordion-up": {
+ from: { height: "var(--radix-accordion-content-height)" },
+ to: { height: "0" },
+ },
+ },
+ animation: {
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ },
+ },
+ },
+} satisfies Config;
+
+export default config;
diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json
new file mode 100644
index 00000000..3bcf5741
--- /dev/null
+++ b/apps/mobile/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "expo/tsconfig.base",
+ "compilerOptions": {
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
+ "types": ["nativewind/types"],
+ "incremental": true,
+ "strict": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "exclude": ["node_modules"]
+}
diff --git a/apps/web/README.md b/apps/web/README.md
new file mode 100644
index 00000000..c4033664
--- /dev/null
+++ b/apps/web/README.md
@@ -0,0 +1,36 @@
+This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
+
+## Getting Started
+
+First, run the development server:
+
+```bash
+npm run dev
+# or
+yarn dev
+# or
+pnpm dev
+# or
+bun dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+
+You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+
+This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
+
+## Learn More
+
+To learn more about Next.js, take a look at the following resources:
+
+- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
+- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+
+You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
+
+## Deploy on Vercel
+
+The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+
+Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
diff --git a/apps/web/app/api/auth/[...nextauth]/route.tsx b/apps/web/app/api/auth/[...nextauth]/route.tsx
new file mode 100644
index 00000000..2f7f1cb0
--- /dev/null
+++ b/apps/web/app/api/auth/[...nextauth]/route.tsx
@@ -0,0 +1,3 @@
+import { authHandler } from "@/server/auth";
+
+export { authHandler as GET, authHandler as POST };
diff --git a/apps/web/app/api/trpc/[trpc]/route.ts b/apps/web/app/api/trpc/[trpc]/route.ts
new file mode 100644
index 00000000..02ca966d
--- /dev/null
+++ b/apps/web/app/api/trpc/[trpc]/route.ts
@@ -0,0 +1,36 @@
+import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
+import { appRouter } from "@hoarder/trpc/routers/_app";
+import { createContext } from "@/server/api/client";
+import { authenticateApiKey } from "@hoarder/trpc/auth";
+import { db } from "@hoarder/db";
+
+const handler = (req: Request) =>
+ fetchRequestHandler({
+ endpoint: "/api/trpc",
+ req,
+ router: appRouter,
+ onError: ({ path, error }) => {
+ if (process.env.NODE_ENV === "development") {
+ console.error(`❌ tRPC failed on ${path}`);
+ }
+ console.error(error);
+ },
+
+ 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();
+ },
+ });
+export { handler as GET, handler as POST };
diff --git a/apps/web/app/dashboard/admin/page.tsx b/apps/web/app/dashboard/admin/page.tsx
new file mode 100644
index 00000000..6babdd79
--- /dev/null
+++ b/apps/web/app/dashboard/admin/page.tsx
@@ -0,0 +1,203 @@
+"use client";
+
+import { ActionButton } from "@/components/ui/action-button";
+import LoadingSpinner from "@/components/ui/spinner";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { toast } from "@/components/ui/use-toast";
+import { api } from "@/lib/trpc";
+import { keepPreviousData } from "@tanstack/react-query";
+import { Trash } from "lucide-react";
+import { useSession } from "next-auth/react";
+import { useRouter } from "next/navigation";
+
+function ActionsSection() {
+ const { mutate: recrawlLinks, isPending: isRecrawlPending } =
+ api.admin.recrawlAllLinks.useMutation({
+ onSuccess: () => {
+ toast({
+ description: "Recrawl enqueued",
+ });
+ },
+ onError: (e) => {
+ toast({
+ variant: "destructive",
+ description: e.message,
+ });
+ },
+ });
+
+ const { mutate: reindexBookmarks, isPending: isReindexPending } =
+ api.admin.reindexAllBookmarks.useMutation({
+ onSuccess: () => {
+ toast({
+ description: "Reindex enqueued",
+ });
+ },
+ onError: (e) => {
+ toast({
+ variant: "destructive",
+ description: e.message,
+ });
+ },
+ });
+
+ return (
+ <>
+ <p className="text-xl">Actions</p>
+ <ActionButton
+ className="w-1/2"
+ variant="destructive"
+ loading={isRecrawlPending}
+ onClick={() => recrawlLinks()}
+ >
+ Recrawl All Links
+ </ActionButton>
+ <ActionButton
+ className="w-1/2"
+ variant="destructive"
+ loading={isReindexPending}
+ onClick={() => reindexBookmarks()}
+ >
+ Reindex All Bookmarks
+ </ActionButton>
+ </>
+ );
+}
+
+function ServerStatsSection() {
+ const { data: serverStats } = api.admin.stats.useQuery(undefined, {
+ refetchInterval: 1000,
+ placeholderData: keepPreviousData,
+ });
+
+ if (!serverStats) {
+ return <LoadingSpinner />;
+ }
+
+ return (
+ <>
+ <p className="text-xl">Server Stats</p>
+ <Table className="w-1/2">
+ <TableBody>
+ <TableRow>
+ <TableCell className="w-2/3">Num Users</TableCell>
+ <TableCell>{serverStats.numUsers}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell>Num Bookmarks</TableCell>
+ <TableCell>{serverStats.numBookmarks}</TableCell>
+ </TableRow>
+ </TableBody>
+ </Table>
+ <hr />
+ <p className="text-xl">Background Jobs</p>
+ <Table className="w-1/2">
+ <TableBody>
+ <TableRow>
+ <TableCell className="w-2/3">Pending Crawling Jobs</TableCell>
+ <TableCell>{serverStats.pendingCrawls}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell>Pending Indexing Jobs</TableCell>
+ <TableCell>{serverStats.pendingIndexing}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell>Pending OpenAI Jobs</TableCell>
+ <TableCell>{serverStats.pendingOpenai}</TableCell>
+ </TableRow>
+ </TableBody>
+ </Table>
+ </>
+ );
+}
+
+function UsersSection() {
+ const { data: session } = useSession();
+ const invalidateUserList = api.useUtils().users.list.invalidate;
+ const { data: users } = api.users.list.useQuery();
+ const { mutate: deleteUser, isPending: isDeletionPending } =
+ api.users.delete.useMutation({
+ onSuccess: () => {
+ toast({
+ description: "User deleted",
+ });
+ invalidateUserList();
+ },
+ onError: (e) => {
+ toast({
+ variant: "destructive",
+ description: `Something went wrong: ${e.message}`,
+ });
+ },
+ });
+
+ if (!users) {
+ return <LoadingSpinner />;
+ }
+
+ return (
+ <>
+ <p className="text-xl">Users</p>
+ <Table>
+ <TableHeader>
+ <TableHead>Name</TableHead>
+ <TableHead>Email</TableHead>
+ <TableHead>Role</TableHead>
+ <TableHead>Action</TableHead>
+ </TableHeader>
+ <TableBody>
+ {users.users.map((u) => (
+ <TableRow key={u.id}>
+ <TableCell>{u.name}</TableCell>
+ <TableCell>{u.email}</TableCell>
+ <TableCell>{u.role}</TableCell>
+ <TableCell>
+ <ActionButton
+ variant="destructive"
+ onClick={() => deleteUser({ userId: u.id })}
+ loading={isDeletionPending}
+ disabled={session!.user.id == u.id}
+ >
+ <Trash />
+ </ActionButton>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </>
+ );
+}
+
+export default function AdminPage() {
+ const router = useRouter();
+ const { data: session, status } = useSession();
+
+ if (status == "loading") {
+ return <LoadingSpinner />;
+ }
+
+ if (!session || session.user.role != "admin") {
+ router.push("/");
+ return;
+ }
+
+ return (
+ <div className="m-4 flex flex-col gap-5 rounded-md border bg-white p-4">
+ <p className="text-2xl">Admin</p>
+ <hr />
+ <ServerStatsSection />
+ <hr />
+ <UsersSection />
+ <hr />
+ <ActionsSection />
+ </div>
+ );
+}
diff --git a/apps/web/app/dashboard/archive/page.tsx b/apps/web/app/dashboard/archive/page.tsx
new file mode 100644
index 00000000..69559185
--- /dev/null
+++ b/apps/web/app/dashboard/archive/page.tsx
@@ -0,0 +1,9 @@
+import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks";
+
+export default async function ArchivedBookmarkPage() {
+ return (
+ <div className="continer mt-4">
+ <Bookmarks title="🗄️ Archive" archived={true} showDivider={true} />
+ </div>
+ );
+}
diff --git a/apps/web/app/dashboard/bookmarks/layout.tsx b/apps/web/app/dashboard/bookmarks/layout.tsx
new file mode 100644
index 00000000..71ee143b
--- /dev/null
+++ b/apps/web/app/dashboard/bookmarks/layout.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import TopNav from "@/components/dashboard/bookmarks/TopNav";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Hoarder - Bookmarks",
+};
+
+export default function BookmarksLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+ <div className="flex h-full flex-col">
+ <div>
+ <TopNav />
+ </div>
+ <hr />
+ <div className="my-4 flex-1 pb-4">{children}</div>
+ </div>
+ );
+}
diff --git a/apps/web/app/dashboard/bookmarks/loading.tsx b/apps/web/app/dashboard/bookmarks/loading.tsx
new file mode 100644
index 00000000..4e56c3c4
--- /dev/null
+++ b/apps/web/app/dashboard/bookmarks/loading.tsx
@@ -0,0 +1,11 @@
+import Spinner from "@/components/ui/spinner";
+
+export default function Loading() {
+ return (
+ <div className="flex size-full">
+ <div className="m-auto">
+ <Spinner />
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/app/dashboard/bookmarks/page.tsx b/apps/web/app/dashboard/bookmarks/page.tsx
new file mode 100644
index 00000000..c9391d85
--- /dev/null
+++ b/apps/web/app/dashboard/bookmarks/page.tsx
@@ -0,0 +1,5 @@
+import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks";
+
+export default async function BookmarksPage() {
+ return <Bookmarks title="Bookmarks" archived={false} />;
+}
diff --git a/apps/web/app/dashboard/error.tsx b/apps/web/app/dashboard/error.tsx
new file mode 100644
index 00000000..556e59a3
--- /dev/null
+++ b/apps/web/app/dashboard/error.tsx
@@ -0,0 +1,9 @@
+"use client";
+
+export default function Error() {
+ return (
+ <div className="flex size-full">
+ <div className="m-auto text-3xl">Something went wrong</div>
+ </div>
+ );
+}
diff --git a/apps/web/app/dashboard/favourites/page.tsx b/apps/web/app/dashboard/favourites/page.tsx
new file mode 100644
index 00000000..de17461d
--- /dev/null
+++ b/apps/web/app/dashboard/favourites/page.tsx
@@ -0,0 +1,14 @@
+import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks";
+
+export default async function FavouritesBookmarkPage() {
+ return (
+ <div className="continer mt-4">
+ <Bookmarks
+ title="⭐️ Favourites"
+ archived={false}
+ favourited={true}
+ showDivider={true}
+ />
+ </div>
+ );
+}
diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx
new file mode 100644
index 00000000..31d592fb
--- /dev/null
+++ b/apps/web/app/dashboard/layout.tsx
@@ -0,0 +1,24 @@
+import { Separator } from "@/components/ui/separator";
+import MobileSidebar from "@/components/dashboard/sidebar/ModileSidebar";
+import Sidebar from "@/components/dashboard/sidebar/Sidebar";
+
+export default async function Dashboard({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+ <div className="flex min-h-screen w-screen flex-col sm:h-screen sm:flex-row">
+ <div className="hidden flex-none sm:flex">
+ <Sidebar />
+ </div>
+ <main className="flex-1 bg-gray-100 sm:overflow-y-auto">
+ <div className="block w-full sm:hidden">
+ <MobileSidebar />
+ <Separator />
+ </div>
+ {children}
+ </main>
+ </div>
+ );
+}
diff --git a/apps/web/app/dashboard/lists/[listId]/page.tsx b/apps/web/app/dashboard/lists/[listId]/page.tsx
new file mode 100644
index 00000000..006fd3ad
--- /dev/null
+++ b/apps/web/app/dashboard/lists/[listId]/page.tsx
@@ -0,0 +1,44 @@
+import { api } from "@/server/api/client";
+import { getServerAuthSession } from "@/server/auth";
+import { TRPCError } from "@trpc/server";
+import { notFound, redirect } from "next/navigation";
+import ListView from "@/components/dashboard/lists/ListView";
+import DeleteListButton from "@/components/dashboard/lists/DeleteListButton";
+
+export default async function ListPage({
+ params,
+}: {
+ params: { listId: string };
+}) {
+ const session = await getServerAuthSession();
+ if (!session) {
+ redirect("/");
+ }
+
+ let list;
+ try {
+ list = await api.lists.get({ listId: params.listId });
+ } catch (e) {
+ if (e instanceof TRPCError) {
+ if (e.code == "NOT_FOUND") {
+ notFound();
+ }
+ }
+ throw e;
+ }
+
+ const bookmarks = await api.bookmarks.getBookmarks({ ids: list.bookmarks });
+
+ return (
+ <div className="container flex flex-col gap-3">
+ <div className="flex justify-between">
+ <span className="pt-4 text-2xl">
+ {list.icon} {list.name}
+ </span>
+ <DeleteListButton list={list} />
+ </div>
+ <hr />
+ <ListView list={list} bookmarks={bookmarks.bookmarks} />
+ </div>
+ );
+}
diff --git a/apps/web/app/dashboard/lists/page.tsx b/apps/web/app/dashboard/lists/page.tsx
new file mode 100644
index 00000000..88eeda47
--- /dev/null
+++ b/apps/web/app/dashboard/lists/page.tsx
@@ -0,0 +1,14 @@
+import { api } from "@/server/api/client";
+import AllListsView from "@/components/dashboard/lists/AllListsView";
+
+export default async function ListsPage() {
+ const lists = await api.lists.list();
+
+ return (
+ <div className="container mt-4 flex flex-col gap-3">
+ <p className="text-2xl">📋 All Lists</p>
+ <hr />
+ <AllListsView initialData={lists.lists} />
+ </div>
+ );
+}
diff --git a/apps/web/app/dashboard/not-found.tsx b/apps/web/app/dashboard/not-found.tsx
new file mode 100644
index 00000000..64df220c
--- /dev/null
+++ b/apps/web/app/dashboard/not-found.tsx
@@ -0,0 +1,7 @@
+export default function NotFound() {
+ return (
+ <div className="flex size-full">
+ <div className="m-auto text-3xl">Not Found :(</div>
+ </div>
+ );
+}
diff --git a/apps/web/app/dashboard/preview/[bookmarkId]/page.tsx b/apps/web/app/dashboard/preview/[bookmarkId]/page.tsx
new file mode 100644
index 00000000..707d2b69
--- /dev/null
+++ b/apps/web/app/dashboard/preview/[bookmarkId]/page.tsx
@@ -0,0 +1,14 @@
+import { api } from "@/server/api/client";
+import BookmarkPreview from "@/components/dashboard/bookmarks/BookmarkPreview";
+
+export default async function BookmarkPreviewPage({
+ params,
+}: {
+ params: { bookmarkId: string };
+}) {
+ const bookmark = await api.bookmarks.getBookmark({
+ bookmarkId: params.bookmarkId,
+ });
+
+ return <BookmarkPreview initialData={bookmark} />;
+}
diff --git a/apps/web/app/dashboard/search/page.tsx b/apps/web/app/dashboard/search/page.tsx
new file mode 100644
index 00000000..602f6aa0
--- /dev/null
+++ b/apps/web/app/dashboard/search/page.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import BookmarksGrid from "@/components/dashboard/bookmarks/BookmarksGrid";
+import Loading from "../bookmarks/loading";
+import { Suspense, useRef } from "react";
+import { SearchInput } from "@/components/dashboard/search/SearchInput";
+import { useBookmarkSearch } from "@/lib/hooks/bookmark-search";
+
+function SearchComp() {
+ const { data, isPending, isPlaceholderData } = useBookmarkSearch();
+
+ const inputRef: React.MutableRefObject<HTMLInputElement | null> =
+ useRef<HTMLInputElement | null>(null);
+
+ return (
+ <div className="container flex flex-col gap-3 p-4">
+ <SearchInput
+ ref={inputRef}
+ autoFocus={true}
+ loading={isPending || isPlaceholderData}
+ />
+ <hr />
+ {data ? (
+ <BookmarksGrid
+ query={{ ids: data.bookmarks.map((b) => b.id) }}
+ bookmarks={data.bookmarks}
+ />
+ ) : (
+ <Loading />
+ )}
+ </div>
+ );
+}
+
+export default function SearchPage() {
+ return (
+ <Suspense>
+ <SearchComp />
+ </Suspense>
+ );
+}
diff --git a/apps/web/app/dashboard/settings/page.tsx b/apps/web/app/dashboard/settings/page.tsx
new file mode 100644
index 00000000..38091e6c
--- /dev/null
+++ b/apps/web/app/dashboard/settings/page.tsx
@@ -0,0 +1,9 @@
+import ApiKeySettings from "@/components/dashboard/settings/ApiKeySettings";
+export default async function Settings() {
+ return (
+ <div className="m-4 flex flex-col space-y-2 rounded-md border bg-white p-4">
+ <p className="text-2xl">Settings</p>
+ <ApiKeySettings />
+ </div>
+ );
+}
diff --git a/apps/web/app/dashboard/tags/[tagName]/page.tsx b/apps/web/app/dashboard/tags/[tagName]/page.tsx
new file mode 100644
index 00000000..c978b86a
--- /dev/null
+++ b/apps/web/app/dashboard/tags/[tagName]/page.tsx
@@ -0,0 +1,55 @@
+import { getServerAuthSession } from "@/server/auth";
+import { db } from "@hoarder/db";
+import { notFound, redirect } from "next/navigation";
+import BookmarksGrid from "@/components/dashboard/bookmarks/BookmarksGrid";
+import { api } from "@/server/api/client";
+import { bookmarkTags, tagsOnBookmarks } from "@hoarder/db/schema";
+import { and, eq } from "drizzle-orm";
+
+export default async function TagPage({
+ params,
+}: {
+ params: { tagName: string };
+}) {
+ const session = await getServerAuthSession();
+ if (!session) {
+ redirect("/");
+ }
+ const tagName = decodeURIComponent(params.tagName);
+ const tag = await db.query.bookmarkTags.findFirst({
+ where: and(
+ eq(bookmarkTags.userId, session.user.id),
+ eq(bookmarkTags.name, tagName),
+ ),
+ columns: {
+ id: true,
+ },
+ });
+
+ if (!tag) {
+ // TODO: Better error message when the tag is not there
+ notFound();
+ }
+
+ const bookmarkIds = await db.query.tagsOnBookmarks.findMany({
+ where: eq(tagsOnBookmarks.tagId, tag.id),
+ columns: {
+ bookmarkId: true,
+ },
+ });
+
+ const query = {
+ ids: bookmarkIds.map((b) => b.bookmarkId),
+ archived: false,
+ };
+
+ const bookmarks = await api.bookmarks.getBookmarks(query);
+
+ return (
+ <div className="container flex flex-col gap-3">
+ <span className="pt-4 text-2xl">{tagName}</span>
+ <hr />
+ <BookmarksGrid query={query} bookmarks={bookmarks.bookmarks} />
+ </div>
+ );
+}
diff --git a/apps/web/app/dashboard/tags/page.tsx b/apps/web/app/dashboard/tags/page.tsx
new file mode 100644
index 00000000..44c164e1
--- /dev/null
+++ b/apps/web/app/dashboard/tags/page.tsx
@@ -0,0 +1,56 @@
+import { Separator } from "@/components/ui/separator";
+import { getServerAuthSession } from "@/server/auth";
+import { db } from "@hoarder/db";
+import { bookmarkTags, tagsOnBookmarks } from "@hoarder/db/schema";
+import { count, eq } from "drizzle-orm";
+import Link from "next/link";
+import { redirect } from "next/navigation";
+
+function TagPill({ name, count }: { name: string; count: number }) {
+ return (
+ <Link
+ className="text-foreground hover:bg-foreground hover:text-background flex gap-2 rounded-md border border-gray-200 bg-white px-2 py-1"
+ href={`/dashboard/tags/${name}`}
+ >
+ {name} <Separator orientation="vertical" /> {count}
+ </Link>
+ );
+}
+
+export default async function TagsPage() {
+ const session = await getServerAuthSession();
+ if (!session) {
+ redirect("/");
+ }
+
+ let tags = await db
+ .select({
+ id: tagsOnBookmarks.tagId,
+ name: bookmarkTags.name,
+ count: count(),
+ })
+ .from(tagsOnBookmarks)
+ .where(eq(bookmarkTags.userId, session.user.id))
+ .groupBy(tagsOnBookmarks.tagId)
+ .innerJoin(bookmarkTags, eq(bookmarkTags.id, tagsOnBookmarks.tagId));
+
+ // Sort tags by usage desc
+ tags = tags.sort((a, b) => b.count - a.count);
+
+ let tagPill;
+ if (tags.length) {
+ tagPill = tags.map((t) => (
+ <TagPill key={t.id} name={t.name} count={t.count} />
+ ));
+ } else {
+ tagPill = "No Tags";
+ }
+
+ return (
+ <div className="container mt-2 space-y-3">
+ <span className="text-2xl">All Tags</span>
+ <hr />
+ <div className="flex flex-wrap gap-3">{tagPill}</div>
+ </div>
+ );
+}
diff --git a/apps/web/app/favicon.ico b/apps/web/app/favicon.ico
new file mode 100644
index 00000000..750e3c04
--- /dev/null
+++ b/apps/web/app/favicon.ico
Binary files differ
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
new file mode 100644
index 00000000..8abdb15c
--- /dev/null
+++ b/apps/web/app/globals.css
@@ -0,0 +1,76 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 222.2 84% 4.9%;
+
+ --card: 0 0% 100%;
+ --card-foreground: 222.2 84% 4.9%;
+
+ --popover: 0 0% 100%;
+ --popover-foreground: 222.2 84% 4.9%;
+
+ --primary: 222.2 47.4% 11.2%;
+ --primary-foreground: 210 40% 98%;
+
+ --secondary: 210 40% 96.1%;
+ --secondary-foreground: 222.2 47.4% 11.2%;
+
+ --muted: 210 40% 96.1%;
+ --muted-foreground: 215.4 16.3% 46.9%;
+
+ --accent: 210 40% 96.1%;
+ --accent-foreground: 222.2 47.4% 11.2%;
+
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 210 40% 98%;
+
+ --border: 214.3 31.8% 91.4%;
+ --input: 214.3 31.8% 91.4%;
+ --ring: 222.2 84% 4.9%;
+
+ --radius: 0.5rem;
+ }
+
+ .dark {
+ --background: 222.2 84% 4.9%;
+ --foreground: 210 40% 98%;
+
+ --card: 222.2 84% 4.9%;
+ --card-foreground: 210 40% 98%;
+
+ --popover: 222.2 84% 4.9%;
+ --popover-foreground: 210 40% 98%;
+
+ --primary: 210 40% 98%;
+ --primary-foreground: 222.2 47.4% 11.2%;
+
+ --secondary: 217.2 32.6% 17.5%;
+ --secondary-foreground: 210 40% 98%;
+
+ --muted: 217.2 32.6% 17.5%;
+ --muted-foreground: 215 20.2% 65.1%;
+
+ --accent: 217.2 32.6% 17.5%;
+ --accent-foreground: 210 40% 98%;
+
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 210 40% 98%;
+
+ --border: 217.2 32.6% 17.5%;
+ --input: 217.2 32.6% 17.5%;
+ --ring: 212.7 26.8% 83.9%;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
new file mode 100644
index 00000000..b1790a1f
--- /dev/null
+++ b/apps/web/app/layout.tsx
@@ -0,0 +1,51 @@
+import type { Metadata } from "next";
+import { Inter } from "next/font/google";
+import "./globals.css";
+import React from "react";
+import { Toaster } from "@/components/ui/toaster";
+import Providers from "@/lib/providers";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import { getServerAuthSession } from "@/server/auth";
+import type { Viewport } from "next";
+
+const inter = Inter({ subsets: ["latin"] });
+
+export const metadata: Metadata = {
+ title: "Hoarder",
+ applicationName: "Hoarder",
+ description: "Your AI powered second brain",
+ manifest: "/manifest.json",
+ appleWebApp: {
+ capable: true,
+ title: "Hoarder",
+ },
+ formatDetection: {
+ telephone: false,
+ },
+};
+
+export const viewport: Viewport = {
+ width: "device-width",
+ initialScale: 1,
+ maximumScale: 1,
+ userScalable: false,
+};
+
+export default async function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ const session = await getServerAuthSession();
+ return (
+ <html lang="en">
+ <body className={inter.className}>
+ <Providers session={session}>
+ {children}
+ <ReactQueryDevtools initialIsOpen={false} />
+ </Providers>
+ <Toaster />
+ </body>
+ </html>
+ );
+}
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
new file mode 100644
index 00000000..f467b64b
--- /dev/null
+++ b/apps/web/app/page.tsx
@@ -0,0 +1,12 @@
+import { getServerAuthSession } from "@/server/auth";
+import { redirect } from "next/navigation";
+
+export default async function Home() {
+ // TODO: Home currently just redirects between pages until we build a proper landing page
+ const session = await getServerAuthSession();
+ if (!session) {
+ redirect("/signin");
+ }
+
+ redirect("/dashboard/bookmarks");
+}
diff --git a/apps/web/app/signin/page.tsx b/apps/web/app/signin/page.tsx
new file mode 100644
index 00000000..fed71b62
--- /dev/null
+++ b/apps/web/app/signin/page.tsx
@@ -0,0 +1,25 @@
+import { PackageOpen } from "lucide-react";
+import SignInForm from "@/components/signin/SignInForm";
+import { redirect } from "next/dist/client/components/navigation";
+import { getServerAuthSession } from "@/server/auth";
+
+export default async function SignInPage() {
+ const session = await getServerAuthSession();
+ if (session) {
+ redirect("/");
+ }
+
+ return (
+ <div className="grid min-h-screen grid-rows-6 justify-center">
+ <div className="row-span-2 flex w-96 items-center justify-center space-x-2">
+ <span>
+ <PackageOpen size="60" className="" />
+ </span>
+ <p className="text-6xl">Hoarder</p>
+ </div>
+ <div className="row-span-4 px-3">
+ <SignInForm />
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/components.json b/apps/web/components.json
new file mode 100644
index 00000000..fa674c93
--- /dev/null
+++ b/apps/web/components.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.ts",
+ "css": "app/globals.css",
+ "baseColor": "slate",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils"
+ }
+}
diff --git a/apps/web/components/dashboard/bookmarks/AddLinkButton.tsx b/apps/web/components/dashboard/bookmarks/AddLinkButton.tsx
new file mode 100644
index 00000000..5973f909
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/AddLinkButton.tsx
@@ -0,0 +1,102 @@
+import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { useForm, SubmitErrorHandler } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { toast } from "@/components/ui/use-toast";
+import { api } from "@/lib/trpc";
+import { ActionButton } from "@/components/ui/action-button";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { useState } from "react";
+
+export function AddLinkButton({ children }: { children: React.ReactNode }) {
+ const [isOpen, setOpen] = useState(false);
+
+ const formSchema = z.object({
+ url: z.string().url({ message: "The link must be a valid URL" }),
+ });
+ const form = useForm<z.infer<typeof formSchema>>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ url: "",
+ },
+ });
+
+ const invalidateBookmarksCache = api.useUtils().bookmarks.invalidate;
+ const createBookmarkMutator = api.bookmarks.createBookmark.useMutation({
+ onSuccess: () => {
+ invalidateBookmarksCache();
+ form.reset();
+ setOpen(false);
+ },
+ onError: () => {
+ toast({ description: "Something went wrong", variant: "destructive" });
+ },
+ });
+
+ const onError: SubmitErrorHandler<z.infer<typeof formSchema>> = (errors) => {
+ toast({
+ description: Object.values(errors)
+ .map((v) => v.message)
+ .join("\n"),
+ variant: "destructive",
+ });
+ };
+
+ return (
+ <Dialog open={isOpen} onOpenChange={setOpen}>
+ <DialogTrigger asChild>{children}</DialogTrigger>
+ <DialogContent>
+ <Form {...form}>
+ <DialogHeader>
+ <DialogTitle>Add Link</DialogTitle>
+ </DialogHeader>
+ <form
+ className="flex flex-col gap-4"
+ onSubmit={form.handleSubmit(
+ (value) =>
+ createBookmarkMutator.mutate({ url: value.url, type: "link" }),
+ onError,
+ )}
+ >
+ <FormField
+ control={form.control}
+ name="url"
+ render={({ field }) => {
+ return (
+ <FormItem className="flex-1">
+ <FormControl>
+ <Input type="text" placeholder="Link" {...field} />
+ </FormControl>
+ </FormItem>
+ );
+ }}
+ />
+ <DialogFooter className="flex-shrink gap-1 sm:justify-end">
+ <DialogClose asChild>
+ <Button type="button" variant="secondary">
+ Close
+ </Button>
+ </DialogClose>
+ <ActionButton
+ type="submit"
+ loading={createBookmarkMutator.isPending}
+ >
+ Add
+ </ActionButton>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/components/dashboard/bookmarks/AddToListModal.tsx b/apps/web/components/dashboard/bookmarks/AddToListModal.tsx
new file mode 100644
index 00000000..c9fd5da0
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/AddToListModal.tsx
@@ -0,0 +1,168 @@
+import { ActionButton } from "@/components/ui/action-button";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage,
+} from "@/components/ui/form";
+
+import { toast } from "@/components/ui/use-toast";
+import { api } from "@/lib/trpc";
+import { useState } from "react";
+
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import LoadingSpinner from "@/components/ui/spinner";
+import { z } from "zod";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+
+export default function AddToListModal({
+ bookmarkId,
+ open,
+ setOpen,
+}: {
+ bookmarkId: string;
+ open: boolean;
+ setOpen: (open: boolean) => void;
+}) {
+ const formSchema = z.object({
+ listId: z.string({
+ required_error: "Please select a list",
+ }),
+ });
+ const form = useForm<z.infer<typeof formSchema>>({
+ resolver: zodResolver(formSchema),
+ });
+
+ const { data: lists, isPending: isFetchingListsPending } =
+ api.lists.list.useQuery();
+
+ const listInvalidationFunction = api.useUtils().lists.get.invalidate;
+ const bookmarksInvalidationFunction =
+ api.useUtils().bookmarks.getBookmarks.invalidate;
+
+ const { mutate: addToList, isPending: isAddingToListPending } =
+ api.lists.addToList.useMutation({
+ onSuccess: (_resp, req) => {
+ toast({
+ description: "List has been updated!",
+ });
+ listInvalidationFunction({ listId: req.listId });
+ bookmarksInvalidationFunction();
+ },
+ onError: (e) => {
+ if (e.data?.code == "BAD_REQUEST") {
+ toast({
+ variant: "destructive",
+ description: e.message,
+ });
+ } else {
+ toast({
+ variant: "destructive",
+ title: "Something went wrong",
+ });
+ }
+ },
+ });
+
+ const isPending = isFetchingListsPending || isAddingToListPending;
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogContent>
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit((value) => {
+ addToList({
+ bookmarkId: bookmarkId,
+ listId: value.listId,
+ });
+ })}
+ >
+ <DialogHeader>
+ <DialogTitle>Add to List</DialogTitle>
+ </DialogHeader>
+
+ <div className="py-4">
+ {lists ? (
+ <FormField
+ control={form.control}
+ name="listId"
+ render={({ field }) => {
+ return (
+ <FormItem>
+ <FormControl>
+ <Select onValueChange={field.onChange}>
+ <SelectTrigger className="w-full">
+ <SelectValue placeholder="Select a list" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {lists &&
+ lists.lists.map((l) => (
+ <SelectItem key={l.id} value={l.id}>
+ {l.icon} {l.name}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ ) : (
+ <LoadingSpinner />
+ )}
+ </div>
+ <DialogFooter className="sm:justify-end">
+ <DialogClose asChild>
+ <Button type="button" variant="secondary">
+ Close
+ </Button>
+ </DialogClose>
+ <ActionButton
+ type="submit"
+ loading={isAddingToListPending}
+ disabled={isPending}
+ >
+ Add
+ </ActionButton>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+export function useAddToListModal(bookmarkId: string) {
+ const [open, setOpen] = useState(false);
+
+ return {
+ open,
+ setOpen,
+ content: (
+ <AddToListModal bookmarkId={bookmarkId} open={open} setOpen={setOpen} />
+ ),
+ };
+}
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkCardSkeleton.tsx b/apps/web/components/dashboard/bookmarks/BookmarkCardSkeleton.tsx
new file mode 100644
index 00000000..1f5fa433
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/BookmarkCardSkeleton.tsx
@@ -0,0 +1,30 @@
+import {
+ ImageCard,
+ ImageCardBody,
+ ImageCardContent,
+ ImageCardFooter,
+ ImageCardTitle,
+ ImageCardBanner,
+} from "@/components/ui/imageCard";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export default function BookmarkCardSkeleton() {
+ return (
+ <ImageCard
+ className={
+ "border-grey-100 border bg-gray-50 duration-300 ease-in hover:border-blue-300 hover:transition-all"
+ }
+ >
+ <ImageCardBanner src="/blur.avif" />
+ <ImageCardContent>
+ <ImageCardTitle></ImageCardTitle>
+ <ImageCardBody className="space-y-2">
+ <Skeleton className="h-4 w-full" />
+ <Skeleton className="h-4 w-full" />
+ <Skeleton className="h-4 w-full" />
+ </ImageCardBody>
+ <ImageCardFooter></ImageCardFooter>
+ </ImageCardContent>
+ </ImageCard>
+ );
+}
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
new file mode 100644
index 00000000..4f08ebee
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
@@ -0,0 +1,185 @@
+"use client";
+
+import { useToast } from "@/components/ui/use-toast";
+import { api } from "@/lib/trpc";
+import { ZBookmark, ZBookmarkedLink } from "@hoarder/trpc/types/bookmarks";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Archive,
+ Link,
+ List,
+ MoreHorizontal,
+ Pencil,
+ RotateCw,
+ Star,
+ Tags,
+ Trash2,
+} from "lucide-react";
+import { useTagModel } from "./TagModal";
+import { useState } from "react";
+import { BookmarkedTextEditor } from "./BookmarkedTextEditor";
+import { useAddToListModal } from "./AddToListModal";
+
+export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
+ const { toast } = useToast();
+ const linkId = bookmark.id;
+
+ const { setOpen: setTagModalIsOpen, content: tagModal } =
+ useTagModel(bookmark);
+ const { setOpen: setAddToListModalOpen, content: addToListModal } =
+ useAddToListModal(bookmark.id);
+
+ const [isTextEditorOpen, setTextEditorOpen] = useState(false);
+
+ const invalidateAllBookmarksCache =
+ api.useUtils().bookmarks.getBookmarks.invalidate;
+
+ const invalidateBookmarkCache =
+ api.useUtils().bookmarks.getBookmark.invalidate;
+
+ const onError = () => {
+ toast({
+ variant: "destructive",
+ title: "Something went wrong",
+ description: "There was a problem with your request.",
+ });
+ };
+ const deleteBookmarkMutator = api.bookmarks.deleteBookmark.useMutation({
+ onSuccess: () => {
+ toast({
+ description: "The bookmark has been deleted!",
+ });
+ },
+ onError,
+ onSettled: () => {
+ invalidateAllBookmarksCache();
+ },
+ });
+
+ const updateBookmarkMutator = api.bookmarks.updateBookmark.useMutation({
+ onSuccess: () => {
+ toast({
+ description: "The bookmark has been updated!",
+ });
+ },
+ onError,
+ onSettled: () => {
+ invalidateBookmarkCache({ bookmarkId: bookmark.id });
+ invalidateAllBookmarksCache();
+ },
+ });
+
+ const crawlBookmarkMutator = api.bookmarks.recrawlBookmark.useMutation({
+ onSuccess: () => {
+ toast({
+ description: "Re-fetch has been enqueued!",
+ });
+ },
+ onError,
+ onSettled: () => {
+ invalidateBookmarkCache({ bookmarkId: bookmark.id });
+ },
+ });
+
+ return (
+ <>
+ {tagModal}
+ {addToListModal}
+ <BookmarkedTextEditor
+ bookmark={bookmark}
+ open={isTextEditorOpen}
+ setOpen={setTextEditorOpen}
+ />
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="ghost"
+ className="px-1 focus-visible:ring-0 focus-visible:ring-offset-0"
+ >
+ <MoreHorizontal />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent className="w-fit">
+ {bookmark.content.type === "text" && (
+ <DropdownMenuItem onClick={() => setTextEditorOpen(true)}>
+ <Pencil className="mr-2 size-4" />
+ <span>Edit</span>
+ </DropdownMenuItem>
+ )}
+ <DropdownMenuItem
+ onClick={() =>
+ updateBookmarkMutator.mutate({
+ bookmarkId: linkId,
+ favourited: !bookmark.favourited,
+ })
+ }
+ >
+ <Star className="mr-2 size-4" />
+ <span>{bookmark.favourited ? "Un-favourite" : "Favourite"}</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() =>
+ updateBookmarkMutator.mutate({
+ bookmarkId: linkId,
+ archived: !bookmark.archived,
+ })
+ }
+ >
+ <Archive className="mr-2 size-4" />
+ <span>{bookmark.archived ? "Un-archive" : "Archive"}</span>
+ </DropdownMenuItem>
+ {bookmark.content.type === "link" && (
+ <DropdownMenuItem
+ onClick={() => {
+ navigator.clipboard.writeText(
+ (bookmark.content as ZBookmarkedLink).url,
+ );
+ toast({
+ description: "Link was added to your clipboard!",
+ });
+ }}
+ >
+ <Link className="mr-2 size-4" />
+ <span>Copy Link</span>
+ </DropdownMenuItem>
+ )}
+ <DropdownMenuItem onClick={() => setTagModalIsOpen(true)}>
+ <Tags className="mr-2 size-4" />
+ <span>Edit Tags</span>
+ </DropdownMenuItem>
+
+ <DropdownMenuItem onClick={() => setAddToListModalOpen(true)}>
+ <List className="mr-2 size-4" />
+ <span>Add to List</span>
+ </DropdownMenuItem>
+
+ {bookmark.content.type === "link" && (
+ <DropdownMenuItem
+ onClick={() =>
+ crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id })
+ }
+ >
+ <RotateCw className="mr-2 size-4" />
+ <span>Refresh</span>
+ </DropdownMenuItem>
+ )}
+ <DropdownMenuItem
+ className="text-destructive"
+ onClick={() =>
+ deleteBookmarkMutator.mutate({ bookmarkId: bookmark.id })
+ }
+ >
+ <Trash2 className="mr-2 size-4" />
+ <span>Delete</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </>
+ );
+}
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx b/apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx
new file mode 100644
index 00000000..2a8ae1b1
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { BackButton } from "@/components/ui/back-button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { isBookmarkStillCrawling } from "@/lib/bookmarkUtils";
+import { api } from "@/lib/trpc";
+import { ZBookmark } from "@hoarder/trpc/types/bookmarks";
+import { ArrowLeftCircle, CalendarDays, ExternalLink } from "lucide-react";
+import Link from "next/link";
+import Markdown from "react-markdown";
+
+export default function BookmarkPreview({
+ initialData,
+}: {
+ initialData: ZBookmark;
+}) {
+ const { data: bookmark } = api.bookmarks.getBookmark.useQuery(
+ {
+ bookmarkId: initialData.id,
+ },
+ {
+ initialData,
+ refetchInterval: (query) => {
+ const data = query.state.data;
+ if (!data) {
+ return false;
+ }
+ // If the link is not crawled or not tagged
+ if (isBookmarkStillCrawling(data)) {
+ return 1000;
+ }
+ return false;
+ },
+ },
+ );
+
+ const linkHeader = bookmark.content.type == "link" && (
+ <div className="flex flex-col space-y-2">
+ <p className="text-center text-3xl">
+ {bookmark.content.title || bookmark.content.url}
+ </p>
+ <Link href={bookmark.content.url} className="mx-auto flex gap-2">
+ <span className="my-auto">View Original</span>
+ <ExternalLink />
+ </Link>
+ </div>
+ );
+
+ let content;
+ switch (bookmark.content.type) {
+ case "link": {
+ if (!bookmark.content.htmlContent) {
+ content = (
+ <div className="text-red-500">Failed to fetch link content ...</div>
+ );
+ } else {
+ content = (
+ <div
+ dangerouslySetInnerHTML={{
+ __html: bookmark.content.htmlContent || "",
+ }}
+ className="prose"
+ />
+ );
+ }
+ break;
+ }
+ case "text": {
+ content = <Markdown className="prose">{bookmark.content.text}</Markdown>;
+ break;
+ }
+ }
+
+ return (
+ <div className="bg-background m-4 min-h-screen space-y-4 rounded-md border p-4">
+ <div className="flex justify-between">
+ <BackButton className="ghost" variant="ghost">
+ <ArrowLeftCircle />
+ </BackButton>
+ <div className="my-auto">
+ <span className="my-auto flex gap-2">
+ <CalendarDays /> {bookmark.createdAt.toLocaleString()}
+ </span>
+ </div>
+ </div>
+ <hr />
+ {linkHeader}
+ <div className="mx-auto flex h-full border-x p-2 px-4 lg:w-2/3">
+ {isBookmarkStillCrawling(bookmark) ? (
+ <div className="flex w-full flex-col gap-2">
+ <Skeleton className="h-4" />
+ <Skeleton className="h-4" />
+ <Skeleton className="h-4" />
+ </div>
+ ) : (
+ content
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx b/apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx
new file mode 100644
index 00000000..a5b58f1a
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx
@@ -0,0 +1,109 @@
+import { ZBookmark } from "@hoarder/trpc/types/bookmarks";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { ActionButton } from "@/components/ui/action-button";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { api } from "@/lib/trpc";
+import { useState } from "react";
+import { toast } from "@/components/ui/use-toast";
+
+export function BookmarkedTextEditor({
+ bookmark,
+ open,
+ setOpen,
+}: {
+ bookmark?: ZBookmark;
+ open: boolean;
+ setOpen: (open: boolean) => void;
+}) {
+ const isNewBookmark = bookmark === undefined;
+ const [noteText, setNoteText] = useState(
+ bookmark && bookmark.content.type == "text" ? bookmark.content.text : "",
+ );
+
+ const invalidateAllBookmarksCache =
+ api.useUtils().bookmarks.getBookmarks.invalidate;
+ const invalidateOneBookmarksCache =
+ api.useUtils().bookmarks.getBookmark.invalidate;
+
+ const { mutate: createBookmarkMutator, isPending: isCreationPending } =
+ api.bookmarks.createBookmark.useMutation({
+ onSuccess: () => {
+ invalidateAllBookmarksCache();
+ toast({
+ description: "Note created!",
+ });
+ setOpen(false);
+ setNoteText("");
+ },
+ onError: () => {
+ toast({ description: "Something went wrong", variant: "destructive" });
+ },
+ });
+ const { mutate: updateBookmarkMutator, isPending: isUpdatePending } =
+ api.bookmarks.updateBookmarkText.useMutation({
+ onSuccess: () => {
+ invalidateOneBookmarksCache({
+ bookmarkId: bookmark!.id,
+ });
+ toast({
+ description: "Note updated!",
+ });
+ setOpen(false);
+ },
+ onError: () => {
+ toast({ description: "Something went wrong", variant: "destructive" });
+ },
+ });
+ const isPending = isCreationPending || isUpdatePending;
+
+ const onSave = () => {
+ if (isNewBookmark) {
+ createBookmarkMutator({
+ type: "text",
+ text: noteText,
+ });
+ } else {
+ updateBookmarkMutator({
+ bookmarkId: bookmark.id,
+ text: noteText,
+ });
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>{isNewBookmark ? "New Note" : "Edit Note"}</DialogTitle>
+ <DialogDescription>
+ Write your note with markdown support
+ </DialogDescription>
+ </DialogHeader>
+ <Textarea
+ value={noteText}
+ onChange={(e) => setNoteText(e.target.value)}
+ className="h-52 grow"
+ />
+ <DialogFooter className="flex-shrink gap-1 sm:justify-end">
+ <DialogClose asChild>
+ <Button type="button" variant="secondary">
+ Close
+ </Button>
+ </DialogClose>
+ <ActionButton type="button" loading={isPending} onClick={onSave}>
+ Save
+ </ActionButton>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkedTextViewer.tsx b/apps/web/components/dashboard/bookmarks/BookmarkedTextViewer.tsx
new file mode 100644
index 00000000..8a620341
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/BookmarkedTextViewer.tsx
@@ -0,0 +1,20 @@
+import { Dialog, DialogContent } from "@/components/ui/dialog";
+import Markdown from "react-markdown";
+
+export function BookmarkedTextViewer({
+ content,
+ open,
+ setOpen,
+}: {
+ content: string;
+ open: boolean;
+ setOpen: (open: boolean) => void;
+}) {
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogContent className="max-h-[75%] overflow-auto">
+ <Markdown className="prose">{content}</Markdown>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/components/dashboard/bookmarks/Bookmarks.tsx b/apps/web/components/dashboard/bookmarks/Bookmarks.tsx
new file mode 100644
index 00000000..1ad3670c
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/Bookmarks.tsx
@@ -0,0 +1,32 @@
+import { redirect } from "next/navigation";
+import BookmarksGrid from "./BookmarksGrid";
+import { ZGetBookmarksRequest } from "@hoarder/trpc/types/bookmarks";
+import { api } from "@/server/api/client";
+import { getServerAuthSession } from "@/server/auth";
+
+export default async function Bookmarks({
+ favourited,
+ archived,
+ title,
+ showDivider,
+}: ZGetBookmarksRequest & { title: string; showDivider?: boolean }) {
+ const session = await getServerAuthSession();
+ if (!session) {
+ redirect("/");
+ }
+
+ const query = {
+ favourited,
+ archived,
+ };
+
+ const bookmarks = await api.bookmarks.getBookmarks(query);
+
+ return (
+ <div className="container flex flex-col gap-3">
+ <div className="text-2xl">{title}</div>
+ {showDivider && <hr />}
+ <BookmarksGrid query={query} bookmarks={bookmarks.bookmarks} />
+ </div>
+ );
+}
diff --git a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx
new file mode 100644
index 00000000..4d5b6b0a
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import LinkCard from "./LinkCard";
+import { ZBookmark, ZGetBookmarksRequest } from "@hoarder/trpc/types/bookmarks";
+import { api } from "@/lib/trpc";
+import TextCard from "./TextCard";
+import { Slot } from "@radix-ui/react-slot";
+import Masonry from "react-masonry-css";
+import resolveConfig from "tailwindcss/resolveConfig";
+import tailwindConfig from "@/tailwind.config";
+import { useMemo } from "react";
+
+function getBreakpointConfig() {
+ const fullConfig = resolveConfig(tailwindConfig);
+
+ const breakpointColumnsObj: { [key: number]: number; default: number } = {
+ default: 3,
+ };
+ breakpointColumnsObj[parseInt(fullConfig.theme.screens.lg)] = 2;
+ breakpointColumnsObj[parseInt(fullConfig.theme.screens.md)] = 1;
+ breakpointColumnsObj[parseInt(fullConfig.theme.screens.sm)] = 1;
+ return breakpointColumnsObj;
+}
+
+function renderBookmark(bookmark: ZBookmark) {
+ let comp;
+ switch (bookmark.content.type) {
+ case "link":
+ comp = <LinkCard bookmark={bookmark} />;
+ break;
+ case "text":
+ comp = <TextCard bookmark={bookmark} />;
+ break;
+ }
+ return (
+ <Slot
+ key={bookmark.id}
+ className="border-grey-100 mb-4 border bg-gray-50 duration-300 ease-in hover:border-blue-300 hover:transition-all"
+ >
+ {comp}
+ </Slot>
+ );
+}
+
+export default function BookmarksGrid({
+ query,
+ bookmarks: initialBookmarks,
+}: {
+ query: ZGetBookmarksRequest;
+ bookmarks: ZBookmark[];
+}) {
+ const { data } = api.bookmarks.getBookmarks.useQuery(query, {
+ initialData: { bookmarks: initialBookmarks },
+ });
+ const breakpointConfig = useMemo(() => getBreakpointConfig(), []);
+ if (data.bookmarks.length == 0) {
+ return <p>No bookmarks</p>;
+ }
+ return (
+ <Masonry className="flex gap-4" breakpointCols={breakpointConfig}>
+ {data.bookmarks.map((b) => renderBookmark(b))}
+ </Masonry>
+ );
+}
diff --git a/apps/web/components/dashboard/bookmarks/LinkCard.tsx b/apps/web/components/dashboard/bookmarks/LinkCard.tsx
new file mode 100644
index 00000000..50f30e47
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/LinkCard.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import {
+ ImageCard,
+ ImageCardBanner,
+ ImageCardBody,
+ ImageCardContent,
+ ImageCardFooter,
+ ImageCardTitle,
+} from "@/components/ui/imageCard";
+import { ZBookmark } from "@hoarder/trpc/types/bookmarks";
+import Link from "next/link";
+import BookmarkOptions from "./BookmarkOptions";
+import { api } from "@/lib/trpc";
+import { Maximize2, Star } from "lucide-react";
+import TagList from "./TagList";
+import {
+ isBookmarkStillCrawling,
+ isBookmarkStillLoading,
+ isBookmarkStillTagging,
+} from "@/lib/bookmarkUtils";
+
+export default function LinkCard({
+ 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 the link is not crawled or not tagged
+ if (isBookmarkStillLoading(data)) {
+ return 1000;
+ }
+ return false;
+ },
+ },
+ );
+ const link = bookmark.content;
+ if (link.type != "link") {
+ throw new Error("Unexpected bookmark type");
+ }
+ const parsedUrl = new URL(link.url);
+
+ // A dummy white pixel for when there's no image.
+ // TODO: Better handling for cards with no images
+ const image =
+ link.imageUrl ??
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdj+P///38ACfsD/QVDRcoAAAAASUVORK5CYII=";
+
+ return (
+ <ImageCard className={className}>
+ <Link href={link.url}>
+ <ImageCardBanner
+ src={isBookmarkStillCrawling(bookmark) ? "/blur.avif" : image}
+ />
+ </Link>
+ <ImageCardContent>
+ <ImageCardTitle>
+ <Link className="line-clamp-2" href={link.url} target="_blank">
+ {link?.title ?? parsedUrl.host}
+ </Link>
+ </ImageCardTitle>
+ {/* There's a hack here. Every tag has the full hight of the container itself. That why, when we enable flex-wrap,
+ the overflowed don't show up. */}
+ <ImageCardBody className="flex h-full flex-wrap space-x-1 overflow-hidden">
+ <TagList
+ bookmark={bookmark}
+ loading={isBookmarkStillTagging(bookmark)}
+ />
+ </ImageCardBody>
+ <ImageCardFooter>
+ <div className="mt-1 flex justify-between text-gray-500">
+ <div className="my-auto">
+ <Link
+ className="line-clamp-1 hover:text-black"
+ href={link.url}
+ target="_blank"
+ >
+ {parsedUrl.host}
+ </Link>
+ </div>
+ <div className="flex">
+ {bookmark.favourited && (
+ <Star
+ className="m-1 size-8 rounded p-1"
+ color="#ebb434"
+ fill="#ebb434"
+ />
+ )}
+ <Link
+ className="my-auto block px-2"
+ href={`/dashboard/preview/${bookmark.id}`}
+ >
+ <Maximize2 size="20" />
+ </Link>
+ <BookmarkOptions bookmark={bookmark} />
+ </div>
+ </div>
+ </ImageCardFooter>
+ </ImageCardContent>
+ </ImageCard>
+ );
+}
diff --git a/apps/web/components/dashboard/bookmarks/TagList.tsx b/apps/web/components/dashboard/bookmarks/TagList.tsx
new file mode 100644
index 00000000..6c9d2d22
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/TagList.tsx
@@ -0,0 +1,39 @@
+import { badgeVariants } from "@/components/ui/badge";
+import Link from "next/link";
+import { Skeleton } from "@/components/ui/skeleton";
+import { ZBookmark } from "@hoarder/trpc/types/bookmarks";
+import { cn } from "@/lib/utils";
+
+export default function TagList({
+ bookmark,
+ loading,
+}: {
+ bookmark: ZBookmark;
+ loading?: boolean;
+}) {
+ if (loading) {
+ return (
+ <div className="flex w-full flex-col justify-end space-y-2 p-2">
+ <Skeleton className="h-4 w-full" />
+ <Skeleton className="h-4 w-full" />
+ </div>
+ );
+ }
+ return (
+ <>
+ {bookmark.tags.map((t) => (
+ <div key={t.id} className="flex h-full flex-col justify-end">
+ <Link
+ className={cn(
+ badgeVariants({ variant: "outline" }),
+ "hover:bg-foreground hover:text-secondary text-nowrap",
+ )}
+ href={`/dashboard/tags/${t.name}`}
+ >
+ {t.name}
+ </Link>
+ </div>
+ ))}
+ </>
+ );
+}
diff --git a/apps/web/components/dashboard/bookmarks/TagModal.tsx b/apps/web/components/dashboard/bookmarks/TagModal.tsx
new file mode 100644
index 00000000..8c09d00e
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/TagModal.tsx
@@ -0,0 +1,207 @@
+import { ActionButton } from "@/components/ui/action-button";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { toast } from "@/components/ui/use-toast";
+import { api } from "@/lib/trpc";
+import { ZBookmark } from "@hoarder/trpc/types/bookmarks";
+import { ZAttachedByEnum } from "@hoarder/trpc/types/tags";
+import { cn } from "@/lib/utils";
+import { Sparkles, X } from "lucide-react";
+import { useState, KeyboardEvent, useEffect } from "react";
+
+type EditableTag = { attachedBy: ZAttachedByEnum; id?: string; name: string };
+
+function TagAddInput({ addTag }: { addTag: (tag: string) => void }) {
+ const onKeyUp = (e: KeyboardEvent<HTMLInputElement>) => {
+ if (e.key === "Enter") {
+ addTag(e.currentTarget.value);
+ e.currentTarget.value = "";
+ }
+ };
+ return (
+ <Input
+ onKeyUp={onKeyUp}
+ className="h-8 w-full border-none focus-visible:ring-0 focus-visible:ring-offset-0"
+ />
+ );
+}
+
+function TagPill({
+ tag,
+ deleteCB,
+}: {
+ tag: { attachedBy: ZAttachedByEnum; id?: string; name: string };
+ deleteCB: () => void;
+}) {
+ const isAttachedByAI = tag.attachedBy == "ai";
+ return (
+ <div
+ className={cn(
+ "flex min-h-8 space-x-1 rounded px-2",
+ isAttachedByAI
+ ? "bg-gradient-to-tr from-purple-500 to-purple-400 text-white"
+ : "bg-gray-200",
+ )}
+ >
+ {isAttachedByAI && <Sparkles className="m-auto size-4" />}
+ <p className="m-auto">{tag.name}</p>
+ <button className="m-auto size-4" onClick={deleteCB}>
+ <X className="size-4" />
+ </button>
+ </div>
+ );
+}
+
+function TagEditor({
+ tags,
+ setTags,
+}: {
+ tags: Map<string, EditableTag>;
+ setTags: (
+ cb: (m: Map<string, EditableTag>) => Map<string, EditableTag>,
+ ) => void;
+}) {
+ return (
+ <div className="mt-4 flex flex-wrap gap-2 rounded border p-2">
+ {[...tags.values()].map((t) => (
+ <TagPill
+ key={t.name}
+ tag={t}
+ deleteCB={() =>
+ setTags((m) => {
+ const newMap = new Map(m);
+ newMap.delete(t.name);
+ return newMap;
+ })
+ }
+ />
+ ))}
+ <div className="flex-1">
+ <TagAddInput
+ addTag={(val) => {
+ setTags((m) => {
+ if (m.has(val)) {
+ // Tag already exists
+ // Do nothing
+ return m;
+ }
+ const newMap = new Map(m);
+ newMap.set(val, { attachedBy: "human", name: val });
+ return newMap;
+ });
+ }}
+ />
+ </div>
+ </div>
+ );
+}
+
+export default function TagModal({
+ bookmark,
+ open,
+ setOpen,
+}: {
+ bookmark: ZBookmark;
+ open: boolean;
+ setOpen: (open: boolean) => void;
+}) {
+ const [tags, setTags] = useState<Map<string, EditableTag>>(new Map());
+ useEffect(() => {
+ const m = new Map<string, EditableTag>();
+ for (const t of bookmark.tags) {
+ m.set(t.name, { attachedBy: t.attachedBy, id: t.id, name: t.name });
+ }
+ setTags(m);
+ }, [bookmark.tags]);
+
+ const bookmarkInvalidationFunction =
+ api.useUtils().bookmarks.getBookmark.invalidate;
+
+ const { mutate, isPending } = api.bookmarks.updateTags.useMutation({
+ onSuccess: () => {
+ toast({
+ description: "Tags has been updated!",
+ });
+ bookmarkInvalidationFunction({ bookmarkId: bookmark.id });
+ },
+ onError: () => {
+ toast({
+ variant: "destructive",
+ title: "Something went wrong",
+ description: "There was a problem with your request.",
+ });
+ },
+ });
+
+ const onSaveButton = () => {
+ const exitingTags = new Set(bookmark.tags.map((t) => t.name));
+
+ const attach = [];
+ const detach = [];
+ for (const t of tags.values()) {
+ if (!exitingTags.has(t.name)) {
+ attach.push({ tag: t.name });
+ }
+ }
+ for (const t of bookmark.tags) {
+ if (!tags.has(t.name)) {
+ detach.push({ tagId: t.id });
+ }
+ }
+ mutate({
+ bookmarkId: bookmark.id,
+ attach,
+ detach,
+ });
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Edit Tags</DialogTitle>
+ </DialogHeader>
+ <TagEditor tags={tags} setTags={setTags} />
+ <DialogFooter className="sm:justify-end">
+ <DialogClose asChild>
+ <Button type="button" variant="secondary">
+ Close
+ </Button>
+ </DialogClose>
+ <ActionButton
+ type="button"
+ loading={isPending}
+ onClick={onSaveButton}
+ >
+ Save
+ </ActionButton>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+export function useTagModel(bookmark: ZBookmark) {
+ const [open, setOpen] = useState(false);
+
+ return {
+ open,
+ setOpen,
+ content: (
+ <TagModal
+ key={bookmark.id}
+ bookmark={bookmark}
+ open={open}
+ setOpen={setOpen}
+ />
+ ),
+ };
+}
diff --git a/apps/web/components/dashboard/bookmarks/TextCard.tsx b/apps/web/components/dashboard/bookmarks/TextCard.tsx
new file mode 100644
index 00000000..2565e69d
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/TextCard.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import { ZBookmark } from "@hoarder/trpc/types/bookmarks";
+import BookmarkOptions from "./BookmarkOptions";
+import { api } from "@/lib/trpc";
+import { Maximize2, Star } from "lucide-react";
+import { cn } from "@/lib/utils";
+import TagList from "./TagList";
+import Markdown from "react-markdown";
+import { useState } from "react";
+import { BookmarkedTextViewer } from "./BookmarkedTextViewer";
+import Link from "next/link";
+import { isBookmarkStillTagging } from "@/lib/bookmarkUtils";
+
+export default function TextCard({
+ 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 [previewModalOpen, setPreviewModalOpen] = useState(false);
+ const bookmarkedText = bookmark.content;
+ if (bookmarkedText.type != "text") {
+ throw new Error("Unexpected bookmark type");
+ }
+
+ return (
+ <>
+ <BookmarkedTextViewer
+ content={bookmarkedText.text}
+ open={previewModalOpen}
+ setOpen={setPreviewModalOpen}
+ />
+ <div
+ className={cn(
+ className,
+ cn(
+ "flex h-min max-h-96 flex-col gap-y-1 overflow-hidden rounded-lg p-2 shadow-md",
+ ),
+ )}
+ >
+ <Markdown className="prose grow overflow-hidden">
+ {bookmarkedText.text}
+ </Markdown>
+ <div className="mt-4 flex flex-none flex-wrap gap-1 overflow-hidden">
+ <TagList
+ bookmark={bookmark}
+ loading={isBookmarkStillTagging(bookmark)}
+ />
+ </div>
+ <div className="flex w-full justify-between">
+ <div />
+ <div className="flex gap-0 text-gray-500">
+ <div>
+ {bookmark.favourited && (
+ <Star
+ className="my-1 size-8 rounded p-1"
+ color="#ebb434"
+ fill="#ebb434"
+ />
+ )}
+ </div>
+ <Link
+ className="my-auto block px-2"
+ href={`/dashboard/preview/${bookmark.id}`}
+ >
+ <Maximize2 size="20" />
+ </Link>
+ <BookmarkOptions bookmark={bookmark} />
+ </div>
+ </div>
+ </div>
+ </>
+ );
+}
diff --git a/apps/web/components/dashboard/bookmarks/TopNav.tsx b/apps/web/components/dashboard/bookmarks/TopNav.tsx
new file mode 100644
index 00000000..6c0f18e5
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/TopNav.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { Link, NotebookPen } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { BookmarkedTextEditor } from "./BookmarkedTextEditor";
+import { useState } from "react";
+import { AddLinkButton } from "./AddLinkButton";
+import { SearchInput } from "../search/SearchInput";
+
+function AddText() {
+ const [isEditorOpen, setEditorOpen] = useState(false);
+
+ return (
+ <div className="flex">
+ <BookmarkedTextEditor open={isEditorOpen} setOpen={setEditorOpen} />
+ <Button className="m-auto" onClick={() => setEditorOpen(true)}>
+ <NotebookPen />
+ </Button>
+ </div>
+ );
+}
+
+function AddLink() {
+ return (
+ <div className="flex">
+ <AddLinkButton>
+ <Button className="m-auto">
+ <Link />
+ </Button>
+ </AddLinkButton>
+ </div>
+ );
+}
+
+export default function TopNav() {
+ return (
+ <div className="container flex gap-2 py-4">
+ <SearchInput />
+ <AddLink />
+ <AddText />
+ </div>
+ );
+}
diff --git a/apps/web/components/dashboard/lists/AllListsView.tsx b/apps/web/components/dashboard/lists/AllListsView.tsx
new file mode 100644
index 00000000..81f31cde
--- /dev/null
+++ b/apps/web/components/dashboard/lists/AllListsView.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { api } from "@/lib/trpc";
+import { ZBookmarkList } from "@hoarder/trpc/types/lists";
+import { keepPreviousData } from "@tanstack/react-query";
+import { Plus } from "lucide-react";
+import Link from "next/link";
+import { useNewListModal } from "@/components/dashboard/sidebar/NewListModal";
+
+function ListItem({
+ name,
+ icon,
+ path,
+}: {
+ name: string;
+ icon: string;
+ path: string;
+}) {
+ return (
+ <Link href={path}>
+ <div className="bg-background rounded-md border border-gray-200 px-4 py-2 text-lg">
+ <p className="text-nowrap">
+ {icon} {name}
+ </p>
+ </div>
+ </Link>
+ );
+}
+
+export default function AllListsView({
+ initialData,
+}: {
+ initialData: ZBookmarkList[];
+}) {
+ const { setOpen: setIsNewListModalOpen } = useNewListModal();
+ let { data: lists } = api.lists.list.useQuery(undefined, {
+ initialData: { lists: initialData },
+ placeholderData: keepPreviousData,
+ });
+
+ // TODO: This seems to be a bug in react query
+ lists ||= { lists: initialData };
+
+ return (
+ <div className="flex flex-col flex-wrap gap-2 md:flex-row">
+ <Button
+ className="my-auto flex h-full"
+ onClick={() => setIsNewListModalOpen(true)}
+ >
+ <Plus />
+ <span className="my-auto">New List</span>
+ </Button>
+ <ListItem name="Favourites" icon="⭐️" path={`/dashboard/favourites`} />
+ <ListItem name="Archive" icon="🗄️" path={`/dashboard/archive`} />
+ {lists.lists.map((l) => (
+ <ListItem
+ key={l.id}
+ name={l.name}
+ icon={l.icon}
+ path={`/dashboard/lists/${l.id}`}
+ />
+ ))}
+ </div>
+ );
+}
diff --git a/apps/web/components/dashboard/lists/DeleteListButton.tsx b/apps/web/components/dashboard/lists/DeleteListButton.tsx
new file mode 100644
index 00000000..5303b217
--- /dev/null
+++ b/apps/web/components/dashboard/lists/DeleteListButton.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Trash } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { toast } from "@/components/ui/use-toast";
+import { api } from "@/lib/trpc";
+import { ActionButton } from "@/components/ui/action-button";
+import { useState } from "react";
+import { ZBookmarkList } from "@hoarder/trpc/types/lists";
+
+export default function DeleteListButton({ list }: { list: ZBookmarkList }) {
+ const [isDialogOpen, setDialogOpen] = useState(false);
+
+ const router = useRouter();
+
+ const listsInvalidationFunction = api.useUtils().lists.list.invalidate;
+ const { mutate: deleteList, isPending } = api.lists.delete.useMutation({
+ onSuccess: () => {
+ listsInvalidationFunction();
+ toast({
+ description: `List "${list.icon} ${list.name}" is deleted!`,
+ });
+ router.push("/");
+ },
+ onError: () => {
+ toast({
+ variant: "destructive",
+ description: `Something went wrong`,
+ });
+ },
+ });
+ return (
+ <Dialog open={isDialogOpen} onOpenChange={setDialogOpen}>
+ <DialogTrigger asChild>
+ <Button className="mt-auto flex gap-2" variant="destructive">
+ <Trash className="size-5" />
+ <span className="hidden md:block">Delete List</span>
+ </Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>
+ Delete {list.icon} {list.name}?
+ </DialogTitle>
+ </DialogHeader>
+ <span>
+ Are you sure you want to delete {list.icon} {list.name}?
+ </span>
+ <DialogFooter className="sm:justify-end">
+ <DialogClose asChild>
+ <Button type="button" variant="secondary">
+ Close
+ </Button>
+ </DialogClose>
+ <ActionButton
+ type="button"
+ variant="destructive"
+ loading={isPending}
+ onClick={() => deleteList({ listId: list.id })}
+ >
+ Delete
+ </ActionButton>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/components/dashboard/lists/ListView.tsx b/apps/web/components/dashboard/lists/ListView.tsx
new file mode 100644
index 00000000..2d48d9e3
--- /dev/null
+++ b/apps/web/components/dashboard/lists/ListView.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import BookmarksGrid from "@/components/dashboard/bookmarks/BookmarksGrid";
+import { ZBookmark } from "@hoarder/trpc/types/bookmarks";
+import { ZBookmarkListWithBookmarks } from "@hoarder/trpc/types/lists";
+import { api } from "@/lib/trpc";
+
+export default function ListView({
+ bookmarks,
+ list: initialData,
+}: {
+ list: ZBookmarkListWithBookmarks;
+ bookmarks: ZBookmark[];
+}) {
+ const { data } = api.lists.get.useQuery(
+ { listId: initialData.id },
+ {
+ initialData,
+ },
+ );
+
+ return (
+ <BookmarksGrid query={{ ids: data.bookmarks }} bookmarks={bookmarks} />
+ );
+}
diff --git a/apps/web/components/dashboard/search/SearchInput.tsx b/apps/web/components/dashboard/search/SearchInput.tsx
new file mode 100644
index 00000000..73d14c90
--- /dev/null
+++ b/apps/web/components/dashboard/search/SearchInput.tsx
@@ -0,0 +1,25 @@
+import { Input } from "@/components/ui/input";
+import { useDoBookmarkSearch } from "@/lib/hooks/bookmark-search";
+import { cn } from "@/lib/utils";
+import React from "react";
+
+const SearchInput = React.forwardRef<
+ HTMLInputElement,
+ React.HTMLAttributes<HTMLInputElement> & { loading?: boolean }
+>(({ className, loading = false, ...props }, ref) => {
+ const { debounceSearch, searchQuery } = useDoBookmarkSearch();
+
+ return (
+ <Input
+ ref={ref}
+ placeholder="Search"
+ defaultValue={searchQuery}
+ onChange={(e) => debounceSearch(e.target.value)}
+ className={cn(loading ? "animate-pulse-border" : undefined, className)}
+ {...props}
+ />
+ );
+});
+SearchInput.displayName = "SearchInput";
+
+export { SearchInput };
diff --git a/apps/web/components/dashboard/settings/AddApiKey.tsx b/apps/web/components/dashboard/settings/AddApiKey.tsx
new file mode 100644
index 00000000..a4fd9c25
--- /dev/null
+++ b/apps/web/components/dashboard/settings/AddApiKey.tsx
@@ -0,0 +1,167 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { z } from "zod";
+import { useRouter } from "next/navigation";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm, SubmitErrorHandler } from "react-hook-form";
+import { toast } from "@/components/ui/use-toast";
+import { api } from "@/lib/trpc";
+import { useState } from "react";
+import { Check, Copy } from "lucide-react";
+import { ActionButton } from "@/components/ui/action-button";
+
+function ApiKeySuccess({ apiKey }: { apiKey: string }) {
+ const [isCopied, setCopied] = useState(false);
+
+ const onCopy = () => {
+ navigator.clipboard.writeText(apiKey);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+ <div>
+ <div className="py-4">
+ Note: please copy the key and store it somewhere safe. Once you close
+ the dialog, you won&apos;t be able to access it again.
+ </div>
+ <div className="flex space-x-2 pt-2">
+ <Input value={apiKey} readOnly />
+ <Button onClick={onCopy}>
+ {!isCopied ? (
+ <Copy className="size-4" />
+ ) : (
+ <Check className="size-4" />
+ )}
+ </Button>
+ </div>
+ </div>
+ );
+}
+
+function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) {
+ const formSchema = z.object({
+ name: z.string(),
+ });
+ const router = useRouter();
+ const mutator = api.apiKeys.create.useMutation({
+ onSuccess: (resp) => {
+ onSuccess(resp.key);
+ router.refresh();
+ },
+ onError: () => {
+ toast({ description: "Something went wrong", variant: "destructive" });
+ },
+ });
+
+ const form = useForm<z.infer<typeof formSchema>>({
+ resolver: zodResolver(formSchema),
+ });
+
+ async function onSubmit(value: z.infer<typeof formSchema>) {
+ mutator.mutate({ name: value.name });
+ }
+
+ const onError: SubmitErrorHandler<z.infer<typeof formSchema>> = (errors) => {
+ toast({
+ description: Object.values(errors)
+ .map((v) => v.message)
+ .join("\n"),
+ variant: "destructive",
+ });
+ };
+
+ return (
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit, onError)}
+ className="flex w-full space-x-3 space-y-8 pt-4"
+ >
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => {
+ return (
+ <FormItem className="flex-1">
+ <FormLabel>Name</FormLabel>
+ <FormControl>
+ <Input type="text" placeholder="Name" {...field} />
+ </FormControl>
+ <FormDescription>
+ Give your API key a unique name
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ <ActionButton
+ className="h-full"
+ type="submit"
+ loading={mutator.isPending}
+ >
+ Create
+ </ActionButton>
+ </form>
+ </Form>
+ );
+}
+
+export default function AddApiKey() {
+ const [key, setKey] = useState<string | undefined>(undefined);
+ const [dialogOpen, setDialogOpen] = useState<boolean>(false);
+ return (
+ <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
+ <DialogTrigger asChild>
+ <Button>New API Key</Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>
+ {key ? "Key was successfully created" : "Create API key"}
+ </DialogTitle>
+ <DialogDescription>
+ {key ? (
+ <ApiKeySuccess apiKey={key} />
+ ) : (
+ <AddApiKeyForm onSuccess={setKey} />
+ )}
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="sm:justify-end">
+ <DialogClose asChild>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setKey(undefined)}
+ >
+ Close
+ </Button>
+ </DialogClose>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/components/dashboard/settings/ApiKeySettings.tsx b/apps/web/components/dashboard/settings/ApiKeySettings.tsx
new file mode 100644
index 00000000..1598f25f
--- /dev/null
+++ b/apps/web/components/dashboard/settings/ApiKeySettings.tsx
@@ -0,0 +1,49 @@
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { api } from "@/server/api/client";
+import DeleteApiKey from "./DeleteApiKey";
+import AddApiKey from "./AddApiKey";
+
+export default async function ApiKeys() {
+ const keys = await api.apiKeys.list();
+ return (
+ <div className="pt-4">
+ <span className="text-xl">API Keys</span>
+ <hr className="my-2" />
+ <div className="flex flex-col space-y-3">
+ <div className="flex flex-1 justify-end">
+ <AddApiKey />
+ </div>
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>Name</TableHead>
+ <TableHead>Key</TableHead>
+ <TableHead>Created At</TableHead>
+ <TableHead>Action</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {keys.keys.map((k) => (
+ <TableRow key={k.id}>
+ <TableCell>{k.name}</TableCell>
+ <TableCell>**_{k.keyId}_**</TableCell>
+ <TableCell>{k.createdAt.toLocaleString()}</TableCell>
+ <TableCell>
+ <DeleteApiKey name={k.name} id={k.id} />
+ </TableCell>
+ </TableRow>
+ ))}
+ <TableRow></TableRow>
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/components/dashboard/settings/DeleteApiKey.tsx b/apps/web/components/dashboard/settings/DeleteApiKey.tsx
new file mode 100644
index 00000000..566136af
--- /dev/null
+++ b/apps/web/components/dashboard/settings/DeleteApiKey.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Trash } from "lucide-react";
+
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { useRouter } from "next/navigation";
+import { toast } from "@/components/ui/use-toast";
+import { api } from "@/lib/trpc";
+import { ActionButton } from "@/components/ui/action-button";
+import { useState } from "react";
+
+export default function DeleteApiKey({
+ name,
+ id,
+}: {
+ name: string;
+ id: string;
+}) {
+ const [isDialogOpen, setDialogOpen] = useState(false);
+ const router = useRouter();
+ const mutator = api.apiKeys.revoke.useMutation({
+ onSuccess: () => {
+ toast({
+ description: "Key was successfully deleted",
+ });
+ setDialogOpen(false);
+ router.refresh();
+ },
+ });
+
+ return (
+ <Dialog open={isDialogOpen} onOpenChange={setDialogOpen}>
+ <DialogTrigger asChild>
+ <Button variant="destructive">
+ <Trash className="size-5" />
+ </Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Delete API Key</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete the API key &quot;{name}&quot;? Any
+ service using this API key will lose access.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="sm:justify-end">
+ <DialogClose asChild>
+ <Button type="button" variant="secondary">
+ Close
+ </Button>
+ </DialogClose>
+ <ActionButton
+ type="button"
+ variant="destructive"
+ loading={mutator.isPending}
+ onClick={() => mutator.mutate({ id })}
+ >
+ Delete
+ </ActionButton>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/components/dashboard/sidebar/AllLists.tsx b/apps/web/components/dashboard/sidebar/AllLists.tsx
new file mode 100644
index 00000000..a77252d0
--- /dev/null
+++ b/apps/web/components/dashboard/sidebar/AllLists.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { api } from "@/lib/trpc";
+import SidebarItem from "./SidebarItem";
+import NewListModal, { useNewListModal } from "./NewListModal";
+import { Plus } from "lucide-react";
+import Link from "next/link";
+import { ZBookmarkList } from "@hoarder/trpc/types/lists";
+
+export default function AllLists({
+ initialData,
+}: {
+ initialData: { lists: ZBookmarkList[] };
+}) {
+ let { data: lists } = api.lists.list.useQuery(undefined, {
+ initialData,
+ });
+ // TODO: This seems to be a bug in react query
+ lists ||= initialData;
+ const { setOpen } = useNewListModal();
+
+ return (
+ <ul className="max-h-full gap-y-2 overflow-auto text-sm font-medium">
+ <NewListModal />
+ <li className="flex justify-between pb-2 font-bold">
+ <p>Lists</p>
+ <Link href="#" onClick={() => setOpen(true)}>
+ <Plus />
+ </Link>
+ </li>
+ <SidebarItem
+ logo={<span className="text-lg">📋</span>}
+ name="All Lists"
+ path={`/dashboard/lists`}
+ className="py-0.5"
+ />
+ <SidebarItem
+ logo={<span className="text-lg">⭐️</span>}
+ name="Favourties"
+ path={`/dashboard/favourites`}
+ className="py-0.5"
+ />
+ <SidebarItem
+ logo={<span className="text-lg">🗄️</span>}
+ name="Archive"
+ path={`/dashboard/archive`}
+ className="py-0.5"
+ />
+ {lists.lists.map((l) => (
+ <SidebarItem
+ key={l.id}
+ logo={<span className="text-lg"> {l.icon}</span>}
+ name={l.name}
+ path={`/dashboard/lists/${l.id}`}
+ className="py-0.5"
+ />
+ ))}
+ </ul>
+ );
+}
diff --git a/apps/web/components/dashboard/sidebar/ModileSidebar.tsx b/apps/web/components/dashboard/sidebar/ModileSidebar.tsx
new file mode 100644
index 00000000..4bd6a347
--- /dev/null
+++ b/apps/web/components/dashboard/sidebar/ModileSidebar.tsx
@@ -0,0 +1,24 @@
+import MobileSidebarItem from "./ModileSidebarItem";
+import {
+ Tag,
+ PackageOpen,
+ Settings,
+ Search,
+ ClipboardList,
+} from "lucide-react";
+import SidebarProfileOptions from "./SidebarProfileOptions";
+
+export default async function MobileSidebar() {
+ return (
+ <aside className="w-full">
+ <ul className="flex justify-between space-x-2 border-b-black bg-gray-100 px-5 py-2 pt-5">
+ <MobileSidebarItem logo={<PackageOpen />} path="/dashboard/bookmarks" />
+ <MobileSidebarItem logo={<Search />} path="/dashboard/search" />
+ <MobileSidebarItem logo={<ClipboardList />} path="/dashboard/lists" />
+ <MobileSidebarItem logo={<Tag />} path="/dashboard/tags" />
+ <MobileSidebarItem logo={<Settings />} path="/dashboard/settings" />
+ <SidebarProfileOptions />
+ </ul>
+ </aside>
+ );
+}
diff --git a/apps/web/components/dashboard/sidebar/ModileSidebarItem.tsx b/apps/web/components/dashboard/sidebar/ModileSidebarItem.tsx
new file mode 100644
index 00000000..9389d2e4
--- /dev/null
+++ b/apps/web/components/dashboard/sidebar/ModileSidebarItem.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+export default function MobileSidebarItem({
+ logo,
+ path,
+}: {
+ logo: React.ReactNode;
+ path: string;
+}) {
+ const currentPath = usePathname();
+ return (
+ <li
+ className={cn(
+ "flex w-full rounded-lg hover:bg-gray-50",
+ path == currentPath ? "bg-gray-50" : "",
+ )}
+ >
+ <Link href={path} className="mx-auto px-3 py-2">
+ {logo}
+ </Link>
+ </li>
+ );
+}
diff --git a/apps/web/components/dashboard/sidebar/NewListModal.tsx b/apps/web/components/dashboard/sidebar/NewListModal.tsx
new file mode 100644
index 00000000..f51616ed
--- /dev/null
+++ b/apps/web/components/dashboard/sidebar/NewListModal.tsx
@@ -0,0 +1,170 @@
+"use client";
+
+import data from "@emoji-mart/data";
+import Picker from "@emoji-mart/react";
+
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+
+import { ActionButton } from "@/components/ui/action-button";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage,
+} from "@/components/ui/form";
+
+import { toast } from "@/components/ui/use-toast";
+import { api } from "@/lib/trpc";
+
+import { z } from "zod";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Input } from "@/components/ui/input";
+
+import { create } from "zustand";
+
+export const useNewListModal = create<{
+ open: boolean;
+ setOpen: (v: boolean) => void;
+}>((set) => ({
+ open: false,
+ setOpen: (open: boolean) => set(() => ({ open })),
+}));
+
+export default function NewListModal() {
+ const { open, setOpen } = useNewListModal();
+
+ const formSchema = z.object({
+ name: z.string(),
+ icon: z.string(),
+ });
+ const form = useForm<z.infer<typeof formSchema>>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ name: "",
+ icon: "💡",
+ },
+ });
+
+ const listsInvalidationFunction = api.useUtils().lists.list.invalidate;
+
+ const { mutate: createList, isPending } = api.lists.create.useMutation({
+ onSuccess: () => {
+ toast({
+ description: "List has been created!",
+ });
+ listsInvalidationFunction();
+ setOpen(false);
+ },
+ onError: (e) => {
+ if (e.data?.code == "BAD_REQUEST") {
+ toast({
+ variant: "destructive",
+ description: e.message,
+ });
+ } else {
+ toast({
+ variant: "destructive",
+ title: "Something went wrong",
+ });
+ }
+ },
+ });
+
+ return (
+ <Dialog
+ open={open}
+ onOpenChange={(s) => {
+ form.reset();
+ setOpen(s);
+ }}
+ >
+ <DialogContent>
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit((value) => {
+ createList(value);
+ })}
+ >
+ <DialogHeader>
+ <DialogTitle>New List</DialogTitle>
+ </DialogHeader>
+ <div className="flex w-full gap-2 py-4">
+ <FormField
+ control={form.control}
+ name="icon"
+ render={({ field }) => {
+ return (
+ <FormItem>
+ <FormControl>
+ <Popover>
+ <PopoverTrigger className="border-input h-full rounded border px-2 text-2xl">
+ {field.value}
+ </PopoverTrigger>
+ <PopoverContent>
+ <Picker
+ data={data}
+ onEmojiSelect={(e: { native: string }) =>
+ field.onChange(e.native)
+ }
+ />
+ </PopoverContent>
+ </Popover>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => {
+ return (
+ <FormItem className="grow">
+ <FormControl>
+ <Input
+ type="text"
+ className="w-full"
+ placeholder="List Name"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ </div>
+ <DialogFooter className="sm:justify-end">
+ <DialogClose asChild>
+ <Button type="button" variant="secondary">
+ Close
+ </Button>
+ </DialogClose>
+ <ActionButton type="submit" loading={isPending}>
+ Create
+ </ActionButton>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/components/dashboard/sidebar/Sidebar.tsx b/apps/web/components/dashboard/sidebar/Sidebar.tsx
new file mode 100644
index 00000000..a5c1d7a5
--- /dev/null
+++ b/apps/web/components/dashboard/sidebar/Sidebar.tsx
@@ -0,0 +1,66 @@
+import { Tag, Home, PackageOpen, Settings, Search, Shield } from "lucide-react";
+import { redirect } from "next/navigation";
+import SidebarItem from "./SidebarItem";
+import { getServerAuthSession } from "@/server/auth";
+import Link from "next/link";
+import SidebarProfileOptions from "./SidebarProfileOptions";
+import { Separator } from "@/components/ui/separator";
+import AllLists from "./AllLists";
+import serverConfig from "@hoarder/shared/config";
+import { api } from "@/server/api/client";
+
+export default async function Sidebar() {
+ const session = await getServerAuthSession();
+ if (!session) {
+ redirect("/");
+ }
+
+ const lists = await api.lists.list();
+
+ return (
+ <aside className="flex h-screen w-60 flex-col gap-5 border-r p-4">
+ <Link href={"/dashboard/bookmarks"}>
+ <div className="flex items-center rounded-lg px-1 text-slate-900">
+ <PackageOpen />
+ <span className="ml-2 text-base font-semibold">Hoarder</span>
+ </div>
+ </Link>
+ <hr />
+ <div>
+ <ul className="space-y-2 text-sm font-medium">
+ <SidebarItem
+ logo={<Home />}
+ name="Home"
+ path="/dashboard/bookmarks"
+ />
+ {serverConfig.meilisearch && (
+ <SidebarItem
+ logo={<Search />}
+ name="Search"
+ path="/dashboard/search"
+ />
+ )}
+ <SidebarItem logo={<Tag />} name="Tags" path="/dashboard/tags" />
+ <SidebarItem
+ logo={<Settings />}
+ name="Settings"
+ path="/dashboard/settings"
+ />
+ {session.user.role == "admin" && (
+ <SidebarItem
+ logo={<Shield />}
+ name="Admin"
+ path="/dashboard/admin"
+ />
+ )}
+ </ul>
+ </div>
+ <Separator />
+ <AllLists initialData={lists} />
+ <div className="mt-auto flex justify-between justify-self-end">
+ <div className="my-auto"> {session.user.name} </div>
+ <SidebarProfileOptions />
+ </div>
+ </aside>
+ );
+}
diff --git a/apps/web/components/dashboard/sidebar/SidebarItem.tsx b/apps/web/components/dashboard/sidebar/SidebarItem.tsx
new file mode 100644
index 00000000..856bdffd
--- /dev/null
+++ b/apps/web/components/dashboard/sidebar/SidebarItem.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+export default function SidebarItem({
+ name,
+ logo,
+ path,
+ className,
+}: {
+ name: string;
+ logo: React.ReactNode;
+ path: string;
+ className?: string;
+}) {
+ const currentPath = usePathname();
+ return (
+ <li
+ className={cn(
+ "rounded-lg px-3 py-2 hover:bg-slate-100",
+ path == currentPath ? "bg-gray-50" : "",
+ className,
+ )}
+ >
+ <Link href={path} className="flex w-full gap-x-2">
+ {logo}
+ <span className="my-auto"> {name} </span>
+ </Link>
+ </li>
+ );
+}
diff --git a/apps/web/components/dashboard/sidebar/SidebarProfileOptions.tsx b/apps/web/components/dashboard/sidebar/SidebarProfileOptions.tsx
new file mode 100644
index 00000000..f931b63e
--- /dev/null
+++ b/apps/web/components/dashboard/sidebar/SidebarProfileOptions.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { LogOut, MoreHorizontal } from "lucide-react";
+import { signOut } from "next-auth/react";
+
+export default function SidebarProfileOptions() {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost">
+ <MoreHorizontal />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent className="w-fit">
+ <DropdownMenuItem
+ onClick={() =>
+ signOut({
+ callbackUrl: "/",
+ })
+ }
+ >
+ <LogOut className="mr-2 size-4" />
+ <span>Sign Out</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+}
diff --git a/apps/web/components/signin/CredentialsForm.tsx b/apps/web/components/signin/CredentialsForm.tsx
new file mode 100644
index 00000000..5296e163
--- /dev/null
+++ b/apps/web/components/signin/CredentialsForm.tsx
@@ -0,0 +1,222 @@
+"use client";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { ActionButton } from "@/components/ui/action-button";
+import { zSignUpSchema } from "@hoarder/trpc/types/users";
+import { signIn } from "next-auth/react";
+import { useState } from "react";
+import { api } from "@/lib/trpc";
+import { useRouter } from "next/navigation";
+import { TRPCClientError } from "@trpc/client";
+
+const signInSchema = z.object({
+ email: z.string().email(),
+ password: z.string(),
+});
+
+function SignIn() {
+ const [signinError, setSigninError] = useState(false);
+ const router = useRouter();
+ const form = useForm<z.infer<typeof signInSchema>>({
+ resolver: zodResolver(signInSchema),
+ });
+
+ return (
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(async (value) => {
+ const resp = await signIn("credentials", {
+ redirect: false,
+ email: value.email,
+ password: value.password,
+ });
+ if (!resp || !resp?.ok) {
+ setSigninError(true);
+ return;
+ }
+ router.replace("/");
+ })}
+ >
+ <div className="flex w-full flex-col space-y-2">
+ {signinError && (
+ <p className="w-full text-center text-red-500">
+ Incorrect username or password
+ </p>
+ )}
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => {
+ return (
+ <FormItem>
+ <FormLabel>Email</FormLabel>
+ <FormControl>
+ <Input type="text" placeholder="Email" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ <FormField
+ control={form.control}
+ name="password"
+ render={({ field }) => {
+ return (
+ <FormItem>
+ <FormLabel>Password</FormLabel>
+ <FormControl>
+ <Input type="password" placeholder="Password" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ <ActionButton type="submit" loading={form.formState.isSubmitting}>
+ Sign In
+ </ActionButton>
+ </div>
+ </form>
+ </Form>
+ );
+}
+
+function SignUp() {
+ const form = useForm<z.infer<typeof zSignUpSchema>>({
+ resolver: zodResolver(zSignUpSchema),
+ });
+ const [errorMessage, setErrorMessage] = useState("");
+
+ const router = useRouter();
+
+ const createUserMutation = api.users.create.useMutation();
+
+ return (
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(async (value) => {
+ try {
+ await createUserMutation.mutateAsync(value);
+ } catch (e) {
+ if (e instanceof TRPCClientError) {
+ setErrorMessage(e.message);
+ }
+ return;
+ }
+ const resp = await signIn("credentials", {
+ redirect: false,
+ email: value.email,
+ password: value.password,
+ });
+ if (!resp || !resp.ok) {
+ setErrorMessage("Hit an unexpected error while signing in");
+ return;
+ }
+ router.replace("/");
+ })}
+ >
+ <div className="flex w-full flex-col space-y-2">
+ {errorMessage && (
+ <p className="w-full text-center text-red-500">{errorMessage}</p>
+ )}
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => {
+ return (
+ <FormItem>
+ <FormLabel>Name</FormLabel>
+ <FormControl>
+ <Input type="text" placeholder="Name" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => {
+ return (
+ <FormItem>
+ <FormLabel>Email</FormLabel>
+ <FormControl>
+ <Input type="text" placeholder="Email" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ <FormField
+ control={form.control}
+ name="password"
+ render={({ field }) => {
+ return (
+ <FormItem>
+ <FormLabel>Password</FormLabel>
+ <FormControl>
+ <Input type="password" placeholder="Password" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ <FormField
+ control={form.control}
+ name="confirmPassword"
+ render={({ field }) => {
+ return (
+ <FormItem>
+ <FormLabel>Confirm Password</FormLabel>
+ <FormControl>
+ <Input
+ type="password"
+ placeholder="Confirm Password"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ <ActionButton type="submit" loading={form.formState.isSubmitting}>
+ Sign Up
+ </ActionButton>
+ </div>
+ </form>
+ </Form>
+ );
+}
+
+export default function CredentialsForm() {
+ return (
+ <Tabs defaultValue="signin" className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="signin">Sign In</TabsTrigger>
+ <TabsTrigger value="signup">Sign Up</TabsTrigger>
+ </TabsList>
+ <TabsContent value="signin">
+ <SignIn />
+ </TabsContent>
+ <TabsContent value="signup">
+ <SignUp />
+ </TabsContent>
+ </Tabs>
+ );
+}
diff --git a/apps/web/components/signin/SignInForm.tsx b/apps/web/components/signin/SignInForm.tsx
new file mode 100644
index 00000000..7c8f8936
--- /dev/null
+++ b/apps/web/components/signin/SignInForm.tsx
@@ -0,0 +1,37 @@
+import { getProviders } from "next-auth/react";
+import SignInProviderButton from "./SignInProviderButton";
+import CredentialsForm from "./CredentialsForm";
+
+export default async function SignInForm() {
+ const providers = await getProviders();
+ let providerValues;
+ if (providers) {
+ providerValues = Object.values(providers).filter(
+ // Credentials are handled manually by the sign in form
+ (p) => p.id != "credentials",
+ );
+ }
+
+ return (
+ <div className="flex flex-col items-center space-y-2">
+ <CredentialsForm />
+
+ {providerValues && providerValues.length > 0 && (
+ <>
+ <div className="flex w-full items-center">
+ <div className="flex-1 grow border-t-2 border-gray-200"></div>
+ <span className="bg-white px-3 text-gray-500">Or</span>
+ <div className="flex-1 grow border-t-2 border-gray-200"></div>
+ </div>
+ <div className="space-y-2">
+ {providerValues.map((provider) => (
+ <div key={provider.id}>
+ <SignInProviderButton provider={provider} />
+ </div>
+ ))}
+ </div>
+ </>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/components/signin/SignInProviderButton.tsx b/apps/web/components/signin/SignInProviderButton.tsx
new file mode 100644
index 00000000..0831236c
--- /dev/null
+++ b/apps/web/components/signin/SignInProviderButton.tsx
@@ -0,0 +1,21 @@
+"use client";
+import { Button } from "@/components/ui/button";
+import { ClientSafeProvider, signIn } from "next-auth/react";
+
+export default function SignInProviderButton({
+ provider,
+}: {
+ provider: ClientSafeProvider;
+}) {
+ return (
+ <Button
+ onClick={() =>
+ signIn(provider.id, {
+ callbackUrl: "/",
+ })
+ }
+ >
+ Sign in with {provider.name}
+ </Button>
+ );
+}
diff --git a/apps/web/components/ui/action-button.tsx b/apps/web/components/ui/action-button.tsx
new file mode 100644
index 00000000..42e16f65
--- /dev/null
+++ b/apps/web/components/ui/action-button.tsx
@@ -0,0 +1,25 @@
+import { Button, ButtonProps } from "./button";
+import LoadingSpinner from "./spinner";
+
+export function ActionButton({
+ children,
+ loading,
+ spinner,
+ disabled,
+ ...props
+}: ButtonProps & {
+ loading: boolean;
+ spinner?: React.ReactNode;
+}) {
+ spinner ||= <LoadingSpinner />;
+ if (disabled !== undefined) {
+ disabled ||= loading;
+ } else if (loading) {
+ disabled = true;
+ }
+ return (
+ <Button {...props} disabled={disabled}>
+ {loading ? spinner : children}
+ </Button>
+ );
+}
diff --git a/apps/web/components/ui/back-button.tsx b/apps/web/components/ui/back-button.tsx
new file mode 100644
index 00000000..685930df
--- /dev/null
+++ b/apps/web/components/ui/back-button.tsx
@@ -0,0 +1,9 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { Button, ButtonProps } from "./button";
+
+export function BackButton({ ...props }: ButtonProps) {
+ const router = useRouter();
+ return <Button {...props} onClick={() => router.back()} />;
+}
diff --git a/apps/web/components/ui/badge.tsx b/apps/web/components/ui/badge.tsx
new file mode 100644
index 00000000..c30daca1
--- /dev/null
+++ b/apps/web/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "focus:ring-ring inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground hover:bg-primary/80 border-transparent",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 border-transparent",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/80 border-transparent",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+export interface BadgeProps
+ extends React.HTMLAttributes<HTMLDivElement>,
+ VariantProps<typeof badgeVariants> {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/apps/web/components/ui/button.tsx b/apps/web/components/ui/button.tsx
new file mode 100644
index 00000000..79b45fa0
--- /dev/null
+++ b/apps/web/components/ui/button.tsx
@@ -0,0 +1,56 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border-input bg-background hover:bg-accent hover:text-accent-foreground border",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3",
+ lg: "h-11 rounded-md px-8",
+ icon: "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
+ VariantProps<typeof buttonVariants> {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return (
+ <Comp
+ className={cn(buttonVariants({ variant, size, className }))}
+ ref={ref}
+ {...props}
+ />
+ );
+ },
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/apps/web/components/ui/card.tsx b/apps/web/components/ui/card.tsx
new file mode 100644
index 00000000..f4e57996
--- /dev/null
+++ b/apps/web/components/ui/card.tsx
@@ -0,0 +1,86 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+ <div
+ ref={ref}
+ className={cn(
+ "bg-card text-card-foreground rounded-lg border shadow-sm",
+ className,
+ )}
+ {...props}
+ />
+));
+Card.displayName = "Card";
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+ <div
+ ref={ref}
+ className={cn("flex flex-col space-y-1.5 p-6", className)}
+ {...props}
+ />
+));
+CardHeader.displayName = "CardHeader";
+
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLHeadingElement>
+>(({ className, ...props }, ref) => (
+ <h3
+ ref={ref}
+ className={cn(
+ "text-2xl font-semibold leading-none tracking-tight",
+ className,
+ )}
+ {...props}
+ />
+));
+CardTitle.displayName = "CardTitle";
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLParagraphElement>
+>(({ className, ...props }, ref) => (
+ <p
+ ref={ref}
+ className={cn("text-muted-foreground text-sm", className)}
+ {...props}
+ />
+));
+CardDescription.displayName = "CardDescription";
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+ <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
+));
+CardContent.displayName = "CardContent";
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+ <div
+ ref={ref}
+ className={cn("flex items-center p-6 pt-0", className)}
+ {...props}
+ />
+));
+CardFooter.displayName = "CardFooter";
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardDescription,
+ CardContent,
+};
diff --git a/apps/web/components/ui/dialog.tsx b/apps/web/components/ui/dialog.tsx
new file mode 100644
index 00000000..8fe3fe35
--- /dev/null
+++ b/apps/web/components/ui/dialog.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Dialog = DialogPrimitive.Root;
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = DialogPrimitive.Portal;
+
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Overlay
+ ref={ref}
+ className={cn(
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
+ className,
+ )}
+ {...props}
+ />
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+ <DialogPortal>
+ <DialogOverlay />
+ <DialogPrimitive.Content
+ ref={ref}
+ className={cn(
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
+ <X className="size-4" />
+ <span className="sr-only">Close</span>
+ </DialogPrimitive.Close>
+ </DialogPrimitive.Content>
+ </DialogPortal>
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex flex-col space-y-1.5 text-center sm:text-left",
+ className,
+ )}
+ {...props}
+ />
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
+ className,
+ )}
+ {...props}
+ />
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Title>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Title
+ ref={ref}
+ className={cn(
+ "text-lg font-semibold leading-none tracking-tight",
+ className,
+ )}
+ {...props}
+ />
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Description>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Description
+ ref={ref}
+ className={cn("text-muted-foreground text-sm", className)}
+ {...props}
+ />
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/apps/web/components/ui/dropdown-menu.tsx b/apps/web/components/ui/dropdown-menu.tsx
new file mode 100644
index 00000000..3a9a2ff7
--- /dev/null
+++ b/apps/web/components/ui/dropdown-menu.tsx
@@ -0,0 +1,200 @@
+"use client";
+
+import * as React from "react";
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
+import { Check, ChevronRight, Circle } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const DropdownMenu = DropdownMenuPrimitive.Root;
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group;
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub;
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
+ inset?: boolean;
+ }
+>(({ className, inset, children, ...props }, ref) => (
+ <DropdownMenuPrimitive.SubTrigger
+ ref={ref}
+ className={cn(
+ "focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
+ inset && "pl-8",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <ChevronRight className="ml-auto size-4" />
+ </DropdownMenuPrimitive.SubTrigger>
+));
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName;
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
+>(({ className, ...props }, ref) => (
+ <DropdownMenuPrimitive.SubContent
+ ref={ref}
+ className={cn(
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
+ className,
+ )}
+ {...props}
+ />
+));
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName;
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
+>(({ className, sideOffset = 4, ...props }, ref) => (
+ <DropdownMenuPrimitive.Portal>
+ <DropdownMenuPrimitive.Content
+ ref={ref}
+ sideOffset={sideOffset}
+ className={cn(
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
+ className,
+ )}
+ {...props}
+ />
+ </DropdownMenuPrimitive.Portal>
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+ <DropdownMenuPrimitive.Item
+ ref={ref}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+ inset && "pl-8",
+ className,
+ )}
+ {...props}
+ />
+));
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
+>(({ className, children, checked, ...props }, ref) => (
+ <DropdownMenuPrimitive.CheckboxItem
+ ref={ref}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+ className,
+ )}
+ checked={checked}
+ {...props}
+ >
+ <span className="absolute left-2 flex size-3.5 items-center justify-center">
+ <DropdownMenuPrimitive.ItemIndicator>
+ <Check className="size-4" />
+ </DropdownMenuPrimitive.ItemIndicator>
+ </span>
+ {children}
+ </DropdownMenuPrimitive.CheckboxItem>
+));
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName;
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
+>(({ className, children, ...props }, ref) => (
+ <DropdownMenuPrimitive.RadioItem
+ ref={ref}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+ className,
+ )}
+ {...props}
+ >
+ <span className="absolute left-2 flex size-3.5 items-center justify-center">
+ <DropdownMenuPrimitive.ItemIndicator>
+ <Circle className="size-2 fill-current" />
+ </DropdownMenuPrimitive.ItemIndicator>
+ </span>
+ {children}
+ </DropdownMenuPrimitive.RadioItem>
+));
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.Label>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+ <DropdownMenuPrimitive.Label
+ ref={ref}
+ className={cn(
+ "px-2 py-1.5 text-sm font-semibold",
+ inset && "pl-8",
+ className,
+ )}
+ {...props}
+ />
+));
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+ <DropdownMenuPrimitive.Separator
+ ref={ref}
+ className={cn("bg-muted -mx-1 my-1 h-px", className)}
+ {...props}
+ />
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLSpanElement>) => {
+ return (
+ <span
+ className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
+ {...props}
+ />
+ );
+};
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+};
diff --git a/apps/web/components/ui/form.tsx b/apps/web/components/ui/form.tsx
new file mode 100644
index 00000000..e62e10e9
--- /dev/null
+++ b/apps/web/components/ui/form.tsx
@@ -0,0 +1,177 @@
+import * as React from "react";
+import * as LabelPrimitive from "@radix-ui/react-label";
+import { Slot } from "@radix-ui/react-slot";
+import {
+ Controller,
+ ControllerProps,
+ FieldPath,
+ FieldValues,
+ FormProvider,
+ useFormContext,
+} from "react-hook-form";
+
+import { cn } from "@/lib/utils";
+import { Label } from "@/components/ui/label";
+
+const Form = FormProvider;
+
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
+> = {
+ name: TName;
+};
+
+const FormFieldContext = React.createContext<FormFieldContextValue>(
+ {} as FormFieldContextValue,
+);
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
+>({
+ ...props
+}: ControllerProps<TFieldValues, TName>) => {
+ return (
+ <FormFieldContext.Provider value={{ name: props.name }}>
+ <Controller {...props} />
+ </FormFieldContext.Provider>
+ );
+};
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext);
+ const itemContext = React.useContext(FormItemContext);
+ const { getFieldState, formState } = useFormContext();
+
+ const fieldState = getFieldState(fieldContext.name, formState);
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within <FormField>");
+ }
+
+ const { id } = itemContext;
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ };
+};
+
+type FormItemContextValue = {
+ id: string;
+};
+
+const FormItemContext = React.createContext<FormItemContextValue>(
+ {} as FormItemContextValue,
+);
+
+const FormItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => {
+ const id = React.useId();
+
+ return (
+ <FormItemContext.Provider value={{ id }}>
+ <div ref={ref} className={cn("space-y-2", className)} {...props} />
+ </FormItemContext.Provider>
+ );
+});
+FormItem.displayName = "FormItem";
+
+const FormLabel = React.forwardRef<
+ React.ElementRef<typeof LabelPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
+>(({ className, ...props }, ref) => {
+ const { error, formItemId } = useFormField();
+
+ return (
+ <Label
+ ref={ref}
+ className={cn(error && "text-destructive", className)}
+ htmlFor={formItemId}
+ {...props}
+ />
+ );
+});
+FormLabel.displayName = "FormLabel";
+
+const FormControl = React.forwardRef<
+ React.ElementRef<typeof Slot>,
+ React.ComponentPropsWithoutRef<typeof Slot>
+>(({ ...props }, ref) => {
+ const { error, formItemId, formDescriptionId, formMessageId } =
+ useFormField();
+
+ return (
+ <Slot
+ ref={ref}
+ id={formItemId}
+ aria-describedby={
+ !error
+ ? `${formDescriptionId}`
+ : `${formDescriptionId} ${formMessageId}`
+ }
+ aria-invalid={!!error}
+ {...props}
+ />
+ );
+});
+FormControl.displayName = "FormControl";
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLParagraphElement>
+>(({ className, ...props }, ref) => {
+ const { formDescriptionId } = useFormField();
+
+ return (
+ <p
+ ref={ref}
+ id={formDescriptionId}
+ className={cn("text-muted-foreground text-sm", className)}
+ {...props}
+ />
+ );
+});
+FormDescription.displayName = "FormDescription";
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLParagraphElement>
+>(({ className, children, ...props }, ref) => {
+ const { error, formMessageId } = useFormField();
+ const body = error ? String(error?.message) : children;
+
+ if (!body) {
+ return null;
+ }
+
+ return (
+ <p
+ ref={ref}
+ id={formMessageId}
+ className={cn("text-destructive text-sm font-medium", className)}
+ {...props}
+ >
+ {body}
+ </p>
+ );
+});
+FormMessage.displayName = "FormMessage";
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+};
diff --git a/apps/web/components/ui/imageCard.tsx b/apps/web/components/ui/imageCard.tsx
new file mode 100644
index 00000000..f10ebdb5
--- /dev/null
+++ b/apps/web/components/ui/imageCard.tsx
@@ -0,0 +1,70 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+export function ImageCard({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) {
+ return (
+ <div
+ className={cn("h-96 overflow-hidden rounded-lg shadow-md", className)}
+ {...props}
+ />
+ );
+}
+
+export function ImageCardBanner({
+ className,
+ ...props
+}: React.ImgHTMLAttributes<HTMLImageElement>) {
+ return (
+ // eslint-disable-next-line @next/next/no-img-element
+ <img
+ className={cn("h-56 min-h-56 w-full object-cover", className)}
+ alt="card banner"
+ {...props}
+ />
+ );
+}
+
+export function ImageCardContent({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) {
+ return (
+ <div
+ className={cn(
+ "flex h-40 min-h-40 flex-col justify-between p-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export function ImageCardTitle({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) {
+ return (
+ <div
+ className={cn("order-first flex-none text-lg font-bold", className)}
+ {...props}
+ />
+ );
+}
+
+export function ImageCardBody({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) {
+ return <div className={cn("order-1", className)} {...props} />;
+}
+
+export function ImageCardFooter({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) {
+ return <div className={cn("order-last", className)} {...props} />;
+}
diff --git a/apps/web/components/ui/input.tsx b/apps/web/components/ui/input.tsx
new file mode 100644
index 00000000..21aac7ad
--- /dev/null
+++ b/apps/web/components/ui/input.tsx
@@ -0,0 +1,25 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+export interface InputProps
+ extends React.InputHTMLAttributes<HTMLInputElement> {}
+
+const Input = React.forwardRef<HTMLInputElement, InputProps>(
+ ({ className, type, ...props }, ref) => {
+ return (
+ <input
+ type={type}
+ className={cn(
+ "border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ ref={ref}
+ {...props}
+ />
+ );
+ },
+);
+Input.displayName = "Input";
+
+export { Input };
diff --git a/apps/web/components/ui/label.tsx b/apps/web/components/ui/label.tsx
new file mode 100644
index 00000000..84f8b0c7
--- /dev/null
+++ b/apps/web/components/ui/label.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import * as React from "react";
+import * as LabelPrimitive from "@radix-ui/react-label";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
+);
+
+const Label = React.forwardRef<
+ React.ElementRef<typeof LabelPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
+ VariantProps<typeof labelVariants>
+>(({ className, ...props }, ref) => (
+ <LabelPrimitive.Root
+ ref={ref}
+ className={cn(labelVariants(), className)}
+ {...props}
+ />
+));
+Label.displayName = LabelPrimitive.Root.displayName;
+
+export { Label };
diff --git a/apps/web/components/ui/popover.tsx b/apps/web/components/ui/popover.tsx
new file mode 100644
index 00000000..a361ba7d
--- /dev/null
+++ b/apps/web/components/ui/popover.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import * as React from "react";
+import * as PopoverPrimitive from "@radix-ui/react-popover";
+
+import { cn } from "@/lib/utils";
+
+const Popover = PopoverPrimitive.Root;
+
+const PopoverTrigger = PopoverPrimitive.Trigger;
+
+const PopoverContent = React.forwardRef<
+ React.ElementRef<typeof PopoverPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+ <PopoverPrimitive.Portal>
+ <PopoverPrimitive.Content
+ ref={ref}
+ align={align}
+ sideOffset={sideOffset}
+ className={cn(
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-none",
+ className,
+ )}
+ {...props}
+ />
+ </PopoverPrimitive.Portal>
+));
+PopoverContent.displayName = PopoverPrimitive.Content.displayName;
+
+export { Popover, PopoverTrigger, PopoverContent };
diff --git a/apps/web/components/ui/scroll-area.tsx b/apps/web/components/ui/scroll-area.tsx
new file mode 100644
index 00000000..32cb6022
--- /dev/null
+++ b/apps/web/components/ui/scroll-area.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import * as React from "react";
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
+
+import { cn } from "@/lib/utils";
+
+const ScrollArea = React.forwardRef<
+ React.ElementRef<typeof ScrollAreaPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
+>(({ className, children, ...props }, ref) => (
+ <ScrollAreaPrimitive.Root
+ ref={ref}
+ className={cn("relative overflow-hidden", className)}
+ {...props}
+ >
+ <ScrollAreaPrimitive.Viewport className="size-full rounded-[inherit]">
+ {children}
+ </ScrollAreaPrimitive.Viewport>
+ <ScrollBar />
+ <ScrollAreaPrimitive.Corner />
+ </ScrollAreaPrimitive.Root>
+));
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
+
+const ScrollBar = React.forwardRef<
+ React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
+ React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
+>(({ className, orientation = "vertical", ...props }, ref) => (
+ <ScrollAreaPrimitive.ScrollAreaScrollbar
+ ref={ref}
+ orientation={orientation}
+ className={cn(
+ "flex touch-none select-none transition-colors",
+ orientation === "vertical" &&
+ "h-full w-2.5 border-l border-l-transparent p-[1px]",
+ orientation === "horizontal" &&
+ "h-2.5 flex-col border-t border-t-transparent p-[1px]",
+ className,
+ )}
+ {...props}
+ >
+ <ScrollAreaPrimitive.ScrollAreaThumb className="bg-border relative flex-1 rounded-full" />
+ </ScrollAreaPrimitive.ScrollAreaScrollbar>
+));
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
+
+export { ScrollArea, ScrollBar };
diff --git a/apps/web/components/ui/select.tsx b/apps/web/components/ui/select.tsx
new file mode 100644
index 00000000..efd4ff1e
--- /dev/null
+++ b/apps/web/components/ui/select.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import * as React from "react";
+import * as SelectPrimitive from "@radix-ui/react-select";
+import { Check, ChevronDown, ChevronUp } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Select = SelectPrimitive.Root;
+
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Trigger>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
+>(({ className, children, ...props }, ref) => (
+ <SelectPrimitive.Trigger
+ ref={ref}
+ className={cn(
+ "border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <SelectPrimitive.Icon asChild>
+ <ChevronDown className="size-4 opacity-50" />
+ </SelectPrimitive.Icon>
+ </SelectPrimitive.Trigger>
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.ScrollUpButton
+ ref={ref}
+ className={cn(
+ "flex cursor-default items-center justify-center py-1",
+ className,
+ )}
+ {...props}
+ >
+ <ChevronUp className="size-4" />
+ </SelectPrimitive.ScrollUpButton>
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.ScrollDownButton
+ ref={ref}
+ className={cn(
+ "flex cursor-default items-center justify-center py-1",
+ className,
+ )}
+ {...props}
+ >
+ <ChevronDown className="size-4" />
+ </SelectPrimitive.ScrollDownButton>
+));
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
+>(({ className, children, position = "popper", ...props }, ref) => (
+ <SelectPrimitive.Portal>
+ <SelectPrimitive.Content
+ ref={ref}
+ className={cn(
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
+ position === "popper" &&
+ "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
+ className,
+ )}
+ position={position}
+ {...props}
+ >
+ <SelectScrollUpButton />
+ <SelectPrimitive.Viewport
+ className={cn(
+ "p-1",
+ position === "popper" &&
+ "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
+ )}
+ >
+ {children}
+ </SelectPrimitive.Viewport>
+ <SelectScrollDownButton />
+ </SelectPrimitive.Content>
+ </SelectPrimitive.Portal>
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Label>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.Label
+ ref={ref}
+ className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
+ {...props}
+ />
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
+>(({ className, children, ...props }, ref) => (
+ <SelectPrimitive.Item
+ ref={ref}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+ className,
+ )}
+ {...props}
+ >
+ <span className="absolute left-2 flex size-3.5 items-center justify-center">
+ <SelectPrimitive.ItemIndicator>
+ <Check className="size-4" />
+ </SelectPrimitive.ItemIndicator>
+ </span>
+
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+ </SelectPrimitive.Item>
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Separator>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.Separator
+ ref={ref}
+ className={cn("bg-muted -mx-1 my-1 h-px", className)}
+ {...props}
+ />
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+};
diff --git a/apps/web/components/ui/separator.tsx b/apps/web/components/ui/separator.tsx
new file mode 100644
index 00000000..3b9f2b84
--- /dev/null
+++ b/apps/web/components/ui/separator.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import * as React from "react";
+import * as SeparatorPrimitive from "@radix-ui/react-separator";
+
+import { cn } from "@/lib/utils";
+
+const Separator = React.forwardRef<
+ React.ElementRef<typeof SeparatorPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
+>(
+ (
+ { className, orientation = "horizontal", decorative = true, ...props },
+ ref,
+ ) => (
+ <SeparatorPrimitive.Root
+ ref={ref}
+ decorative={decorative}
+ orientation={orientation}
+ className={cn(
+ "bg-border shrink-0",
+ orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
+ className,
+ )}
+ {...props}
+ />
+ ),
+);
+Separator.displayName = SeparatorPrimitive.Root.displayName;
+
+export { Separator };
diff --git a/apps/web/components/ui/skeleton.tsx b/apps/web/components/ui/skeleton.tsx
new file mode 100644
index 00000000..5fab2023
--- /dev/null
+++ b/apps/web/components/ui/skeleton.tsx
@@ -0,0 +1,15 @@
+import { cn } from "@/lib/utils";
+
+function Skeleton({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) {
+ return (
+ <div
+ className={cn("bg-muted animate-pulse rounded-md", className)}
+ {...props}
+ />
+ );
+}
+
+export { Skeleton };
diff --git a/apps/web/components/ui/spinner.tsx b/apps/web/components/ui/spinner.tsx
new file mode 100644
index 00000000..adcd2807
--- /dev/null
+++ b/apps/web/components/ui/spinner.tsx
@@ -0,0 +1,20 @@
+import { cn } from "@/lib/utils";
+
+export default function LoadingSpinner({ className }: { className?: string }) {
+ return (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="24"
+ height="24"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ className={cn("animate-spin", className)}
+ >
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
+ </svg>
+ );
+}
diff --git a/apps/web/components/ui/table.tsx b/apps/web/components/ui/table.tsx
new file mode 100644
index 00000000..0fa9288e
--- /dev/null
+++ b/apps/web/components/ui/table.tsx
@@ -0,0 +1,117 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Table = React.forwardRef<
+ HTMLTableElement,
+ React.HTMLAttributes<HTMLTableElement>
+>(({ className, ...props }, ref) => (
+ <div className="relative w-full overflow-auto">
+ <table
+ ref={ref}
+ className={cn("w-full caption-bottom text-sm", className)}
+ {...props}
+ />
+ </div>
+));
+Table.displayName = "Table";
+
+const TableHeader = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+ <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
+));
+TableHeader.displayName = "TableHeader";
+
+const TableBody = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+ <tbody
+ ref={ref}
+ className={cn("[&_tr:last-child]:border-0", className)}
+ {...props}
+ />
+));
+TableBody.displayName = "TableBody";
+
+const TableFooter = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+ <tfoot
+ ref={ref}
+ className={cn(
+ "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
+ className,
+ )}
+ {...props}
+ />
+));
+TableFooter.displayName = "TableFooter";
+
+const TableRow = React.forwardRef<
+ HTMLTableRowElement,
+ React.HTMLAttributes<HTMLTableRowElement>
+>(({ className, ...props }, ref) => (
+ <tr
+ ref={ref}
+ className={cn(
+ "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
+ className,
+ )}
+ {...props}
+ />
+));
+TableRow.displayName = "TableRow";
+
+const TableHead = React.forwardRef<
+ HTMLTableCellElement,
+ React.ThHTMLAttributes<HTMLTableCellElement>
+>(({ className, ...props }, ref) => (
+ <th
+ ref={ref}
+ className={cn(
+ "text-muted-foreground h-12 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
+ className,
+ )}
+ {...props}
+ />
+));
+TableHead.displayName = "TableHead";
+
+const TableCell = React.forwardRef<
+ HTMLTableCellElement,
+ React.TdHTMLAttributes<HTMLTableCellElement>
+>(({ className, ...props }, ref) => (
+ <td
+ ref={ref}
+ className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
+ {...props}
+ />
+));
+TableCell.displayName = "TableCell";
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes<HTMLTableCaptionElement>
+>(({ className, ...props }, ref) => (
+ <caption
+ ref={ref}
+ className={cn("text-muted-foreground mt-4 text-sm", className)}
+ {...props}
+ />
+));
+TableCaption.displayName = "TableCaption";
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+};
diff --git a/apps/web/components/ui/tabs.tsx b/apps/web/components/ui/tabs.tsx
new file mode 100644
index 00000000..990017db
--- /dev/null
+++ b/apps/web/components/ui/tabs.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import * as React from "react";
+import * as TabsPrimitive from "@radix-ui/react-tabs";
+
+import { cn } from "@/lib/utils";
+
+const Tabs = TabsPrimitive.Root;
+
+const TabsList = React.forwardRef<
+ React.ElementRef<typeof TabsPrimitive.List>,
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
+>(({ className, ...props }, ref) => (
+ <TabsPrimitive.List
+ ref={ref}
+ className={cn(
+ "bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1",
+ className,
+ )}
+ {...props}
+ />
+));
+TabsList.displayName = TabsPrimitive.List.displayName;
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef<typeof TabsPrimitive.Trigger>,
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
+>(({ className, ...props }, ref) => (
+ <TabsPrimitive.Trigger
+ ref={ref}
+ className={cn(
+ "ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm",
+ className,
+ )}
+ {...props}
+ />
+));
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
+
+const TabsContent = React.forwardRef<
+ React.ElementRef<typeof TabsPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
+>(({ className, ...props }, ref) => (
+ <TabsPrimitive.Content
+ ref={ref}
+ className={cn(
+ "ring-offset-background focus-visible:ring-ring mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
+ className,
+ )}
+ {...props}
+ />
+));
+TabsContent.displayName = TabsPrimitive.Content.displayName;
+
+export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/apps/web/components/ui/textarea.tsx b/apps/web/components/ui/textarea.tsx
new file mode 100644
index 00000000..a0de3371
--- /dev/null
+++ b/apps/web/components/ui/textarea.tsx
@@ -0,0 +1,24 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+export interface TextareaProps
+ extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
+
+const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
+ ({ className, ...props }, ref) => {
+ return (
+ <textarea
+ className={cn(
+ "border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ ref={ref}
+ {...props}
+ />
+ );
+ },
+);
+Textarea.displayName = "Textarea";
+
+export { Textarea };
diff --git a/apps/web/components/ui/toast.tsx b/apps/web/components/ui/toast.tsx
new file mode 100644
index 00000000..0d162dca
--- /dev/null
+++ b/apps/web/components/ui/toast.tsx
@@ -0,0 +1,127 @@
+import * as React from "react";
+import * as ToastPrimitives from "@radix-ui/react-toast";
+import { cva, type VariantProps } from "class-variance-authority";
+import { X } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const ToastProvider = ToastPrimitives.Provider;
+
+const ToastViewport = React.forwardRef<
+ React.ElementRef<typeof ToastPrimitives.Viewport>,
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
+>(({ className, ...props }, ref) => (
+ <ToastPrimitives.Viewport
+ ref={ref}
+ className={cn(
+ "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
+ className,
+ )}
+ {...props}
+ />
+));
+ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
+
+const toastVariants = cva(
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground border",
+ destructive:
+ "destructive border-destructive bg-destructive text-destructive-foreground group",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+const Toast = React.forwardRef<
+ React.ElementRef<typeof ToastPrimitives.Root>,
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
+ VariantProps<typeof toastVariants>
+>(({ className, variant, ...props }, ref) => {
+ return (
+ <ToastPrimitives.Root
+ ref={ref}
+ className={cn(toastVariants({ variant }), className)}
+ {...props}
+ />
+ );
+});
+Toast.displayName = ToastPrimitives.Root.displayName;
+
+const ToastAction = React.forwardRef<
+ React.ElementRef<typeof ToastPrimitives.Action>,
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
+>(({ className, ...props }, ref) => (
+ <ToastPrimitives.Action
+ ref={ref}
+ className={cn(
+ "ring-offset-background hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ />
+));
+ToastAction.displayName = ToastPrimitives.Action.displayName;
+
+const ToastClose = React.forwardRef<
+ React.ElementRef<typeof ToastPrimitives.Close>,
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
+>(({ className, ...props }, ref) => (
+ <ToastPrimitives.Close
+ ref={ref}
+ className={cn(
+ "text-foreground/50 hover:text-foreground absolute right-2 top-2 rounded-md p-1 opacity-0 transition-opacity focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
+ className,
+ )}
+ toast-close=""
+ {...props}
+ >
+ <X className="size-4" />
+ </ToastPrimitives.Close>
+));
+ToastClose.displayName = ToastPrimitives.Close.displayName;
+
+const ToastTitle = React.forwardRef<
+ React.ElementRef<typeof ToastPrimitives.Title>,
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
+>(({ className, ...props }, ref) => (
+ <ToastPrimitives.Title
+ ref={ref}
+ className={cn("text-sm font-semibold", className)}
+ {...props}
+ />
+));
+ToastTitle.displayName = ToastPrimitives.Title.displayName;
+
+const ToastDescription = React.forwardRef<
+ React.ElementRef<typeof ToastPrimitives.Description>,
+ React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
+>(({ className, ...props }, ref) => (
+ <ToastPrimitives.Description
+ ref={ref}
+ className={cn("text-sm opacity-90", className)}
+ {...props}
+ />
+));
+ToastDescription.displayName = ToastPrimitives.Description.displayName;
+
+type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
+
+type ToastActionElement = React.ReactElement<typeof ToastAction>;
+
+export {
+ type ToastProps,
+ type ToastActionElement,
+ ToastProvider,
+ ToastViewport,
+ Toast,
+ ToastTitle,
+ ToastDescription,
+ ToastClose,
+ ToastAction,
+};
diff --git a/apps/web/components/ui/toaster.tsx b/apps/web/components/ui/toaster.tsx
new file mode 100644
index 00000000..7d82ed55
--- /dev/null
+++ b/apps/web/components/ui/toaster.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import {
+ Toast,
+ ToastClose,
+ ToastDescription,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+} from "@/components/ui/toast";
+import { useToast } from "@/components/ui/use-toast";
+
+export function Toaster() {
+ const { toasts } = useToast();
+
+ return (
+ <ToastProvider>
+ {toasts.map(function ({ id, title, description, action, ...props }) {
+ return (
+ <Toast key={id} {...props}>
+ <div className="grid gap-1">
+ {title && <ToastTitle>{title}</ToastTitle>}
+ {description && (
+ <ToastDescription>{description}</ToastDescription>
+ )}
+ </div>
+ {action}
+ <ToastClose />
+ </Toast>
+ );
+ })}
+ <ToastViewport />
+ </ToastProvider>
+ );
+}
diff --git a/apps/web/components/ui/use-toast.ts b/apps/web/components/ui/use-toast.ts
new file mode 100644
index 00000000..5491e140
--- /dev/null
+++ b/apps/web/components/ui/use-toast.ts
@@ -0,0 +1,189 @@
+// Inspired by react-hot-toast library
+import * as React from "react";
+
+import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
+
+const TOAST_LIMIT = 1;
+const TOAST_REMOVE_DELAY = 1000000;
+
+type ToasterToast = ToastProps & {
+ id: string;
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ action?: ToastActionElement;
+};
+
+const actionTypes = {
+ ADD_TOAST: "ADD_TOAST",
+ UPDATE_TOAST: "UPDATE_TOAST",
+ DISMISS_TOAST: "DISMISS_TOAST",
+ REMOVE_TOAST: "REMOVE_TOAST",
+} as const;
+
+let count = 0;
+
+function genId() {
+ count = (count + 1) % Number.MAX_SAFE_INTEGER;
+ return count.toString();
+}
+
+type ActionType = typeof actionTypes;
+
+type Action =
+ | {
+ type: ActionType["ADD_TOAST"];
+ toast: ToasterToast;
+ }
+ | {
+ type: ActionType["UPDATE_TOAST"];
+ toast: Partial<ToasterToast>;
+ }
+ | {
+ type: ActionType["DISMISS_TOAST"];
+ toastId?: ToasterToast["id"];
+ }
+ | {
+ type: ActionType["REMOVE_TOAST"];
+ toastId?: ToasterToast["id"];
+ };
+
+interface State {
+ toasts: ToasterToast[];
+}
+
+const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
+
+const addToRemoveQueue = (toastId: string) => {
+ if (toastTimeouts.has(toastId)) {
+ return;
+ }
+
+ const timeout = setTimeout(() => {
+ toastTimeouts.delete(toastId);
+ dispatch({
+ type: "REMOVE_TOAST",
+ toastId: toastId,
+ });
+ }, TOAST_REMOVE_DELAY);
+
+ toastTimeouts.set(toastId, timeout);
+};
+
+export const reducer = (state: State, action: Action): State => {
+ switch (action.type) {
+ case "ADD_TOAST":
+ return {
+ ...state,
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
+ };
+
+ case "UPDATE_TOAST":
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === action.toast.id ? { ...t, ...action.toast } : t,
+ ),
+ };
+
+ case "DISMISS_TOAST": {
+ const { toastId } = action;
+
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
+ // but I'll keep it here for simplicity
+ if (toastId) {
+ addToRemoveQueue(toastId);
+ } else {
+ state.toasts.forEach((toast) => {
+ addToRemoveQueue(toast.id);
+ });
+ }
+
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === toastId || toastId === undefined
+ ? {
+ ...t,
+ open: false,
+ }
+ : t,
+ ),
+ };
+ }
+ case "REMOVE_TOAST":
+ if (action.toastId === undefined) {
+ return {
+ ...state,
+ toasts: [],
+ };
+ }
+ return {
+ ...state,
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
+ };
+ }
+};
+
+const listeners: Array<(_state: State) => void> = [];
+
+let memoryState: State = { toasts: [] };
+
+function dispatch(action: Action) {
+ memoryState = reducer(memoryState, action);
+ listeners.forEach((listener) => {
+ listener(memoryState);
+ });
+}
+
+type Toast = Omit<ToasterToast, "id">;
+
+function toast({ ...props }: Toast) {
+ const id = genId();
+
+ const update = (props: ToasterToast) =>
+ dispatch({
+ type: "UPDATE_TOAST",
+ toast: { ...props, id },
+ });
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
+
+ dispatch({
+ type: "ADD_TOAST",
+ toast: {
+ ...props,
+ id,
+ open: true,
+ onOpenChange: (open) => {
+ if (!open) dismiss();
+ },
+ },
+ });
+
+ return {
+ id: id,
+ dismiss,
+ update,
+ };
+}
+
+function useToast() {
+ const [state, setState] = React.useState<State>(memoryState);
+
+ React.useEffect(() => {
+ listeners.push(setState);
+ return () => {
+ const index = listeners.indexOf(setState);
+ if (index > -1) {
+ listeners.splice(index, 1);
+ }
+ };
+ }, [state]);
+
+ return {
+ ...state,
+ toast,
+ dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
+ };
+}
+
+export { useToast, toast };
diff --git a/apps/web/lib/bookmarkUtils.tsx b/apps/web/lib/bookmarkUtils.tsx
new file mode 100644
index 00000000..a2828c29
--- /dev/null
+++ b/apps/web/lib/bookmarkUtils.tsx
@@ -0,0 +1,22 @@
+import { ZBookmark } from "@hoarder/trpc/types/bookmarks";
+
+const MAX_LOADING_MSEC = 30 * 1000;
+
+export function isBookmarkStillCrawling(bookmark: ZBookmark) {
+ return (
+ bookmark.content.type == "link" &&
+ !bookmark.content.crawledAt &&
+ Date.now().valueOf() - bookmark.createdAt.valueOf() < MAX_LOADING_MSEC
+ );
+}
+
+export function isBookmarkStillTagging(bookmark: ZBookmark) {
+ return (
+ bookmark.taggingStatus == "pending" &&
+ Date.now().valueOf() - bookmark.createdAt.valueOf() < MAX_LOADING_MSEC
+ );
+}
+
+export function isBookmarkStillLoading(bookmark: ZBookmark) {
+ return isBookmarkStillTagging(bookmark) || isBookmarkStillCrawling(bookmark);
+}
diff --git a/apps/web/lib/hooks/bookmark-search.ts b/apps/web/lib/hooks/bookmark-search.ts
new file mode 100644
index 00000000..738e1bd8
--- /dev/null
+++ b/apps/web/lib/hooks/bookmark-search.ts
@@ -0,0 +1,73 @@
+import { useEffect, useState } from "react";
+import { api } from "@/lib/trpc";
+import { useRouter, useSearchParams } from "next/navigation";
+import { keepPreviousData } from "@tanstack/react-query";
+
+function useSearchQuery() {
+ const searchParams = useSearchParams();
+ const searchQuery = searchParams.get("q") || "";
+ return { searchQuery };
+}
+
+export function useDoBookmarkSearch() {
+ const router = useRouter();
+ const { searchQuery } = useSearchQuery();
+ const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | undefined>();
+
+ useEffect(() => {
+ return () => {
+ if (!timeoutId) {
+ return;
+ }
+ clearTimeout(timeoutId);
+ };
+ }, [timeoutId]);
+
+ const doSearch = (val: string) => {
+ setTimeoutId(undefined);
+ router.replace(`/dashboard/search?q=${val}`);
+ };
+
+ const debounceSearch = (val: string) => {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ const id = setTimeout(() => {
+ doSearch(val);
+ }, 200);
+ setTimeoutId(id);
+ };
+
+ return {
+ doSearch,
+ debounceSearch,
+ searchQuery,
+ };
+}
+
+export function useBookmarkSearch() {
+ const { searchQuery } = useSearchQuery();
+
+ const { data, isPending, isPlaceholderData, error } =
+ api.bookmarks.searchBookmarks.useQuery(
+ {
+ text: searchQuery,
+ },
+ {
+ placeholderData: keepPreviousData,
+ gcTime: 0,
+ },
+ );
+
+ if (error) {
+ throw error;
+ }
+
+ return {
+ searchQuery,
+ error,
+ data,
+ isPending,
+ isPlaceholderData,
+ };
+}
diff --git a/apps/web/lib/providers.tsx b/apps/web/lib/providers.tsx
new file mode 100644
index 00000000..5c4649b5
--- /dev/null
+++ b/apps/web/lib/providers.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import React, { useState } from "react";
+import { api } from "./trpc";
+import { loggerLink } from "@trpc/client";
+import { httpBatchLink } from "@trpc/client";
+import superjson from "superjson";
+import { SessionProvider } from "next-auth/react";
+import { Session } from "next-auth";
+
+function makeQueryClient() {
+ return new QueryClient({
+ defaultOptions: {
+ queries: {
+ // With SSR, we usually want to set some default staleTime
+ // above 0 to avoid refetching immediately on the client
+ staleTime: 60 * 1000,
+ },
+ },
+ });
+}
+
+let browserQueryClient: QueryClient | undefined = undefined;
+
+function getQueryClient() {
+ if (typeof window === "undefined") {
+ // Server: always make a new query client
+ return makeQueryClient();
+ } else {
+ // Browser: make a new query client if we don't already have one
+ // This is very important so we don't re-make a new client if React
+ // supsends during the initial render. This may not be needed if we
+ // have a suspense boundary BELOW the creation of the query client
+ if (!browserQueryClient) browserQueryClient = makeQueryClient();
+ return browserQueryClient;
+ }
+}
+
+export default function Providers({
+ children,
+ session,
+}: {
+ children: React.ReactNode;
+ session: Session | null;
+}) {
+ const queryClient = getQueryClient();
+
+ const [trpcClient] = useState(() =>
+ api.createClient({
+ links: [
+ loggerLink({
+ enabled: (op) =>
+ process.env.NODE_ENV === "development" ||
+ (op.direction === "down" && op.result instanceof Error),
+ }),
+ httpBatchLink({
+ // TODO: Change this to be a full URL exposed as a client side setting
+ url: `/api/trpc`,
+ transformer: superjson,
+ }),
+ ],
+ }),
+ );
+
+ return (
+ <SessionProvider session={session}>
+ <api.Provider client={trpcClient} queryClient={queryClient}>
+ <QueryClientProvider client={queryClient}>
+ {children}
+ </QueryClientProvider>
+ </api.Provider>
+ </SessionProvider>
+ );
+}
diff --git a/apps/web/lib/trpc.tsx b/apps/web/lib/trpc.tsx
new file mode 100644
index 00000000..79a2a9fe
--- /dev/null
+++ b/apps/web/lib/trpc.tsx
@@ -0,0 +1,5 @@
+"use client";
+import type { AppRouter } from "@hoarder/trpc/routers/_app";
+import { createTRPCReact } from "@trpc/react-query";
+
+export const api = createTRPCReact<AppRouter>();
diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts
new file mode 100644
index 00000000..365058ce
--- /dev/null
+++ b/apps/web/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs
new file mode 100644
index 00000000..fa0757dd
--- /dev/null
+++ b/apps/web/next.config.mjs
@@ -0,0 +1,53 @@
+import pwa from "next-pwa";
+
+const withPWA = pwa({
+ dest: "public",
+ disable: process.env.NODE_ENV != "production",
+});
+
+/** @type {import('next').NextConfig} */
+const nextConfig = withPWA({
+ output: "standalone",
+ async headers() {
+ return [
+ {
+ // Routes this applies to
+ source: "/api/(.*)",
+ // Headers
+ headers: [
+ // Allow for specific domains to have access or * for all
+ {
+ key: "Access-Control-Allow-Origin",
+ value: "chrome-extension://olmdabfolepgfmjhmikngmfekcdgjinp",
+ },
+ // Allows for specific methods accepted
+ {
+ key: "Access-Control-Allow-Methods",
+ value: "GET, POST, PUT, DELETE, OPTIONS",
+ },
+ // Allows for specific headers accepted (These are a few standard ones)
+ {
+ key: "Access-Control-Allow-Headers",
+ value: "Content-Type, Authorization",
+ },
+ {
+ key: "Access-Control-Allow-Credentials",
+ value: "true",
+ },
+ ],
+ },
+ ];
+ },
+
+ transpilePackages: [
+ "@hoarder/shared",
+ "@hoarder/db",
+ "@hoarder/trpc",
+ ],
+
+ /** We already do linting and typechecking as separate tasks in CI */
+ eslint: { ignoreDuringBuilds: true },
+ typescript: { ignoreBuildErrors: true },
+});
+
+export default nextConfig;
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 00000000..28708c6c
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,89 @@
+{
+ "$schema": "https://json.schemastore.org/package.json",
+ "name": "@hoarder/web",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "next dev",
+ "clean": "git clean -xdf .next .turbo node_modules",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "test": "vitest",
+ "typecheck": "tsc --noEmit",
+ "format": "prettier --check . --ignore-path ../../.gitignore"
+ },
+ "dependencies": {
+ "@auth/drizzle-adapter": "^0.8.0",
+ "@emoji-mart/data": "^1.1.2",
+ "@emoji-mart/react": "^1.1.1",
+ "@hoarder/db": "workspace:^0.1.0",
+ "@hoarder/shared": "workspace:^0.1.0",
+ "@hoarder/trpc": "workspace:^0.1.0",
+ "@hookform/resolvers": "^3.3.4",
+ "@radix-ui/react-dialog": "^1.0.5",
+ "@radix-ui/react-dropdown-menu": "^2.0.6",
+ "@radix-ui/react-label": "^2.0.2",
+ "@radix-ui/react-popover": "^1.0.7",
+ "@radix-ui/react-scroll-area": "^1.0.5",
+ "@radix-ui/react-select": "^2.0.0",
+ "@radix-ui/react-separator": "^1.0.3",
+ "@radix-ui/react-slot": "^1.0.2",
+ "@radix-ui/react-tabs": "^1.0.4",
+ "@radix-ui/react-toast": "^1.1.5",
+ "@tanstack/react-query": "^5.24.8",
+ "@tanstack/react-query-devtools": "^5.21.0",
+ "@trpc/client": "11.0.0-next-beta.308",
+ "@trpc/next": "11.0.0-next-beta.308",
+ "@trpc/react-query": "11.0.0-next-beta.308",
+ "@trpc/server": "11.0.0-next-beta.308",
+ "better-sqlite3": "^9.4.3",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.0",
+ "drizzle-orm": "^0.29.4",
+ "install": "^0.13.0",
+ "lucide-react": "^0.330.0",
+ "meilisearch": "^0.37.0",
+ "next": "14.1.1",
+ "next-auth": "^4.24.5",
+ "next-pwa": "^5.6.0",
+ "prettier": "^3.2.5",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-hook-form": "^7.50.1",
+ "react-markdown": "^9.0.1",
+ "react-masonry-css": "^1.0.16",
+ "server-only": "^0.0.1",
+ "superjson": "^2.2.1",
+ "tailwind-merge": "^2.2.1",
+ "tailwindcss-animate": "^1.0.7",
+ "zod": "^3.22.4",
+ "zustand": "^4.5.1"
+ },
+ "devDependencies": {
+ "@hoarder/eslint-config": "workspace:^0.2.0",
+ "@hoarder/prettier-config": "workspace:^0.1.0",
+ "@hoarder/tailwind-config": "workspace:^0.1.0",
+ "@hoarder/tsconfig": "workspace:^0.1.0",
+ "@tailwindcss/typography": "^0.5.10",
+ "@types/emoji-mart": "^3.0.14",
+ "@types/react": "^18.2.55",
+ "@types/react-dom": "^18.2.19",
+ "autoprefixer": "^10.4.17",
+ "postcss": "^8.4.35",
+ "tailwindcss": "^3.4.1",
+ "ts-node": "^10.9.2",
+ "vite-tsconfig-paths": "^4.3.1",
+ "vitest": "^1.3.1"
+ },
+ "eslintConfig": {
+ "root": true,
+ "extends": [
+ "@hoarder/eslint-config/base",
+ "@hoarder/eslint-config/nextjs",
+ "@hoarder/eslint-config/react"
+ ]
+ },
+ "prettier": "@hoarder/prettier-config"
+}
diff --git a/apps/web/postcss.config.cjs b/apps/web/postcss.config.cjs
new file mode 100644
index 00000000..12a703d9
--- /dev/null
+++ b/apps/web/postcss.config.cjs
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/apps/web/public/blur.avif b/apps/web/public/blur.avif
new file mode 100644
index 00000000..cbc6cd37
--- /dev/null
+++ b/apps/web/public/blur.avif
Binary files differ
diff --git a/apps/web/public/icons/logo-128.png b/apps/web/public/icons/logo-128.png
new file mode 100644
index 00000000..71ead90c
--- /dev/null
+++ b/apps/web/public/icons/logo-128.png
Binary files differ
diff --git a/apps/web/public/icons/logo-16.png b/apps/web/public/icons/logo-16.png
new file mode 100644
index 00000000..dd864d44
--- /dev/null
+++ b/apps/web/public/icons/logo-16.png
Binary files differ
diff --git a/apps/web/public/icons/logo-48.png b/apps/web/public/icons/logo-48.png
new file mode 100644
index 00000000..7ba1cd49
--- /dev/null
+++ b/apps/web/public/icons/logo-48.png
Binary files differ
diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json
new file mode 100644
index 00000000..b42343f6
--- /dev/null
+++ b/apps/web/public/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Hoarder",
+ "short_name": "Hoarder",
+ "icons": [
+ {
+ "src": "/icons/logo-16.png",
+ "sizes": "16x16",
+ "type": "image/png",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "/icons/logo-48.png",
+ "sizes": "48x48",
+ "type": "image/png"
+ },
+ {
+ "src": "/icons/logo-128.png",
+ "sizes": "128x128",
+ "type": "image/png"
+ }
+ ],
+ "start_url": "/",
+ "display": "standalone",
+ "orientation": "portrait"
+}
diff --git a/apps/web/server/api/client.ts b/apps/web/server/api/client.ts
new file mode 100644
index 00000000..88ea7a0e
--- /dev/null
+++ b/apps/web/server/api/client.ts
@@ -0,0 +1,16 @@
+import { appRouter } from "@hoarder/trpc/routers/_app";
+import { getServerAuthSession } from "@/server/auth";
+import { Context, createCallerFactory } from "@hoarder/trpc";
+import { db } from "@hoarder/db";
+
+export const createContext = async (database?: typeof db): Promise<Context> => {
+ const session = await getServerAuthSession();
+ return {
+ user: session?.user ?? null,
+ db: database ?? db,
+ };
+};
+
+const createCaller = createCallerFactory(appRouter);
+
+export const api = createCaller(createContext);
diff --git a/apps/web/server/auth.ts b/apps/web/server/auth.ts
new file mode 100644
index 00000000..950443b9
--- /dev/null
+++ b/apps/web/server/auth.ts
@@ -0,0 +1,96 @@
+import NextAuth, { NextAuthOptions, getServerSession } from "next-auth";
+import type { Adapter } from "next-auth/adapters";
+import AuthentikProvider from "next-auth/providers/authentik";
+import serverConfig from "@hoarder/shared/config";
+import { validatePassword } from "@hoarder/trpc/auth";
+import { db } from "@hoarder/db";
+import { DefaultSession } from "next-auth";
+import CredentialsProvider from "next-auth/providers/credentials";
+import { DrizzleAdapter } from "@auth/drizzle-adapter";
+
+import { Provider } from "next-auth/providers/index";
+
+declare module "next-auth/jwt" {
+ export interface JWT {
+ user: {
+ id: string;
+ role: "admin" | "user";
+ } & DefaultSession["user"];
+ }
+}
+
+declare module "next-auth" {
+ /**
+ * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
+ */
+ export interface Session {
+ user: {
+ id: string;
+ role: "admin" | "user";
+ } & DefaultSession["user"];
+ }
+
+ export interface DefaultUser {
+ role: "admin" | "user" | null;
+ }
+}
+
+const providers: Provider[] = [
+ CredentialsProvider({
+ // The name to display on the sign in form (e.g. "Sign in with...")
+ name: "Credentials",
+ credentials: {
+ email: { label: "Email", type: "email", placeholder: "Email" },
+ password: { label: "Password", type: "password" },
+ },
+ async authorize(credentials) {
+ if (!credentials) {
+ return null;
+ }
+
+ try {
+ return await validatePassword(
+ credentials?.email,
+ credentials?.password,
+ );
+ } catch (e) {
+ return null;
+ }
+ },
+ }),
+];
+
+if (serverConfig.auth.authentik) {
+ providers.push(AuthentikProvider(serverConfig.auth.authentik));
+}
+
+export const authOptions: NextAuthOptions = {
+ // https://github.com/nextauthjs/next-auth/issues/9493
+ adapter: DrizzleAdapter(db) as Adapter,
+ providers: providers,
+ session: {
+ strategy: "jwt",
+ },
+ callbacks: {
+ async jwt({ token, user }) {
+ if (user) {
+ token.user = {
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ image: user.image,
+ role: user.role || "user",
+ };
+ }
+ return token;
+ },
+ async session({ session, token }) {
+ session.user = { ...token.user };
+ return session;
+ },
+ },
+};
+
+export const authHandler = NextAuth(authOptions);
+
+export const getServerAuthSession = () => getServerSession(authOptions);
diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts
new file mode 100644
index 00000000..521ba51c
--- /dev/null
+++ b/apps/web/tailwind.config.ts
@@ -0,0 +1,89 @@
+import type { Config } from "tailwindcss";
+
+const config = {
+ darkMode: ["class"],
+ content: [
+ "./pages/**/*.{ts,tsx}",
+ "./components/**/*.{ts,tsx}",
+ "./app/**/*.{ts,tsx}",
+ "./src/**/*.{ts,tsx}",
+ ],
+ prefix: "",
+ theme: {
+ container: {
+ center: true,
+ padding: "2rem",
+ screens: {
+ "2xl": "1400px",
+ },
+ },
+ extend: {
+ colors: {
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ keyframes: {
+ "accordion-down": {
+ from: { height: "0" },
+ to: { height: "var(--radix-accordion-content-height)" },
+ },
+ "accordion-up": {
+ from: { height: "var(--radix-accordion-content-height)" },
+ to: { height: "0" },
+ },
+ "pulse-border": {
+ "0%, 100%": {
+ "box-shadow": "0 0 0 0 gray",
+ },
+ "50%": {
+ "box-shadow": "0 0 0 2px gray",
+ },
+ },
+ },
+ animation: {
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ "pulse-border": "pulse-border 1s ease-in-out infinite",
+ },
+ },
+ },
+ plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
+} satisfies Config;
+
+export default config;
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 00000000..db90cf17
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "@hoarder/tsconfig/base.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts
new file mode 100644
index 00000000..c3d02f71
--- /dev/null
+++ b/apps/web/vitest.config.ts
@@ -0,0 +1,14 @@
+/// <reference types="vitest" />
+
+import { defineConfig } from "vitest/config";
+import tsconfigPaths from "vite-tsconfig-paths";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [tsconfigPaths()],
+ test: {
+ alias: {
+ "@/*": "./*",
+ },
+ },
+});
diff --git a/apps/workers/crawlerWorker.ts b/apps/workers/crawlerWorker.ts
new file mode 100644
index 00000000..5db2da7b
--- /dev/null
+++ b/apps/workers/crawlerWorker.ts
@@ -0,0 +1,201 @@
+import logger from "@hoarder/shared/logger";
+import {
+ LinkCrawlerQueue,
+ OpenAIQueue,
+ SearchIndexingQueue,
+ ZCrawlLinkRequest,
+ queueConnectionDetails,
+ zCrawlLinkRequestSchema,
+} from "@hoarder/shared/queues";
+import DOMPurify from "dompurify";
+import { JSDOM } from "jsdom";
+
+import { Worker } from "bullmq";
+import { Job } from "bullmq";
+
+import { db } from "@hoarder/db";
+
+import { Browser } from "puppeteer";
+import puppeteer from "puppeteer-extra";
+import StealthPlugin from "puppeteer-extra-plugin-stealth";
+import AdblockerPlugin from "puppeteer-extra-plugin-adblocker";
+
+import metascraper from "metascraper";
+
+import metascraperDescription from "metascraper-description";
+import metascraperImage from "metascraper-image";
+import metascraperLogo from "metascraper-logo-favicon";
+import metascraperTitle from "metascraper-title";
+import metascraperUrl from "metascraper-url";
+import metascraperTwitter from "metascraper-twitter";
+import metascraperReadability from "metascraper-readability";
+import { Mutex } from "async-mutex";
+import assert from "assert";
+import serverConfig from "@hoarder/shared/config";
+import { bookmarkLinks } from "@hoarder/db/schema";
+import { eq } from "drizzle-orm";
+import { Readability } from "@mozilla/readability";
+
+const metascraperParser = metascraper([
+ metascraperReadability(),
+ metascraperTitle(),
+ metascraperDescription(),
+ metascraperTwitter(),
+ metascraperImage(),
+ metascraperLogo(),
+ metascraperUrl(),
+]);
+
+let browser: Browser | undefined;
+// Guards the interactions with the browser instance.
+// This is needed given that most of the browser APIs are async.
+const browserMutex = new Mutex();
+
+async function launchBrowser() {
+ browser = undefined;
+ await browserMutex.runExclusive(async () => {
+ browser = await puppeteer.launch({
+ headless: serverConfig.crawler.headlessBrowser,
+ executablePath: serverConfig.crawler.browserExecutablePath,
+ userDataDir: serverConfig.crawler.browserUserDataDir,
+ });
+ browser.on("disconnected", async () => {
+ logger.info(
+ "The puppeteer browser got disconnected. Will attempt to launch it again.",
+ );
+ await launchBrowser();
+ });
+ });
+}
+
+export class CrawlerWorker {
+ static async build() {
+ puppeteer.use(StealthPlugin());
+ puppeteer.use(
+ AdblockerPlugin({
+ blockTrackersAndAnnoyances: true,
+ }),
+ );
+ await launchBrowser();
+
+ logger.info("Starting crawler worker ...");
+ const worker = new Worker<ZCrawlLinkRequest, void>(
+ LinkCrawlerQueue.name,
+ runCrawler,
+ {
+ connection: queueConnectionDetails,
+ autorun: false,
+ },
+ );
+
+ worker.on("completed", (job) => {
+ const jobId = job?.id || "unknown";
+ logger.info(`[Crawler][${jobId}] Completed successfully`);
+ });
+
+ worker.on("failed", (job, error) => {
+ const jobId = job?.id || "unknown";
+ logger.error(`[Crawler][${jobId}] Crawling job failed: ${error}`);
+ });
+
+ return worker;
+ }
+}
+
+async function getBookmarkUrl(bookmarkId: string) {
+ const bookmark = await db.query.bookmarkLinks.findFirst({
+ where: eq(bookmarkLinks.id, bookmarkId),
+ });
+
+ if (!bookmark) {
+ throw new Error("The bookmark either doesn't exist or not a link");
+ }
+ return bookmark.url;
+}
+
+async function crawlPage(url: string) {
+ assert(browser);
+ const context = await browser.createBrowserContext();
+
+ try {
+ const page = await context.newPage();
+
+ await page.goto(url, {
+ timeout: 10000, // 10 seconds
+ });
+
+ // Wait until there's at most two connections for 2 seconds
+ // Attempt to wait only for 5 seconds
+ await Promise.race([
+ page.waitForNetworkIdle({
+ idleTime: 1000, // 1 sec
+ concurrency: 2,
+ }),
+ new Promise((f) => setTimeout(f, 5000)),
+ ]);
+
+ const htmlContent = await page.content();
+ return htmlContent;
+ } finally {
+ await context.close();
+ }
+}
+
+async function runCrawler(job: Job<ZCrawlLinkRequest, void>) {
+ const jobId = job.id || "unknown";
+
+ const request = zCrawlLinkRequestSchema.safeParse(job.data);
+ if (!request.success) {
+ logger.error(
+ `[Crawler][${jobId}] Got malformed job request: ${request.error.toString()}`,
+ );
+ return;
+ }
+
+ const { bookmarkId } = request.data;
+ const url = await getBookmarkUrl(bookmarkId);
+
+ logger.info(
+ `[Crawler][${jobId}] Will crawl "${url}" for link with id "${bookmarkId}"`,
+ );
+ // TODO(IMPORTANT): Run security validations on the input URL (e.g. deny localhost, etc)
+
+ const htmlContent = await crawlPage(url);
+
+ const meta = await metascraperParser({
+ url,
+ html: htmlContent,
+ });
+
+ const window = new JSDOM("").window;
+ const purify = DOMPurify(window);
+ const purifiedHTML = purify.sanitize(htmlContent);
+ const purifiedDOM = new JSDOM(purifiedHTML, { url });
+ const readableContent = new Readability(purifiedDOM.window.document).parse();
+
+ // TODO(important): Restrict the size of content to store
+
+ await db
+ .update(bookmarkLinks)
+ .set({
+ title: meta.title,
+ description: meta.description,
+ imageUrl: meta.image,
+ favicon: meta.logo,
+ content: readableContent?.textContent,
+ htmlContent: readableContent?.content,
+ crawledAt: new Date(),
+ })
+ .where(eq(bookmarkLinks.id, bookmarkId));
+
+ // Enqueue openai job
+ OpenAIQueue.add("openai", {
+ bookmarkId,
+ });
+
+ // Update the search index
+ SearchIndexingQueue.add("search_indexing", {
+ bookmarkId,
+ type: "index",
+ });
+}
diff --git a/apps/workers/index.ts b/apps/workers/index.ts
new file mode 100644
index 00000000..5b6b62d5
--- /dev/null
+++ b/apps/workers/index.ts
@@ -0,0 +1,16 @@
+import "dotenv/config";
+import { CrawlerWorker } from "./crawlerWorker";
+import { OpenAiWorker } from "./openaiWorker";
+import { SearchIndexingWorker } from "./searchWorker";
+
+async function main() {
+ const [crawler, openai, search] = [
+ await CrawlerWorker.build(),
+ await OpenAiWorker.build(),
+ await SearchIndexingWorker.build(),
+ ];
+
+ await Promise.all([crawler.run(), openai.run(), search.run()]);
+}
+
+main();
diff --git a/apps/workers/openaiWorker.ts b/apps/workers/openaiWorker.ts
new file mode 100644
index 00000000..1ec22d32
--- /dev/null
+++ b/apps/workers/openaiWorker.ts
@@ -0,0 +1,263 @@
+import { db } from "@hoarder/db";
+import logger from "@hoarder/shared/logger";
+import serverConfig from "@hoarder/shared/config";
+import {
+ OpenAIQueue,
+ 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()),
+});
+
+async function attemptMarkTaggingStatus(
+ jobData: object | undefined,
+ status: "success" | "failure",
+) {
+ if (!jobData) {
+ return;
+ }
+ try {
+ const request = zOpenAIRequestSchema.parse(jobData);
+ await db
+ .update(bookmarks)
+ .set({
+ taggingStatus: status,
+ })
+ .where(eq(bookmarks.id, request.bookmarkId));
+ } catch (e) {
+ console.log(`Something went wrong when marking the tagging status: ${e}`);
+ }
+}
+
+export class OpenAiWorker {
+ static async build() {
+ logger.info("Starting openai worker ...");
+ const worker = new Worker<ZOpenAIRequest, void>(
+ OpenAIQueue.name,
+ runOpenAI,
+ {
+ connection: queueConnectionDetails,
+ autorun: false,
+ },
+ );
+
+ worker.on("completed", async (job) => {
+ const jobId = job?.id || "unknown";
+ logger.info(`[openai][${jobId}] Completed successfully`);
+ await attemptMarkTaggingStatus(job?.data, "success");
+ });
+
+ worker.on("failed", async (job, error) => {
+ const jobId = job?.id || "unknown";
+ logger.error(`[openai][${jobId}] openai job failed: ${error}`);
+ await attemptMarkTaggingStatus(job?.data, "failure");
+ });
+
+ return worker;
+ }
+}
+
+const 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
+you may also include a tag for the website. Tags should be lowercases and don't contain spaces. If the tag is not generic enough, don't
+include it. Aim for 3-5 tags. If there are no good tags, don't emit any. The content can include text for cookie consent and privacy policy, ignore those while tagging.
+You must respond in JSON with the key "tags" and the value is list of tags.
+CONTENT START HERE:
+`;
+
+function buildPrompt(
+ bookmark: NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>,
+) {
+ if (bookmark.link) {
+ if (!bookmark.link.description && !bookmark.link.content) {
+ throw new Error(
+ `No content found for link "${bookmark.id}". Skipping ...`,
+ );
+ }
+
+ let content = bookmark.link.content;
+ if (content) {
+ let words = content.split(" ");
+ if (words.length > 2000) {
+ words = words.slice(2000);
+ content = words.join(" ");
+ }
+ }
+ return `
+${PROMPT_BASE}
+URL: ${bookmark.link.url}
+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}
+${bookmark.text.text}
+ `;
+ }
+
+ throw new Error("Unknown bookmark type");
+}
+
+async function fetchBookmark(linkId: string) {
+ return await db.query.bookmarks.findFirst({
+ where: eq(bookmarks.id, linkId),
+ with: {
+ link: true,
+ text: true,
+ },
+ });
+}
+
+async function inferTags(
+ jobId: string,
+ bookmark: NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>,
+ openai: OpenAI,
+) {
+ const chatCompletion = await openai.chat.completions.create({
+ messages: [{ role: "system", content: buildPrompt(bookmark) }],
+ model: "gpt-3.5-turbo-0125",
+ response_format: { type: "json_object" },
+ });
+
+ const response = chatCompletion.choices[0].message.content;
+ if (!response) {
+ throw new Error(`[openai][${jobId}] Got no message content from OpenAI`);
+ }
+
+ try {
+ let tags = openAIResponseSchema.parse(JSON.parse(response)).tags;
+ logger.info(
+ `[openai][${jobId}] Inferring tag for bookmark "${bookmark.id}" used ${chatCompletion.usage?.total_tokens} tokens and inferred: ${tags}`,
+ );
+
+ // Sometimes the tags contain the hashtag symbol, let's strip them out if they do.
+ tags = tags.map((t) => {
+ if (t.startsWith("#")) {
+ return t.slice(1);
+ }
+ return t;
+ });
+
+ return tags;
+ } catch (e) {
+ throw new Error(
+ `[openai][${jobId}] Failed to parse JSON response from OpenAI: ${e}`,
+ );
+ }
+}
+
+async function connectTags(
+ bookmarkId: string,
+ newTags: string[],
+ userId: string,
+) {
+ if (newTags.length == 0) {
+ return;
+ }
+
+ await db.transaction(async (tx) => {
+ // Create tags that didn't exist previously
+ await tx
+ .insert(bookmarkTags)
+ .values(
+ newTags.map((t) => ({
+ name: t,
+ userId,
+ })),
+ )
+ .onConflictDoNothing();
+
+ const newTagIds = (
+ await tx.query.bookmarkTags.findMany({
+ where: and(
+ eq(bookmarkTags.userId, userId),
+ inArray(bookmarkTags.name, newTags),
+ ),
+ columns: {
+ id: true,
+ },
+ })
+ ).map((r) => r.id);
+
+ // Delete old AI tags
+ await tx
+ .delete(tagsOnBookmarks)
+ .where(
+ and(
+ eq(tagsOnBookmarks.attachedBy, "ai"),
+ eq(tagsOnBookmarks.bookmarkId, bookmarkId),
+ ),
+ );
+
+ // Attach new ones
+ await tx
+ .insert(tagsOnBookmarks)
+ .values(
+ newTagIds.map((tagId) => ({
+ tagId,
+ bookmarkId,
+ attachedBy: "ai" as const,
+ })),
+ )
+ .onConflictDoNothing();
+ });
+}
+
+async function runOpenAI(job: Job<ZOpenAIRequest, void>) {
+ const jobId = job.id || "unknown";
+
+ const { openAI } = serverConfig;
+
+ if (!openAI.apiKey) {
+ logger.debug(
+ `[openai][${jobId}] OpenAI is not configured, nothing to do now`,
+ );
+ return;
+ }
+
+ const openai = new OpenAI({
+ apiKey: openAI.apiKey,
+ });
+
+ const request = zOpenAIRequestSchema.safeParse(job.data);
+ if (!request.success) {
+ throw new Error(
+ `[openai][${jobId}] Got malformed job request: ${request.error.toString()}`,
+ );
+ }
+
+ const { bookmarkId } = request.data;
+ const bookmark = await fetchBookmark(bookmarkId);
+ if (!bookmark) {
+ throw new Error(
+ `[openai][${jobId}] bookmark with id ${bookmarkId} was not found`,
+ );
+ }
+
+ const tags = await inferTags(jobId, bookmark, openai);
+
+ await connectTags(bookmarkId, tags, bookmark.userId);
+
+ // Update the search index
+ SearchIndexingQueue.add("search_indexing", {
+ bookmarkId,
+ type: "index",
+ });
+}
diff --git a/apps/workers/package.json b/apps/workers/package.json
new file mode 100644
index 00000000..8446a54d
--- /dev/null
+++ b/apps/workers/package.json
@@ -0,0 +1,56 @@
+{
+ "$schema": "https://json.schemastore.org/package.json",
+ "name": "@hoarder/workers",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@hoarder/db": "workspace:*",
+ "@hoarder/shared": "workspace:*",
+ "@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",
+ "jsdom": "^24.0.0",
+ "metascraper": "^5.43.4",
+ "metascraper-description": "^5.43.4",
+ "metascraper-image": "^5.43.4",
+ "metascraper-logo": "^5.43.4",
+ "metascraper-logo-favicon": "^5.43.4",
+ "metascraper-readability": "^5.43.4",
+ "metascraper-title": "^5.43.4",
+ "metascraper-twitter": "^5.43.4",
+ "metascraper-url": "^5.43.4",
+ "openai": "^4.29.0",
+ "puppeteer": "^22.0.0",
+ "puppeteer-extra": "^3.3.6",
+ "puppeteer-extra-plugin-adblocker": "^2.13.6",
+ "puppeteer-extra-plugin-stealth": "^2.11.2",
+ "tsx": "^4.7.1",
+ "typescript": "^5.3.3",
+ "zod": "^3.22.4"
+ },
+ "devDependencies": {
+ "@hoarder/eslint-config": "workspace:^0.2.0",
+ "@hoarder/prettier-config": "workspace:^0.1.0",
+ "@hoarder/tsconfig": "workspace:^0.1.0",
+ "@types/dompurify": "^3.0.5",
+ "@types/jsdom": "^21.1.6",
+ "@types/metascraper": "^5.14.3"
+ },
+ "scripts": {
+ "start": "tsx watch index.ts",
+ "start:prod": "tsx index.ts",
+ "typecheck": "tsc --noEmit"
+ },
+ "eslintConfig": {
+ "root": true,
+ "extends": [
+ "@hoarder/eslint-config/base"
+ ]
+ },
+ "prettier": "@hoarder/prettier-config"
+}
diff --git a/apps/workers/searchWorker.ts b/apps/workers/searchWorker.ts
new file mode 100644
index 00000000..618e7c89
--- /dev/null
+++ b/apps/workers/searchWorker.ts
@@ -0,0 +1,116 @@
+import { db } from "@hoarder/db";
+import logger from "@hoarder/shared/logger";
+import { getSearchIdxClient } from "@hoarder/shared/search";
+import {
+ SearchIndexingQueue,
+ ZSearchIndexingRequest,
+ queueConnectionDetails,
+ zSearchIndexingRequestSchema,
+} from "@hoarder/shared/queues";
+import { Job } from "bullmq";
+import { Worker } from "bullmq";
+import { bookmarks } from "@hoarder/db/schema";
+import { eq } from "drizzle-orm";
+
+export class SearchIndexingWorker {
+ static async build() {
+ logger.info("Starting search indexing worker ...");
+ const worker = new Worker<ZSearchIndexingRequest, void>(
+ SearchIndexingQueue.name,
+ runSearchIndexing,
+ {
+ connection: queueConnectionDetails,
+ autorun: false,
+ },
+ );
+
+ worker.on("completed", (job) => {
+ const jobId = job?.id || "unknown";
+ logger.info(`[search][${jobId}] Completed successfully`);
+ });
+
+ worker.on("failed", (job, error) => {
+ const jobId = job?.id || "unknown";
+ logger.error(`[search][${jobId}] openai job failed: ${error}`);
+ });
+
+ return worker;
+ }
+}
+
+async function runIndex(
+ searchClient: NonNullable<Awaited<ReturnType<typeof getSearchIdxClient>>>,
+ bookmarkId: string,
+) {
+ const bookmark = await db.query.bookmarks.findFirst({
+ where: eq(bookmarks.id, bookmarkId),
+ with: {
+ link: true,
+ text: true,
+ tagsOnBookmarks: {
+ with: {
+ tag: true,
+ },
+ },
+ },
+ });
+
+ if (!bookmark) {
+ throw new Error(`Bookmark ${bookmarkId} not found`);
+ }
+
+ searchClient.addDocuments([
+ {
+ id: bookmark.id,
+ userId: bookmark.userId,
+ ...(bookmark.link
+ ? {
+ url: bookmark.link.url,
+ title: bookmark.link.title,
+ description: bookmark.link.description,
+ content: bookmark.link.content,
+ }
+ : undefined),
+ ...(bookmark.text ? { content: bookmark.text.text } : undefined),
+ tags: bookmark.tagsOnBookmarks.map((t) => t.tag.name),
+ },
+ ]);
+}
+
+async function runDelete(
+ searchClient: NonNullable<Awaited<ReturnType<typeof getSearchIdxClient>>>,
+ bookmarkId: string,
+) {
+ await searchClient.deleteDocument(bookmarkId);
+}
+
+async function runSearchIndexing(job: Job<ZSearchIndexingRequest, void>) {
+ const jobId = job.id || "unknown";
+
+ const request = zSearchIndexingRequestSchema.safeParse(job.data);
+ if (!request.success) {
+ throw new Error(
+ `[search][${jobId}] Got malformed job request: ${request.error.toString()}`,
+ );
+ }
+
+ const searchClient = await getSearchIdxClient();
+ if (!searchClient) {
+ logger.debug(
+ `[search][${jobId}] Search is not configured, nothing to do now`,
+ );
+ return;
+ }
+
+ const bookmarkId = request.data.bookmarkId;
+ switch (request.data.type) {
+ case "index": {
+ await runIndex(searchClient, bookmarkId);
+ break;
+ }
+ case "delete": {
+ await runDelete(searchClient, bookmarkId);
+ break;
+ }
+ }
+}
diff --git a/apps/workers/tsconfig.json b/apps/workers/tsconfig.json
new file mode 100644
index 00000000..24b9a10d
--- /dev/null
+++ b/apps/workers/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "@hoarder/tsconfig/node.json",
+ "include": ["**/*.ts"],
+ "exclude": ["node_modules"],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
+ }
+}