diff options
| author | MohamedBassem <me@mbassem.com> | 2024-03-13 21:43:44 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2024-03-14 16:40:45 +0000 |
| commit | 04572a8e5081b1e4871e273cde9dbaaa44c52fe0 (patch) | |
| tree | 8e993acb732a50d1306d4d6953df96c165c57f57 /packages | |
| parent | 2df08ed08c065e8b91bc8df0266bd4bcbb062be4 (diff) | |
| download | karakeep-04572a8e5081b1e4871e273cde9dbaaa44c52fe0.tar.zst | |
structure: Create apps dir and copy tooling dir from t3-turbo repo
Diffstat (limited to 'packages')
187 files changed, 49 insertions, 8884 deletions
diff --git a/packages/browser-extension/.eslintrc.cjs b/packages/browser-extension/.eslintrc.cjs deleted file mode 100644 index 450106a4..00000000 --- a/packages/browser-extension/.eslintrc.cjs +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - ignorePatterns: ["dist/"], -}; diff --git a/packages/browser-extension/.gitignore b/packages/browser-extension/.gitignore deleted file mode 100644 index a547bf36..00000000 --- a/packages/browser-extension/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# 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/packages/browser-extension/index.html b/packages/browser-extension/index.html deleted file mode 100644 index e4b78eae..00000000 --- a/packages/browser-extension/index.html +++ /dev/null @@ -1,13 +0,0 @@ -<!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/packages/browser-extension/manifest.json b/packages/browser-extension/manifest.json deleted file mode 100644 index d3a27e7b..00000000 --- a/packages/browser-extension/manifest.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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/packages/browser-extension/package.json b/packages/browser-extension/package.json deleted file mode 100644 index b205a257..00000000 --- a/packages/browser-extension/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "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" - }, - "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", - "@types/chrome": "^0.0.260", - "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": { - "@crxjs/vite-plugin": "^1.0.14", - "@types/react": "^18.2.55", - "@types/react-dom": "^18.2.19", - "@typescript-eslint/eslint-plugin": "^6.21.0", - "@typescript-eslint/parser": "^6.21.0", - "@vitejs/plugin-react-swc": "^3.5.0", - "autoprefixer": "^10.4.17", - "eslint": "^8.56.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.2.2", - "vite": "^5.1.0" - } -} diff --git a/packages/browser-extension/postcss.config.js b/packages/browser-extension/postcss.config.js deleted file mode 100644 index 2aa7205d..00000000 --- a/packages/browser-extension/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/packages/browser-extension/public/logo-128.png b/packages/browser-extension/public/logo-128.png Binary files differdeleted file mode 100644 index 71ead90c..00000000 --- a/packages/browser-extension/public/logo-128.png +++ /dev/null diff --git a/packages/browser-extension/public/logo-16.png b/packages/browser-extension/public/logo-16.png Binary files differdeleted file mode 100644 index dd864d44..00000000 --- a/packages/browser-extension/public/logo-16.png +++ /dev/null diff --git a/packages/browser-extension/public/logo-48.png b/packages/browser-extension/public/logo-48.png Binary files differdeleted file mode 100644 index 7ba1cd49..00000000 --- a/packages/browser-extension/public/logo-48.png +++ /dev/null diff --git a/packages/browser-extension/public/logo.png b/packages/browser-extension/public/logo.png Binary files differdeleted file mode 100644 index ebe0a6a3..00000000 --- a/packages/browser-extension/public/logo.png +++ /dev/null diff --git a/packages/browser-extension/src/BookmarkDeletedPage.tsx b/packages/browser-extension/src/BookmarkDeletedPage.tsx deleted file mode 100644 index 23e1d9da..00000000 --- a/packages/browser-extension/src/BookmarkDeletedPage.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function BookmarkDeletedPage() { - return <p className="text-lg">Bookmark Deleted!</p>; -} diff --git a/packages/browser-extension/src/BookmarkSavedPage.tsx b/packages/browser-extension/src/BookmarkSavedPage.tsx deleted file mode 100644 index f25a83ba..00000000 --- a/packages/browser-extension/src/BookmarkSavedPage.tsx +++ /dev/null @@ -1,62 +0,0 @@ -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/packages/browser-extension/src/Layout.tsx b/packages/browser-extension/src/Layout.tsx deleted file mode 100644 index f8279a18..00000000 --- a/packages/browser-extension/src/Layout.tsx +++ /dev/null @@ -1,51 +0,0 @@ -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/packages/browser-extension/src/Logo.tsx b/packages/browser-extension/src/Logo.tsx deleted file mode 100644 index 6b29e68c..00000000 --- a/packages/browser-extension/src/Logo.tsx +++ /dev/null @@ -1,10 +0,0 @@ -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/packages/browser-extension/src/NotConfiguredPage.tsx b/packages/browser-extension/src/NotConfiguredPage.tsx deleted file mode 100644 index fc5c8f47..00000000 --- a/packages/browser-extension/src/NotConfiguredPage.tsx +++ /dev/null @@ -1,47 +0,0 @@ -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/packages/browser-extension/src/OptionsPage.tsx b/packages/browser-extension/src/OptionsPage.tsx deleted file mode 100644 index 6407b3cc..00000000 --- a/packages/browser-extension/src/OptionsPage.tsx +++ /dev/null @@ -1,60 +0,0 @@ -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/packages/browser-extension/src/SavePage.tsx b/packages/browser-extension/src/SavePage.tsx deleted file mode 100644 index 638af149..00000000 --- a/packages/browser-extension/src/SavePage.tsx +++ /dev/null @@ -1,60 +0,0 @@ -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/packages/browser-extension/src/SignInPage.tsx b/packages/browser-extension/src/SignInPage.tsx deleted file mode 100644 index 6db7c348..00000000 --- a/packages/browser-extension/src/SignInPage.tsx +++ /dev/null @@ -1,89 +0,0 @@ -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/packages/browser-extension/src/Spinner.tsx b/packages/browser-extension/src/Spinner.tsx deleted file mode 100644 index 9fd8839b..00000000 --- a/packages/browser-extension/src/Spinner.tsx +++ /dev/null @@ -1,18 +0,0 @@ -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/packages/browser-extension/src/assets/react.svg b/packages/browser-extension/src/assets/react.svg deleted file mode 100644 index 6c87de9b..00000000 --- a/packages/browser-extension/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ -<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/packages/browser-extension/src/index.css b/packages/browser-extension/src/index.css deleted file mode 100644 index e7d4bb2f..00000000 --- a/packages/browser-extension/src/index.css +++ /dev/null @@ -1,72 +0,0 @@ -@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/packages/browser-extension/src/main.tsx b/packages/browser-extension/src/main.tsx deleted file mode 100644 index 085a5a69..00000000 --- a/packages/browser-extension/src/main.tsx +++ /dev/null @@ -1,40 +0,0 @@ -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/packages/browser-extension/src/utils/providers.tsx b/packages/browser-extension/src/utils/providers.tsx deleted file mode 100644 index d20f2512..00000000 --- a/packages/browser-extension/src/utils/providers.tsx +++ /dev/null @@ -1,46 +0,0 @@ -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/packages/browser-extension/src/utils/settings.ts b/packages/browser-extension/src/utils/settings.ts deleted file mode 100644 index 37f474c0..00000000 --- a/packages/browser-extension/src/utils/settings.ts +++ /dev/null @@ -1,22 +0,0 @@ -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/packages/browser-extension/src/utils/trpc.ts b/packages/browser-extension/src/utils/trpc.ts deleted file mode 100644 index da21a55a..00000000 --- a/packages/browser-extension/src/utils/trpc.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createTRPCReact } from "@trpc/react-query"; -import type { AppRouter } from "@hoarder/trpc/routers/_app"; - -export const api = createTRPCReact<AppRouter>(); diff --git a/packages/browser-extension/src/vite-env.d.ts b/packages/browser-extension/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2..00000000 --- a/packages/browser-extension/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// <reference types="vite/client" /> diff --git a/packages/browser-extension/tailwind.config.js b/packages/browser-extension/tailwind.config.js deleted file mode 100644 index 1c0c7c87..00000000 --- a/packages/browser-extension/tailwind.config.js +++ /dev/null @@ -1,10 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -const tailwindConfig = { - content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], - theme: { - extend: {}, - }, - plugins: [], -}; - -export default tailwindConfig; diff --git a/packages/browser-extension/tsconfig.json b/packages/browser-extension/tsconfig.json deleted file mode 100644 index a7fc6fbf..00000000 --- a/packages/browser-extension/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "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/packages/browser-extension/tsconfig.node.json b/packages/browser-extension/tsconfig.node.json deleted file mode 100644 index 97ede7ee..00000000 --- a/packages/browser-extension/tsconfig.node.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true, - "strict": true - }, - "include": ["vite.config.ts"] -} diff --git a/packages/browser-extension/vite.config.ts b/packages/browser-extension/vite.config.ts deleted file mode 100644 index 29c6bc6e..00000000 --- a/packages/browser-extension/vite.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/packages/db/package.json b/packages/db/package.json index 20dee1e9..aebbf8fe 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -4,6 +4,7 @@ "version": "0.1.0", "private": true, "main": "index.ts", + "type": "module", "scripts": { "typecheck": "tsc --noEmit", "migrate": "tsx migrate.ts", @@ -18,8 +19,18 @@ "tsx": "^4.7.1" }, "devDependencies": { + "@hoarder/eslint-config": "workspace:^0.2.0", + "@hoarder/prettier-config": "workspace:^0.1.0", + "@hoarder/tsconfig": "workspace:^0.1.0", "@tsconfig/node21": "^21.0.1", "@types/better-sqlite3": "^7.6.9", "drizzle-kit": "^0.20.14" - } + }, + "eslintConfig": { + "root": true, + "extends": [ + "@hoarder/eslint-config/base" + ] + }, + "prettier": "@hoarder/prettier-config" } diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index cf49c407..59982ea1 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -1,11 +1,10 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@tsconfig/node21/tsconfig.json", + "extends": "@hoarder/tsconfig/node.json", "include": ["**/*.ts"], "exclude": ["node_modules"], "compilerOptions": { - "module": "ESNext", - "moduleResolution": "node", - "esModuleInterop": true + "baseUrl" : ".", + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" } } diff --git a/packages/mobile/.eslintrc.js b/packages/mobile/.eslintrc.js deleted file mode 100644 index 53beac49..00000000 --- a/packages/mobile/.eslintrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - root: true, - extends: ["universe/native"], -}; diff --git a/packages/mobile/.gitignore b/packages/mobile/.gitignore deleted file mode 100644 index 2920e5a8..00000000 --- a/packages/mobile/.gitignore +++ /dev/null @@ -1,39 +0,0 @@ -# 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/packages/mobile/.npmrc b/packages/mobile/.npmrc deleted file mode 100644 index d67f3748..00000000 --- a/packages/mobile/.npmrc +++ /dev/null @@ -1 +0,0 @@ -node-linker=hoisted diff --git a/packages/mobile/app.json b/packages/mobile/app.json deleted file mode 100644 index e16baa37..00000000 --- a/packages/mobile/app.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "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/packages/mobile/app/+not-found.tsx b/packages/mobile/app/+not-found.tsx deleted file mode 100644 index 466505b6..00000000 --- a/packages/mobile/app/+not-found.tsx +++ /dev/null @@ -1,6 +0,0 @@ -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/packages/mobile/app/_layout.tsx b/packages/mobile/app/_layout.tsx deleted file mode 100644 index 6304ced5..00000000 --- a/packages/mobile/app/_layout.tsx +++ /dev/null @@ -1,53 +0,0 @@ -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/packages/mobile/app/dashboard/(tabs)/_layout.tsx b/packages/mobile/app/dashboard/(tabs)/_layout.tsx deleted file mode 100644 index 5b2d810a..00000000 --- a/packages/mobile/app/dashboard/(tabs)/_layout.tsx +++ /dev/null @@ -1,38 +0,0 @@ -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/packages/mobile/app/dashboard/(tabs)/index.tsx b/packages/mobile/app/dashboard/(tabs)/index.tsx deleted file mode 100644 index b2349525..00000000 --- a/packages/mobile/app/dashboard/(tabs)/index.tsx +++ /dev/null @@ -1,31 +0,0 @@ -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/packages/mobile/app/dashboard/(tabs)/lists.tsx b/packages/mobile/app/dashboard/(tabs)/lists.tsx deleted file mode 100644 index b534ddda..00000000 --- a/packages/mobile/app/dashboard/(tabs)/lists.tsx +++ /dev/null @@ -1,67 +0,0 @@ -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/packages/mobile/app/dashboard/(tabs)/search.tsx b/packages/mobile/app/dashboard/(tabs)/search.tsx deleted file mode 100644 index 980cab36..00000000 --- a/packages/mobile/app/dashboard/(tabs)/search.tsx +++ /dev/null @@ -1,35 +0,0 @@ -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/packages/mobile/app/dashboard/(tabs)/settings.tsx b/packages/mobile/app/dashboard/(tabs)/settings.tsx deleted file mode 100644 index 9f86d5ec..00000000 --- a/packages/mobile/app/dashboard/(tabs)/settings.tsx +++ /dev/null @@ -1,41 +0,0 @@ -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/packages/mobile/app/dashboard/_layout.tsx b/packages/mobile/app/dashboard/_layout.tsx deleted file mode 100644 index ff2384d2..00000000 --- a/packages/mobile/app/dashboard/_layout.tsx +++ /dev/null @@ -1,38 +0,0 @@ -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/packages/mobile/app/dashboard/add-link.tsx b/packages/mobile/app/dashboard/add-link.tsx deleted file mode 100644 index 69a9c7a2..00000000 --- a/packages/mobile/app/dashboard/add-link.tsx +++ /dev/null @@ -1,57 +0,0 @@ -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/packages/mobile/app/dashboard/add-note.tsx b/packages/mobile/app/dashboard/add-note.tsx deleted file mode 100644 index cf775a15..00000000 --- a/packages/mobile/app/dashboard/add-note.tsx +++ /dev/null @@ -1,53 +0,0 @@ -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/packages/mobile/app/dashboard/archive.tsx b/packages/mobile/app/dashboard/archive.tsx deleted file mode 100644 index d75cfe22..00000000 --- a/packages/mobile/app/dashboard/archive.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { View } from "react-native"; - -import BookmarkList from "@/components/bookmarks/BookmarkList"; - -export default function Archive() { - return ( - <View> - <BookmarkList archived /> - </View> - ); -} diff --git a/packages/mobile/app/dashboard/favourites.tsx b/packages/mobile/app/dashboard/favourites.tsx deleted file mode 100644 index 90374f18..00000000 --- a/packages/mobile/app/dashboard/favourites.tsx +++ /dev/null @@ -1,11 +0,0 @@ -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/packages/mobile/app/dashboard/lists/[slug].tsx b/packages/mobile/app/dashboard/lists/[slug].tsx deleted file mode 100644 index 54744874..00000000 --- a/packages/mobile/app/dashboard/lists/[slug].tsx +++ /dev/null @@ -1,31 +0,0 @@ -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/packages/mobile/app/error.tsx b/packages/mobile/app/error.tsx deleted file mode 100644 index 2ca227a4..00000000 --- a/packages/mobile/app/error.tsx +++ /dev/null @@ -1,9 +0,0 @@ -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/packages/mobile/app/index.tsx b/packages/mobile/app/index.tsx deleted file mode 100644 index 5ce20cda..00000000 --- a/packages/mobile/app/index.tsx +++ /dev/null @@ -1,20 +0,0 @@ -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/packages/mobile/app/sharing.tsx b/packages/mobile/app/sharing.tsx deleted file mode 100644 index 64bbd933..00000000 --- a/packages/mobile/app/sharing.tsx +++ /dev/null @@ -1,99 +0,0 @@ -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/packages/mobile/app/signin.tsx b/packages/mobile/app/signin.tsx deleted file mode 100644 index a89b0087..00000000 --- a/packages/mobile/app/signin.tsx +++ /dev/null @@ -1,101 +0,0 @@ -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/packages/mobile/assets/blur.jpeg b/packages/mobile/assets/blur.jpeg Binary files differdeleted file mode 100644 index 387ce697..00000000 --- a/packages/mobile/assets/blur.jpeg +++ /dev/null diff --git a/packages/mobile/assets/icon.png b/packages/mobile/assets/icon.png Binary files differdeleted file mode 100644 index 71ead90c..00000000 --- a/packages/mobile/assets/icon.png +++ /dev/null diff --git a/packages/mobile/assets/splash.png b/packages/mobile/assets/splash.png Binary files differdeleted file mode 100644 index 3759c518..00000000 --- a/packages/mobile/assets/splash.png +++ /dev/null diff --git a/packages/mobile/babel.config.js b/packages/mobile/babel.config.js deleted file mode 100644 index f3c649bb..00000000 --- a/packages/mobile/babel.config.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = function (api) { - api.cache(true); - return { - presets: [ - ["babel-preset-expo", { jsxImportSource: "nativewind" }], - "nativewind/babel", - ], - }; -}; diff --git a/packages/mobile/components/Logo.tsx b/packages/mobile/components/Logo.tsx deleted file mode 100644 index 57f7a5c3..00000000 --- a/packages/mobile/components/Logo.tsx +++ /dev/null @@ -1,11 +0,0 @@ -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/packages/mobile/components/bookmarks/BookmarkCard.tsx b/packages/mobile/components/bookmarks/BookmarkCard.tsx deleted file mode 100644 index 25947790..00000000 --- a/packages/mobile/components/bookmarks/BookmarkCard.tsx +++ /dev/null @@ -1,243 +0,0 @@ -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/packages/mobile/components/bookmarks/BookmarkList.tsx b/packages/mobile/components/bookmarks/BookmarkList.tsx deleted file mode 100644 index 8e408709..00000000 --- a/packages/mobile/components/bookmarks/BookmarkList.tsx +++ /dev/null @@ -1,61 +0,0 @@ -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/packages/mobile/components/ui/ActionButton.tsx b/packages/mobile/components/ui/ActionButton.tsx deleted file mode 100644 index c51eb332..00000000 --- a/packages/mobile/components/ui/ActionButton.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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/packages/mobile/components/ui/Button.tsx b/packages/mobile/components/ui/Button.tsx deleted file mode 100644 index 4c3cbc69..00000000 --- a/packages/mobile/components/ui/Button.tsx +++ /dev/null @@ -1,81 +0,0 @@ -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/packages/mobile/components/ui/Divider.tsx b/packages/mobile/components/ui/Divider.tsx deleted file mode 100644 index 1da0a71e..00000000 --- a/packages/mobile/components/ui/Divider.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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/packages/mobile/components/ui/FullPageSpinner.tsx b/packages/mobile/components/ui/FullPageSpinner.tsx deleted file mode 100644 index 01187f11..00000000 --- a/packages/mobile/components/ui/FullPageSpinner.tsx +++ /dev/null @@ -1,9 +0,0 @@ -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/packages/mobile/components/ui/Input.tsx b/packages/mobile/components/ui/Input.tsx deleted file mode 100644 index 2fcb2764..00000000 --- a/packages/mobile/components/ui/Input.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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/packages/mobile/components/ui/Skeleton.tsx b/packages/mobile/components/ui/Skeleton.tsx deleted file mode 100644 index 68b22e1e..00000000 --- a/packages/mobile/components/ui/Skeleton.tsx +++ /dev/null @@ -1,38 +0,0 @@ -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/packages/mobile/components/ui/Toast.tsx b/packages/mobile/components/ui/Toast.tsx deleted file mode 100644 index fb319f84..00000000 --- a/packages/mobile/components/ui/Toast.tsx +++ /dev/null @@ -1,183 +0,0 @@ -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/packages/mobile/eas.json b/packages/mobile/eas.json deleted file mode 100644 index 0897755d..00000000 --- a/packages/mobile/eas.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "cli": { - "version": ">= 7.5.0", - "promptToConfigurePushNotifications": false - }, - "build": { - "development": { - "developmentClient": true, - "distribution": "internal" - }, - "preview": { - "distribution": "internal" - }, - "production": {} - }, - "submit": { - "production": {} - } -} diff --git a/packages/mobile/globals.css b/packages/mobile/globals.css deleted file mode 100644 index de1cf559..00000000 --- a/packages/mobile/globals.css +++ /dev/null @@ -1,80 +0,0 @@ -@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/packages/mobile/lib/last-shared-intent.ts b/packages/mobile/lib/last-shared-intent.ts deleted file mode 100644 index 951bcf74..00000000 --- a/packages/mobile/lib/last-shared-intent.ts +++ /dev/null @@ -1,15 +0,0 @@ -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/packages/mobile/lib/providers.tsx b/packages/mobile/lib/providers.tsx deleted file mode 100644 index 1717afb2..00000000 --- a/packages/mobile/lib/providers.tsx +++ /dev/null @@ -1,54 +0,0 @@ -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/packages/mobile/lib/session.ts b/packages/mobile/lib/session.ts deleted file mode 100644 index e2ab245b..00000000 --- a/packages/mobile/lib/session.ts +++ /dev/null @@ -1,20 +0,0 @@ -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/packages/mobile/lib/settings.ts b/packages/mobile/lib/settings.ts deleted file mode 100644 index 21f40528..00000000 --- a/packages/mobile/lib/settings.ts +++ /dev/null @@ -1,29 +0,0 @@ -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/packages/mobile/lib/storage-state.ts b/packages/mobile/lib/storage-state.ts deleted file mode 100644 index 4988f0e0..00000000 --- a/packages/mobile/lib/storage-state.ts +++ /dev/null @@ -1,51 +0,0 @@ -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/packages/mobile/lib/trpc.ts b/packages/mobile/lib/trpc.ts deleted file mode 100644 index 6b428bd9..00000000 --- a/packages/mobile/lib/trpc.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { AppRouter } from "@hoarder/trpc/routers/_app"; -import { createTRPCReact } from "@trpc/react-query"; - -export const api = createTRPCReact<AppRouter>(); diff --git a/packages/mobile/lib/utils.ts b/packages/mobile/lib/utils.ts deleted file mode 100644 index 365058ce..00000000 --- a/packages/mobile/lib/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} diff --git a/packages/mobile/metro.config.js b/packages/mobile/metro.config.js deleted file mode 100644 index 6b2b0477..00000000 --- a/packages/mobile/metro.config.js +++ /dev/null @@ -1,8 +0,0 @@ -const { getDefaultConfig } = require("expo/metro-config"); -const { withNativeWind } = require("nativewind/metro"); - -/** @type {import('expo/metro-config').MetroConfig} */ -// eslint-disable-next-line no-undef -const config = getDefaultConfig(__dirname); - -module.exports = withNativeWind(config, { input: "./globals.css" }); diff --git a/packages/mobile/nativewind-env.d.ts b/packages/mobile/nativewind-env.d.ts deleted file mode 100644 index a13e3136..00000000 --- a/packages/mobile/nativewind-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// <reference types="nativewind/types" /> diff --git a/packages/mobile/package.json b/packages/mobile/package.json deleted file mode 100644 index 1298b8db..00000000 --- a/packages/mobile/package.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "hoarder-mobile", - "version": "1.0.0", - "main": "expo-router/entry", - "scripts": { - "start": "expo start", - "android": "expo run:android", - "ios": "expo run:ios", - "web": "expo start --web", - "lint": "eslint ." - }, - "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": { - "@babel/core": "^7.20.0", - "@types/react": "~18.2.45", - "ajv": "latest", - "eslint": "^8.57.0", - "eslint-config-universe": "^12.0.0", - "prettier": "^3.2.5", - "tailwindcss": "3.3.2", - "typescript": "^5.1.3" - }, - "private": true -} diff --git a/packages/mobile/tailwind.config.js b/packages/mobile/tailwind.config.js deleted file mode 100644 index b49f9598..00000000 --- a/packages/mobile/tailwind.config.js +++ /dev/null @@ -1,71 +0,0 @@ -const { hairlineWidth } = require("nativewind/theme"); - -/** @type {import('tailwindcss').Config} */ -module.exports = { - 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", - }, - }, - }, -}; diff --git a/packages/mobile/tsconfig.json b/packages/mobile/tsconfig.json deleted file mode 100644 index 84d97cb0..00000000 --- a/packages/mobile/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "expo/tsconfig.base", - "compilerOptions": { - "strict": true, - "baseUrl": ".", - "paths": { - "@/*": ["./*"] - } - } -} diff --git a/packages/shared/package.json b/packages/shared/package.json index 0b3a8078..dca8925c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,10 +3,23 @@ "name": "@hoarder/shared", "version": "0.1.0", "private": true, + "type": "module", "dependencies": { "meilisearch": "^0.37.0", "winston": "^3.11.0", "zod": "^3.22.4" }, - "main": "index.ts" + "devDependencies": { + "@hoarder/eslint-config": "workspace:^0.2.0", + "@hoarder/prettier-config": "workspace:^0.1.0", + "@hoarder/tsconfig": "workspace:^0.1.0" + }, + "main": "index.ts", + "eslintConfig": { + "root": true, + "extends": [ + "@hoarder/eslint-config/base" + ] + }, + "prettier": "@hoarder/prettier-config" } diff --git a/packages/workers/tsconfig.json b/packages/shared/tsconfig.json index cf49c407..71bf61e7 100644 --- a/packages/workers/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -1,11 +1,9 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@tsconfig/node21/tsconfig.json", + "extends": "@hoarder/tsconfig/node.json", "include": ["**/*.ts"], "exclude": ["node_modules"], "compilerOptions": { - "module": "ESNext", - "moduleResolution": "node", - "esModuleInterop": true - } + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, } diff --git a/packages/trpc/package.json b/packages/trpc/package.json index ac4fb6fe..631f6ece 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -3,6 +3,7 @@ "name": "@hoarder/trpc", "version": "0.1.0", "private": true, + "type": "module", "scripts": { "typecheck": "tsc --noEmit", "test": "vitest" @@ -10,20 +11,28 @@ "dependencies": { "@hoarder/db": "workspace:*", "@hoarder/shared": "workspace:*", - "@trpc/server": "11.0.0-next-beta.304", + "@trpc/server": "11.0.0-next-beta.308", "bcryptjs": "^2.4.3", "drizzle-orm": "^0.29.4", "superjson": "^2.2.1", "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/bcryptjs": "^2.4.6", "@tsconfig/node21": "^21.0.1", "@types/bcrypt": "^5.0.2", - "aws-sdk": "^2.1570.0", - "mock-aws-s3": "^4.0.2", "nock": "^13.5.4", "vite-tsconfig-paths": "^4.3.1", "vitest": "^1.3.1" - } -} + }, + "eslintConfig": { + "root": true, + "extends": [ + "@hoarder/eslint-config/base" + ] + }, + "prettier": "@hoarder/prettier-config" +}
\ No newline at end of file diff --git a/packages/trpc/tsconfig.json b/packages/trpc/tsconfig.json index bf020b01..80329662 100644 --- a/packages/trpc/tsconfig.json +++ b/packages/trpc/tsconfig.json @@ -1,13 +1,10 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@tsconfig/node21/tsconfig.json", + "extends": "@hoarder/tsconfig/node.json", "include": ["**/*.ts"], "exclude": ["node_modules"], "compilerOptions": { - "module": "ESNext", - "moduleResolution": "node", - "baseUrl": "./", - "esModuleInterop": true - } + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, } diff --git a/packages/web/README.md b/packages/web/README.md deleted file mode 100644 index c4033664..00000000 --- a/packages/web/README.md +++ /dev/null @@ -1,36 +0,0 @@ -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/packages/web/app/api/auth/[...nextauth]/route.tsx b/packages/web/app/api/auth/[...nextauth]/route.tsx deleted file mode 100644 index 2f7f1cb0..00000000 --- a/packages/web/app/api/auth/[...nextauth]/route.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { authHandler } from "@/server/auth"; - -export { authHandler as GET, authHandler as POST }; diff --git a/packages/web/app/api/trpc/[trpc]/route.ts b/packages/web/app/api/trpc/[trpc]/route.ts deleted file mode 100644 index b6753101..00000000 --- a/packages/web/app/api/trpc/[trpc]/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -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/packages/web/app/dashboard/admin/page.tsx b/packages/web/app/dashboard/admin/page.tsx deleted file mode 100644 index 6babdd79..00000000 --- a/packages/web/app/dashboard/admin/page.tsx +++ /dev/null @@ -1,203 +0,0 @@ -"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/packages/web/app/dashboard/archive/page.tsx b/packages/web/app/dashboard/archive/page.tsx deleted file mode 100644 index 69559185..00000000 --- a/packages/web/app/dashboard/archive/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -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/packages/web/app/dashboard/bookmarks/layout.tsx b/packages/web/app/dashboard/bookmarks/layout.tsx deleted file mode 100644 index 71ee143b..00000000 --- a/packages/web/app/dashboard/bookmarks/layout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -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/packages/web/app/dashboard/bookmarks/loading.tsx b/packages/web/app/dashboard/bookmarks/loading.tsx deleted file mode 100644 index 4e56c3c4..00000000 --- a/packages/web/app/dashboard/bookmarks/loading.tsx +++ /dev/null @@ -1,11 +0,0 @@ -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/packages/web/app/dashboard/bookmarks/page.tsx b/packages/web/app/dashboard/bookmarks/page.tsx deleted file mode 100644 index c9391d85..00000000 --- a/packages/web/app/dashboard/bookmarks/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks"; - -export default async function BookmarksPage() { - return <Bookmarks title="Bookmarks" archived={false} />; -} diff --git a/packages/web/app/dashboard/error.tsx b/packages/web/app/dashboard/error.tsx deleted file mode 100644 index 556e59a3..00000000 --- a/packages/web/app/dashboard/error.tsx +++ /dev/null @@ -1,9 +0,0 @@ -"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/packages/web/app/dashboard/favourites/page.tsx b/packages/web/app/dashboard/favourites/page.tsx deleted file mode 100644 index de17461d..00000000 --- a/packages/web/app/dashboard/favourites/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -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/packages/web/app/dashboard/layout.tsx b/packages/web/app/dashboard/layout.tsx deleted file mode 100644 index 31d592fb..00000000 --- a/packages/web/app/dashboard/layout.tsx +++ /dev/null @@ -1,24 +0,0 @@ -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/packages/web/app/dashboard/lists/[listId]/page.tsx b/packages/web/app/dashboard/lists/[listId]/page.tsx deleted file mode 100644 index 006fd3ad..00000000 --- a/packages/web/app/dashboard/lists/[listId]/page.tsx +++ /dev/null @@ -1,44 +0,0 @@ -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/packages/web/app/dashboard/lists/page.tsx b/packages/web/app/dashboard/lists/page.tsx deleted file mode 100644 index 88eeda47..00000000 --- a/packages/web/app/dashboard/lists/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -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/packages/web/app/dashboard/not-found.tsx b/packages/web/app/dashboard/not-found.tsx deleted file mode 100644 index 64df220c..00000000 --- a/packages/web/app/dashboard/not-found.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function NotFound() { - return ( - <div className="flex size-full"> - <div className="m-auto text-3xl">Not Found :(</div> - </div> - ); -} diff --git a/packages/web/app/dashboard/preview/[bookmarkId]/page.tsx b/packages/web/app/dashboard/preview/[bookmarkId]/page.tsx deleted file mode 100644 index 707d2b69..00000000 --- a/packages/web/app/dashboard/preview/[bookmarkId]/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -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/packages/web/app/dashboard/search/page.tsx b/packages/web/app/dashboard/search/page.tsx deleted file mode 100644 index 602f6aa0..00000000 --- a/packages/web/app/dashboard/search/page.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"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/packages/web/app/dashboard/settings/page.tsx b/packages/web/app/dashboard/settings/page.tsx deleted file mode 100644 index 38091e6c..00000000 --- a/packages/web/app/dashboard/settings/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -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/packages/web/app/dashboard/tags/[tagName]/page.tsx b/packages/web/app/dashboard/tags/[tagName]/page.tsx deleted file mode 100644 index c978b86a..00000000 --- a/packages/web/app/dashboard/tags/[tagName]/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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/packages/web/app/dashboard/tags/page.tsx b/packages/web/app/dashboard/tags/page.tsx deleted file mode 100644 index 44c164e1..00000000 --- a/packages/web/app/dashboard/tags/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -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/packages/web/app/favicon.ico b/packages/web/app/favicon.ico Binary files differdeleted file mode 100644 index 750e3c04..00000000 --- a/packages/web/app/favicon.ico +++ /dev/null diff --git a/packages/web/app/globals.css b/packages/web/app/globals.css deleted file mode 100644 index 8abdb15c..00000000 --- a/packages/web/app/globals.css +++ /dev/null @@ -1,76 +0,0 @@ -@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/packages/web/app/layout.tsx b/packages/web/app/layout.tsx deleted file mode 100644 index b1790a1f..00000000 --- a/packages/web/app/layout.tsx +++ /dev/null @@ -1,51 +0,0 @@ -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/packages/web/app/page.tsx b/packages/web/app/page.tsx deleted file mode 100644 index f467b64b..00000000 --- a/packages/web/app/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -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/packages/web/app/signin/page.tsx b/packages/web/app/signin/page.tsx deleted file mode 100644 index fed71b62..00000000 --- a/packages/web/app/signin/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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/packages/web/components.json b/packages/web/components.json deleted file mode 100644 index fa674c93..00000000 --- a/packages/web/components.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$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/packages/web/components/dashboard/bookmarks/AddLinkButton.tsx b/packages/web/components/dashboard/bookmarks/AddLinkButton.tsx deleted file mode 100644 index 5973f909..00000000 --- a/packages/web/components/dashboard/bookmarks/AddLinkButton.tsx +++ /dev/null @@ -1,102 +0,0 @@ -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/packages/web/components/dashboard/bookmarks/AddToListModal.tsx b/packages/web/components/dashboard/bookmarks/AddToListModal.tsx deleted file mode 100644 index c9fd5da0..00000000 --- a/packages/web/components/dashboard/bookmarks/AddToListModal.tsx +++ /dev/null @@ -1,168 +0,0 @@ -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/packages/web/components/dashboard/bookmarks/BookmarkCardSkeleton.tsx b/packages/web/components/dashboard/bookmarks/BookmarkCardSkeleton.tsx deleted file mode 100644 index 1f5fa433..00000000 --- a/packages/web/components/dashboard/bookmarks/BookmarkCardSkeleton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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/packages/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/packages/web/components/dashboard/bookmarks/BookmarkOptions.tsx deleted file mode 100644 index 4f08ebee..00000000 --- a/packages/web/components/dashboard/bookmarks/BookmarkOptions.tsx +++ /dev/null @@ -1,185 +0,0 @@ -"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/packages/web/components/dashboard/bookmarks/BookmarkPreview.tsx b/packages/web/components/dashboard/bookmarks/BookmarkPreview.tsx deleted file mode 100644 index 2a8ae1b1..00000000 --- a/packages/web/components/dashboard/bookmarks/BookmarkPreview.tsx +++ /dev/null @@ -1,101 +0,0 @@ -"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/packages/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx b/packages/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx deleted file mode 100644 index a5b58f1a..00000000 --- a/packages/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx +++ /dev/null @@ -1,109 +0,0 @@ -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/packages/web/components/dashboard/bookmarks/BookmarkedTextViewer.tsx b/packages/web/components/dashboard/bookmarks/BookmarkedTextViewer.tsx deleted file mode 100644 index 8a620341..00000000 --- a/packages/web/components/dashboard/bookmarks/BookmarkedTextViewer.tsx +++ /dev/null @@ -1,20 +0,0 @@ -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/packages/web/components/dashboard/bookmarks/Bookmarks.tsx b/packages/web/components/dashboard/bookmarks/Bookmarks.tsx deleted file mode 100644 index 1ad3670c..00000000 --- a/packages/web/components/dashboard/bookmarks/Bookmarks.tsx +++ /dev/null @@ -1,32 +0,0 @@ -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/packages/web/components/dashboard/bookmarks/BookmarksGrid.tsx b/packages/web/components/dashboard/bookmarks/BookmarksGrid.tsx deleted file mode 100644 index 4d5b6b0a..00000000 --- a/packages/web/components/dashboard/bookmarks/BookmarksGrid.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"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/packages/web/components/dashboard/bookmarks/LinkCard.tsx b/packages/web/components/dashboard/bookmarks/LinkCard.tsx deleted file mode 100644 index 50f30e47..00000000 --- a/packages/web/components/dashboard/bookmarks/LinkCard.tsx +++ /dev/null @@ -1,114 +0,0 @@ -"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/packages/web/components/dashboard/bookmarks/TagList.tsx b/packages/web/components/dashboard/bookmarks/TagList.tsx deleted file mode 100644 index 6c9d2d22..00000000 --- a/packages/web/components/dashboard/bookmarks/TagList.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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/packages/web/components/dashboard/bookmarks/TagModal.tsx b/packages/web/components/dashboard/bookmarks/TagModal.tsx deleted file mode 100644 index 8c09d00e..00000000 --- a/packages/web/components/dashboard/bookmarks/TagModal.tsx +++ /dev/null @@ -1,207 +0,0 @@ -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/packages/web/components/dashboard/bookmarks/TextCard.tsx b/packages/web/components/dashboard/bookmarks/TextCard.tsx deleted file mode 100644 index 2565e69d..00000000 --- a/packages/web/components/dashboard/bookmarks/TextCard.tsx +++ /dev/null @@ -1,94 +0,0 @@ -"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/packages/web/components/dashboard/bookmarks/TopNav.tsx b/packages/web/components/dashboard/bookmarks/TopNav.tsx deleted file mode 100644 index 6c0f18e5..00000000 --- a/packages/web/components/dashboard/bookmarks/TopNav.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"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/packages/web/components/dashboard/lists/AllListsView.tsx b/packages/web/components/dashboard/lists/AllListsView.tsx deleted file mode 100644 index 81f31cde..00000000 --- a/packages/web/components/dashboard/lists/AllListsView.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"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/packages/web/components/dashboard/lists/DeleteListButton.tsx b/packages/web/components/dashboard/lists/DeleteListButton.tsx deleted file mode 100644 index 5303b217..00000000 --- a/packages/web/components/dashboard/lists/DeleteListButton.tsx +++ /dev/null @@ -1,77 +0,0 @@ -"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/packages/web/components/dashboard/lists/ListView.tsx b/packages/web/components/dashboard/lists/ListView.tsx deleted file mode 100644 index 2d48d9e3..00000000 --- a/packages/web/components/dashboard/lists/ListView.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"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/packages/web/components/dashboard/search/SearchInput.tsx b/packages/web/components/dashboard/search/SearchInput.tsx deleted file mode 100644 index 73d14c90..00000000 --- a/packages/web/components/dashboard/search/SearchInput.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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/packages/web/components/dashboard/settings/AddApiKey.tsx b/packages/web/components/dashboard/settings/AddApiKey.tsx deleted file mode 100644 index a4fd9c25..00000000 --- a/packages/web/components/dashboard/settings/AddApiKey.tsx +++ /dev/null @@ -1,167 +0,0 @@ -"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'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/packages/web/components/dashboard/settings/ApiKeySettings.tsx b/packages/web/components/dashboard/settings/ApiKeySettings.tsx deleted file mode 100644 index 1598f25f..00000000 --- a/packages/web/components/dashboard/settings/ApiKeySettings.tsx +++ /dev/null @@ -1,49 +0,0 @@ -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/packages/web/components/dashboard/settings/DeleteApiKey.tsx b/packages/web/components/dashboard/settings/DeleteApiKey.tsx deleted file mode 100644 index 566136af..00000000 --- a/packages/web/components/dashboard/settings/DeleteApiKey.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"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 "{name}"? 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/packages/web/components/dashboard/sidebar/AllLists.tsx b/packages/web/components/dashboard/sidebar/AllLists.tsx deleted file mode 100644 index a77252d0..00000000 --- a/packages/web/components/dashboard/sidebar/AllLists.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"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/packages/web/components/dashboard/sidebar/ModileSidebar.tsx b/packages/web/components/dashboard/sidebar/ModileSidebar.tsx deleted file mode 100644 index 4bd6a347..00000000 --- a/packages/web/components/dashboard/sidebar/ModileSidebar.tsx +++ /dev/null @@ -1,24 +0,0 @@ -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/packages/web/components/dashboard/sidebar/ModileSidebarItem.tsx b/packages/web/components/dashboard/sidebar/ModileSidebarItem.tsx deleted file mode 100644 index 9389d2e4..00000000 --- a/packages/web/components/dashboard/sidebar/ModileSidebarItem.tsx +++ /dev/null @@ -1,27 +0,0 @@ -"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/packages/web/components/dashboard/sidebar/NewListModal.tsx b/packages/web/components/dashboard/sidebar/NewListModal.tsx deleted file mode 100644 index f51616ed..00000000 --- a/packages/web/components/dashboard/sidebar/NewListModal.tsx +++ /dev/null @@ -1,170 +0,0 @@ -"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/packages/web/components/dashboard/sidebar/Sidebar.tsx b/packages/web/components/dashboard/sidebar/Sidebar.tsx deleted file mode 100644 index a5c1d7a5..00000000 --- a/packages/web/components/dashboard/sidebar/Sidebar.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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/packages/web/components/dashboard/sidebar/SidebarItem.tsx b/packages/web/components/dashboard/sidebar/SidebarItem.tsx deleted file mode 100644 index 856bdffd..00000000 --- a/packages/web/components/dashboard/sidebar/SidebarItem.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"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/packages/web/components/dashboard/sidebar/SidebarProfileOptions.tsx b/packages/web/components/dashboard/sidebar/SidebarProfileOptions.tsx deleted file mode 100644 index f931b63e..00000000 --- a/packages/web/components/dashboard/sidebar/SidebarProfileOptions.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"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/packages/web/components/signin/CredentialsForm.tsx b/packages/web/components/signin/CredentialsForm.tsx deleted file mode 100644 index 5296e163..00000000 --- a/packages/web/components/signin/CredentialsForm.tsx +++ /dev/null @@ -1,222 +0,0 @@ -"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/packages/web/components/signin/SignInForm.tsx b/packages/web/components/signin/SignInForm.tsx deleted file mode 100644 index 7c8f8936..00000000 --- a/packages/web/components/signin/SignInForm.tsx +++ /dev/null @@ -1,37 +0,0 @@ -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/packages/web/components/signin/SignInProviderButton.tsx b/packages/web/components/signin/SignInProviderButton.tsx deleted file mode 100644 index 0831236c..00000000 --- a/packages/web/components/signin/SignInProviderButton.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"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/packages/web/components/ui/action-button.tsx b/packages/web/components/ui/action-button.tsx deleted file mode 100644 index 42e16f65..00000000 --- a/packages/web/components/ui/action-button.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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/packages/web/components/ui/back-button.tsx b/packages/web/components/ui/back-button.tsx deleted file mode 100644 index 685930df..00000000 --- a/packages/web/components/ui/back-button.tsx +++ /dev/null @@ -1,9 +0,0 @@ -"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/packages/web/components/ui/badge.tsx b/packages/web/components/ui/badge.tsx deleted file mode 100644 index c30daca1..00000000 --- a/packages/web/components/ui/badge.tsx +++ /dev/null @@ -1,36 +0,0 @@ -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/packages/web/components/ui/button.tsx b/packages/web/components/ui/button.tsx deleted file mode 100644 index 79b45fa0..00000000 --- a/packages/web/components/ui/button.tsx +++ /dev/null @@ -1,56 +0,0 @@ -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/packages/web/components/ui/card.tsx b/packages/web/components/ui/card.tsx deleted file mode 100644 index f4e57996..00000000 --- a/packages/web/components/ui/card.tsx +++ /dev/null @@ -1,86 +0,0 @@ -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/packages/web/components/ui/dialog.tsx b/packages/web/components/ui/dialog.tsx deleted file mode 100644 index 8fe3fe35..00000000 --- a/packages/web/components/ui/dialog.tsx +++ /dev/null @@ -1,122 +0,0 @@ -"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/packages/web/components/ui/dropdown-menu.tsx b/packages/web/components/ui/dropdown-menu.tsx deleted file mode 100644 index 3a9a2ff7..00000000 --- a/packages/web/components/ui/dropdown-menu.tsx +++ /dev/null @@ -1,200 +0,0 @@ -"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/packages/web/components/ui/form.tsx b/packages/web/components/ui/form.tsx deleted file mode 100644 index e62e10e9..00000000 --- a/packages/web/components/ui/form.tsx +++ /dev/null @@ -1,177 +0,0 @@ -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/packages/web/components/ui/imageCard.tsx b/packages/web/components/ui/imageCard.tsx deleted file mode 100644 index f10ebdb5..00000000 --- a/packages/web/components/ui/imageCard.tsx +++ /dev/null @@ -1,70 +0,0 @@ -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/packages/web/components/ui/input.tsx b/packages/web/components/ui/input.tsx deleted file mode 100644 index 21aac7ad..00000000 --- a/packages/web/components/ui/input.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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/packages/web/components/ui/label.tsx b/packages/web/components/ui/label.tsx deleted file mode 100644 index 84f8b0c7..00000000 --- a/packages/web/components/ui/label.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"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/packages/web/components/ui/popover.tsx b/packages/web/components/ui/popover.tsx deleted file mode 100644 index a361ba7d..00000000 --- a/packages/web/components/ui/popover.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"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/packages/web/components/ui/scroll-area.tsx b/packages/web/components/ui/scroll-area.tsx deleted file mode 100644 index 32cb6022..00000000 --- a/packages/web/components/ui/scroll-area.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"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/packages/web/components/ui/select.tsx b/packages/web/components/ui/select.tsx deleted file mode 100644 index efd4ff1e..00000000 --- a/packages/web/components/ui/select.tsx +++ /dev/null @@ -1,160 +0,0 @@ -"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/packages/web/components/ui/separator.tsx b/packages/web/components/ui/separator.tsx deleted file mode 100644 index 3b9f2b84..00000000 --- a/packages/web/components/ui/separator.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"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/packages/web/components/ui/skeleton.tsx b/packages/web/components/ui/skeleton.tsx deleted file mode 100644 index 5fab2023..00000000 --- a/packages/web/components/ui/skeleton.tsx +++ /dev/null @@ -1,15 +0,0 @@ -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/packages/web/components/ui/spinner.tsx b/packages/web/components/ui/spinner.tsx deleted file mode 100644 index adcd2807..00000000 --- a/packages/web/components/ui/spinner.tsx +++ /dev/null @@ -1,20 +0,0 @@ -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/packages/web/components/ui/table.tsx b/packages/web/components/ui/table.tsx deleted file mode 100644 index 0fa9288e..00000000 --- a/packages/web/components/ui/table.tsx +++ /dev/null @@ -1,117 +0,0 @@ -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/packages/web/components/ui/tabs.tsx b/packages/web/components/ui/tabs.tsx deleted file mode 100644 index 990017db..00000000 --- a/packages/web/components/ui/tabs.tsx +++ /dev/null @@ -1,55 +0,0 @@ -"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/packages/web/components/ui/textarea.tsx b/packages/web/components/ui/textarea.tsx deleted file mode 100644 index a0de3371..00000000 --- a/packages/web/components/ui/textarea.tsx +++ /dev/null @@ -1,24 +0,0 @@ -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/packages/web/components/ui/toast.tsx b/packages/web/components/ui/toast.tsx deleted file mode 100644 index 0d162dca..00000000 --- a/packages/web/components/ui/toast.tsx +++ /dev/null @@ -1,127 +0,0 @@ -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/packages/web/components/ui/toaster.tsx b/packages/web/components/ui/toaster.tsx deleted file mode 100644 index 7d82ed55..00000000 --- a/packages/web/components/ui/toaster.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"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/packages/web/components/ui/use-toast.ts b/packages/web/components/ui/use-toast.ts deleted file mode 100644 index 5491e140..00000000 --- a/packages/web/components/ui/use-toast.ts +++ /dev/null @@ -1,189 +0,0 @@ -// 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/packages/web/lib/bookmarkUtils.tsx b/packages/web/lib/bookmarkUtils.tsx deleted file mode 100644 index a2828c29..00000000 --- a/packages/web/lib/bookmarkUtils.tsx +++ /dev/null @@ -1,22 +0,0 @@ -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/packages/web/lib/hooks/bookmark-search.ts b/packages/web/lib/hooks/bookmark-search.ts deleted file mode 100644 index 738e1bd8..00000000 --- a/packages/web/lib/hooks/bookmark-search.ts +++ /dev/null @@ -1,73 +0,0 @@ -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/packages/web/lib/providers.tsx b/packages/web/lib/providers.tsx deleted file mode 100644 index 5c4649b5..00000000 --- a/packages/web/lib/providers.tsx +++ /dev/null @@ -1,75 +0,0 @@ -"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/packages/web/lib/trpc.tsx b/packages/web/lib/trpc.tsx deleted file mode 100644 index 79a2a9fe..00000000 --- a/packages/web/lib/trpc.tsx +++ /dev/null @@ -1,5 +0,0 @@ -"use client"; -import type { AppRouter } from "@hoarder/trpc/routers/_app"; -import { createTRPCReact } from "@trpc/react-query"; - -export const api = createTRPCReact<AppRouter>(); diff --git a/packages/web/lib/utils.ts b/packages/web/lib/utils.ts deleted file mode 100644 index 365058ce..00000000 --- a/packages/web/lib/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} diff --git a/packages/web/next.config.mjs b/packages/web/next.config.mjs deleted file mode 100644 index bda43a58..00000000 --- a/packages/web/next.config.mjs +++ /dev/null @@ -1,43 +0,0 @@ -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", - }, - ], - }, - ]; - }, -}); - -export default nextConfig; diff --git a/packages/web/package.json b/packages/web/package.json deleted file mode 100644 index e0c9d407..00000000 --- a/packages/web/package.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/package.json", - "name": "@hoarder/web", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint", - "test": "vitest", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@auth/drizzle-adapter": "^0.8.0", - "@emoji-mart/data": "^1.1.2", - "@emoji-mart/react": "^1.1.1", - "@hoarder/db": "0.1.0", - "@hoarder/shared": "0.1.0", - "@hoarder/trpc": "0.1.0", - "@hookform/resolvers": "^3.3.4", - "@next/eslint-plugin-next": "^14.1.1", - "@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.6", - "@tanstack/react-query-devtools": "^5.21.0", - "@trpc/client": "11.0.0-next-beta.304", - "@trpc/next": "11.0.0-next-beta.304", - "@trpc/react-query": "^11.0.0-next-beta.304", - "@trpc/server": "11.0.0-next-beta.304", - "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.322.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", - "react-dom": "^18", - "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": { - "@tailwindcss/typography": "^0.5.10", - "@types/emoji-mart": "^3.0.14", - "@types/react": "^18", - "@types/react-dom": "^18", - "autoprefixer": "^10.0.1", - "postcss": "^8", - "tailwindcss": "^3.3.0", - "ts-node": "^10.9.2", - "vite-tsconfig-paths": "^4.3.1", - "vitest": "^1.3.1" - } -} diff --git a/packages/web/postcss.config.js b/packages/web/postcss.config.js deleted file mode 100644 index 12a703d9..00000000 --- a/packages/web/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/packages/web/public/blur.avif b/packages/web/public/blur.avif Binary files differdeleted file mode 100644 index cbc6cd37..00000000 --- a/packages/web/public/blur.avif +++ /dev/null diff --git a/packages/web/public/icons/logo-128.png b/packages/web/public/icons/logo-128.png Binary files differdeleted file mode 100644 index 71ead90c..00000000 --- a/packages/web/public/icons/logo-128.png +++ /dev/null diff --git a/packages/web/public/icons/logo-16.png b/packages/web/public/icons/logo-16.png Binary files differdeleted file mode 100644 index dd864d44..00000000 --- a/packages/web/public/icons/logo-16.png +++ /dev/null diff --git a/packages/web/public/icons/logo-48.png b/packages/web/public/icons/logo-48.png Binary files differdeleted file mode 100644 index 7ba1cd49..00000000 --- a/packages/web/public/icons/logo-48.png +++ /dev/null diff --git a/packages/web/public/manifest.json b/packages/web/public/manifest.json deleted file mode 100644 index b42343f6..00000000 --- a/packages/web/public/manifest.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "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/packages/web/server/api/client.ts b/packages/web/server/api/client.ts deleted file mode 100644 index 88ea7a0e..00000000 --- a/packages/web/server/api/client.ts +++ /dev/null @@ -1,16 +0,0 @@ -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/packages/web/server/auth.ts b/packages/web/server/auth.ts deleted file mode 100644 index 950443b9..00000000 --- a/packages/web/server/auth.ts +++ /dev/null @@ -1,96 +0,0 @@ -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/packages/web/tailwind.config.ts b/packages/web/tailwind.config.ts deleted file mode 100644 index 521ba51c..00000000 --- a/packages/web/tailwind.config.ts +++ /dev/null @@ -1,89 +0,0 @@ -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/packages/web/tsconfig.json b/packages/web/tsconfig.json deleted file mode 100644 index ecbd5643..00000000 --- a/packages/web/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "target": "ES6", - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts deleted file mode 100644 index c3d02f71..00000000 --- a/packages/web/vitest.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -/// <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/packages/workers/crawler.ts b/packages/workers/crawler.ts deleted file mode 100644 index 5db2da7b..00000000 --- a/packages/workers/crawler.ts +++ /dev/null @@ -1,201 +0,0 @@ -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/packages/workers/index.ts b/packages/workers/index.ts deleted file mode 100644 index 295eeaef..00000000 --- a/packages/workers/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import "dotenv/config"; -import { CrawlerWorker } from "./crawler"; -import { OpenAiWorker } from "./openai"; -import { SearchIndexingWorker } from "./search"; - -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/packages/workers/openai.ts b/packages/workers/openai.ts deleted file mode 100644 index 1ec22d32..00000000 --- a/packages/workers/openai.ts +++ /dev/null @@ -1,263 +0,0 @@ -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/packages/workers/package.json b/packages/workers/package.json deleted file mode 100644 index f2fc164c..00000000 --- a/packages/workers/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/package.json", - "name": "@hoarder/workers", - "version": "0.1.0", - "private": true, - "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.26.1", - "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", - "zod": "^3.22.4" - }, - "devDependencies": { - "@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" - } -} diff --git a/packages/workers/search.ts b/packages/workers/search.ts deleted file mode 100644 index 618e7c89..00000000 --- a/packages/workers/search.ts +++ /dev/null @@ -1,116 +0,0 @@ -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; - } - } -} |
