├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── (dashboard) │ ├── layout.tsx │ └── mind │ │ ├── [id] │ │ └── page.tsx │ │ └── page.tsx ├── (homepage) │ └── (auth) │ │ └── sign-in │ │ └── page.tsx ├── api │ ├── [userId] │ │ ├── bookmark │ │ │ ├── [id] │ │ │ │ ├── route.ts │ │ │ │ └── tag │ │ │ │ │ └── [tagId] │ │ │ │ │ └── route.tsx │ │ │ └── route.ts │ │ ├── folder │ │ │ └── route.ts │ │ └── tag │ │ │ ├── [id] │ │ │ └── route.ts │ │ │ └── route.ts │ ├── auth │ │ └── [...nextauth] │ │ │ └── route.ts │ └── hello │ │ └── route.ts ├── favicon.ico ├── globals.css ├── layout.tsx ├── page.tsx └── utils │ ├── action.ts │ ├── definition.ts │ ├── queryKeys.ts │ └── response.ts ├── components.json ├── components ├── container │ ├── homepage │ │ └── navbar │ │ │ └── index.tsx │ ├── mind │ │ ├── actions │ │ │ └── BookmarkActions.tsx │ │ ├── bookmarks │ │ │ ├── BookmarkCardView.tsx │ │ │ ├── BookmarkDragOverlay.tsx │ │ │ ├── BookmarkList.tsx │ │ │ └── BookmarkListView.tsx │ │ ├── control │ │ │ └── index.tsx │ │ ├── dialog │ │ │ ├── BookmarkDialog.tsx │ │ │ ├── BookmarkRecommendationDialog.tsx │ │ │ └── DeleteConfirmationDialog.tsx │ │ ├── header │ │ │ └── index.tsx │ │ └── sidebar │ │ │ ├── CreateFolder.tsx │ │ │ ├── MenuItem.tsx │ │ │ ├── RenameItem.tsx │ │ │ └── index.tsx │ └── provider.tsx ├── skeleton │ └── bookmark-skeleton.tsx └── ui │ ├── accordion.tsx │ ├── alert-dialog.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── command.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── index.tsx │ ├── input.tsx │ ├── magic │ ├── bento-grid.tsx │ ├── index.tsx │ └── marquee.tsx │ ├── multiple-selector.tsx │ ├── popover.tsx │ ├── select.tsx │ └── tooltip.tsx ├── drizzle.config.ts ├── drizzle ├── 0000_real_quasimodo.sql ├── health.ts ├── meta │ ├── 0000_snapshot.json │ └── _journal.json └── schema │ └── index.ts ├── hooks ├── createBookmark.ts ├── createFolder.ts ├── createTagInBookmark.ts ├── deleteBookmark.ts ├── deleteFolder.ts ├── deleteTag.ts ├── deleteTagInBookmark.ts ├── index.ts ├── renameFolder.ts ├── renameTag.ts ├── useBookmarks.ts ├── useDebounce.ts ├── useFolder.ts ├── useTagNotInBookmark.ts ├── useTags.ts ├── useUpdateBookmarkFolder.ts └── useUserId.ts ├── lib ├── auth.ts ├── database.ts ├── queryClient.ts └── utils.ts ├── middleware.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── image │ ├── hero.png │ ├── hooknhold-preview.gif │ ├── hooknhold-preview.png │ ├── icon │ │ ├── github.png │ │ └── google.png │ └── tailwind.png ├── next.svg └── vercel.svg ├── tailwind.config.ts ├── tmp └── tmp.md └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Base URL for the API 2 | API_BASE_URL="http://localhost:3002/api" 3 | 4 | # NextAuth configuration 5 | NEXTAUTH_URL="http://localhost:3002" 6 | NEXTAUTH_SECRET="your_nextauth_secret" 7 | 8 | # Database connection string 9 | DATABASE_URL="postgresql://username:password@host:port/database?sslmode=require" 10 | 11 | # GitHub OAuth credentials 12 | GITHUB_CLIENT_ID="your_github_client_id" 13 | GITHUB_CLIENT_SECRET="your_github_client_secret" 14 | 15 | # AWS S3 configuration 16 | AWS_REGION="your_aws_region" 17 | AWS_BUCKET_NAME="your_bucket_name" 18 | AWS_ACCESS_KEY_ID="your_aws_access_key_id" 19 | AWS_SECRET_ACCESS_KEY="your_aws_secret_access_key" 20 | AWS_IMAGE_URL="https://your-bucket-name.s3.your-region.amazonaws.com/" 21 | 22 | # Set this to "local" for local storage, or "s3" for AWS S3 storage 23 | STORAGE_TYPE="local" 24 | 25 | # Local storage directory (only used if STORAGE_TYPE is "local") 26 | LOCAL_STORAGE_PATH="D:/hooknhold/tmp" -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | .env -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HooknHold 2 | 3 | HooknHold is an open-source project built with [Next.js](https://nextjs.org/) that empowers users to efficiently bookmark, organize, and manage their favorite web content. 4 | 5 | ![HooknHold Preview](./public/image/hooknhold-preview.gif) 6 | 7 | ## Features 8 | 9 | - **Smart Bookmarking**: Easily save and categorize web pages with automatic metadata extraction. 10 | - **Intuitive Organization**: Create custom folders and tags for effortless content management. 11 | - **Drag-and-Drop Interface**: Seamlessly reorganize bookmarks and folders with a user-friendly drag-and-drop system. 12 | - **Full Search**: Quickly find your bookmarks. 13 | 14 | ## Getting Started 15 | 16 | ### Prerequisites 17 | 18 | - Node.js 19 | - pnpm, npm, or yarn 20 | - PostgreSQL database 21 | - AWS S3 bucket (optional, for cloud storage) 22 | - GitHub account (for OAuth) 23 | 24 | ### Installation 25 | 26 | 1. Clone the repository: 27 | ```bash 28 | git clone https://github.com/dendianugerah/hooknhold.git 29 | cd hooknhold 30 | ``` 31 | 32 | 2. Install dependencies: 33 | ```bash 34 | pnpm install 35 | # or 36 | npm install 37 | # or 38 | yarn install 39 | ``` 40 | 41 | 3. Set up environment variables: 42 | Copy the `.env.example` file to `.env` and fill in the required values. 43 | 44 | 4. Run database migrations: 45 | ```bash 46 | pnpm run migrate 47 | # or 48 | npm run migrate 49 | # or 50 | yarn migrate 51 | ``` 52 | 53 | 5. Start the development server: 54 | ```bash 55 | pnpm run dev 56 | # or 57 | npm run dev 58 | # or 59 | yarn dev 60 | ``` 61 | 62 | 6. Open [http://localhost:3002](http://localhost:3002) with your browser to see the result. 63 | 64 | ## Usage 65 | 66 | 1. Sign in with your GitHub account. 67 | 2. Add bookmarks by pasting URLs or using the "Add Bookmark" button. 68 | 3. Organize your bookmarks into folders and add tags for easy categorization. 69 | 4. Use the search function to quickly find specific bookmarks. 70 | 5. Drag and drop bookmarks to rearrange or move them between folders. 71 | 72 | ## Tech Stack 73 | 74 | - [Next.js](https://nextjs.org/) - React framework for server-side rendering and static site generation 75 | - [NextAuth.js](https://next-auth.js.org/) - Authentication for Next.js applications 76 | - [Drizzle ORM](https://orm.drizzle.team/) - TypeScript ORM for SQL databases 77 | - [PostgreSQL](https://www.postgresql.org/) - Open-source relational database 78 | - [AWS S3](https://aws.amazon.com/s3/) - Cloud object storage (mandatory for now) 79 | - [TypeScript](https://www.typescriptlang.org/) - Typed superset of JavaScript 80 | - [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS framework 81 | 82 | ## Upcoming Features 83 | 84 | - [V] Hybrid storage system: Option to store bookmarks in local directory or AWS S3 (Still need to test) 85 | - [ ] Code refactoring for improved performance and maintainability 86 | - [ ] Browser extension for quick bookmarking 87 | - [ ] Sharing bookmarks with other users 88 | - [ ] AI-powered website recommendations based on bookmark history 89 | - [ ] Comprehensive API documentation 90 | - [ ] Integration tests for core functionalities 91 | -------------------------------------------------------------------------------- /app/(dashboard)/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { createContext, useState } from "react"; 3 | import { QueryClientProvider } from "react-query"; 4 | 5 | import queryClient from "@/lib/queryClient"; 6 | import useDebounce from "@/hooks/useDebounce"; 7 | import HeaderSection from "@/components/container/mind/header"; 8 | 9 | interface LayoutProps { 10 | children: React.ReactNode; 11 | } 12 | 13 | export const SearchContext = createContext({ 14 | search: "", 15 | setSearch: (search: string) => {}, 16 | }); 17 | 18 | export const AccordionContext = createContext({ 19 | openItems: {} as Record, 20 | setOpenItems: (items: React.SetStateAction>) => {}, 21 | }); 22 | 23 | const Layout: React.FC = ({ children }) => { 24 | const [search, setSearch] = useState(""); 25 | const [isSidebarOpen, setIsSidebarOpen] = useState(false); 26 | const [openItems, setOpenItems] = useState>({}); 27 | const debounceSearch = useDebounce(search, 300); 28 | 29 | return ( 30 | 31 | 32 | 33 |
34 | 38 |
{children}
39 |
40 |
41 |
42 |
43 | ); 44 | }; 45 | 46 | export default Layout; 47 | -------------------------------------------------------------------------------- /app/(dashboard)/mind/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import Mind from "../page"; 2 | 3 | export default function Folder({ params }: { params: { id: string } }) { 4 | const { id } = params; 5 | 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /app/(dashboard)/mind/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import useUserId from "@/hooks/useUserId"; 3 | import ControlSection from "@/components/container/mind/control"; 4 | import BookmarkList from "@/components/container/mind/bookmarks/BookmarkList"; 5 | import BookmarkActions from "@/components/container/mind/actions/BookmarkActions"; 6 | import BookmarkRecommendation from "@/components/container/mind/dialog/BookmarkRecommendationDialog"; 7 | import { SearchContext } from "../layout"; 8 | import { useContext, useState } from "react"; 9 | import { usePathname } from "next/navigation"; 10 | import { useDeleteBookmark, useBookmarks } from "@/hooks"; 11 | import { 12 | DndContext, 13 | DragOverlay, 14 | DragEndEvent, 15 | DragStartEvent, 16 | useSensor, 17 | useSensors, 18 | PointerSensor, 19 | MouseSensor, 20 | } from "@dnd-kit/core"; 21 | import { useUpdateBookmarkFolder } from "@/hooks/useUpdateBookmarkFolder"; 22 | import { BookmarkDragOverlay } from "@/components/container/mind/bookmarks/BookmarkDragOverlay"; 23 | import SidebarSection from "@/components/container/mind/sidebar"; 24 | 25 | interface MindProps { 26 | folderId?: string; 27 | } 28 | 29 | export default function Mind({ folderId }: MindProps) { 30 | const userId = useUserId(); 31 | const [isOpen, setIsOpen] = useState(false); 32 | const [isCardView, setIsCardView] = useState(true); 33 | const [isRecommendationsOpen, setIsRecommendationsOpen] = useState(false); 34 | // const [activeId, setActiveId] = useState(null); 35 | const updateBookmarkFolder = useUpdateBookmarkFolder(userId); 36 | const [isSidebarOpen, setIsSidebarOpen] = useState(true); 37 | const [isDragging, setIsDragging] = useState(false); 38 | const [draggedTitle, setDraggedTitle] = useState(null); 39 | 40 | const { search } = useContext(SearchContext); 41 | const { bookmarks, isLoading: isLoadBookmarks } = useBookmarks( 42 | userId, 43 | folderId, 44 | search 45 | ); 46 | 47 | const deleteBookmark = useDeleteBookmark(userId); 48 | 49 | const pathname = usePathname(); 50 | const isMindRoute = pathname === "/mind"; 51 | 52 | const sensors = useSensors( 53 | useSensor(PointerSensor, { 54 | activationConstraint: { 55 | distance: 8, 56 | }, 57 | }), 58 | useSensor(MouseSensor, { 59 | activationConstraint: { 60 | distance: 8, 61 | }, 62 | }) 63 | ); 64 | 65 | const handleDragStart = (event: DragStartEvent) => { 66 | const { active } = event; 67 | // setActiveId(active.id as string); 68 | setDraggedTitle(active.data.current?.title || "Untitled"); 69 | setIsDragging(true); 70 | }; 71 | 72 | const handleDragEnd = (event: DragEndEvent) => { 73 | const { active, over } = event; 74 | 75 | if ( 76 | over && 77 | active.data.current?.type === "bookmark" && 78 | over.data.current?.type === "folder" 79 | ) { 80 | updateBookmarkFolder.mutate({ 81 | bookmarkId: active.id as string, 82 | folderId: over.id as string, 83 | }); 84 | } 85 | // setActiveId(null); 86 | setDraggedTitle(null); 87 | setIsDragging(false); 88 | }; 89 | 90 | return ( 91 | 96 |
97 | 98 |
103 |
104 | 108 | 115 |
116 | deleteBookmark.mutate(id)} 121 | /> 122 | 126 |
127 |
128 |
129 | 130 | {isDragging && draggedTitle && ( 131 | 132 | )} 133 | 134 |
135 |
136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /app/(homepage)/(auth)/sign-in/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import Link from "next/link"; 3 | import Image from "next/image"; 4 | import { Button } from "@/components/ui"; 5 | import { signIn } from "next-auth/react"; 6 | import { Package2 } from "lucide-react"; 7 | 8 | export default function SignIn() { 9 | return ( 10 |
11 |
12 |
13 | 17 | 18 |

Hooknhold.

19 | 20 |
21 |
22 |
23 |
24 |
25 |
26 |

Sign in to Hooknhold

27 | 40 |
41 | {/*

42 | By signing in, you agree to our{" "} 43 | 44 | Terms of Service 45 | {" "} 46 | and{" "} 47 | 48 | Privacy Policy 49 | 50 | . 51 |

*/} 52 |
53 |
54 |
55 |
56 |

57 | Welcome to Hooknhold 58 |

59 |

60 | Hooknhold is your ultimate bookmark management tool. Organize, 61 | discover, and share your favorite web content effortlessly. 62 |

63 |

64 | With Hooknhold, you can easily save and categorize your bookmarks, 65 | access them from any device, and collaborate with others. 66 | Start streamlining your online experience today! 67 |

68 |
69 |
70 |
71 |
72 |
73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /app/api/[userId]/bookmark/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server"; 2 | import db, { bookmark, bookmark_tag, tag } from "@/lib/database"; 3 | import { Response } from "@/app/utils/response"; 4 | import { and, eq, not, notExists } from "drizzle-orm"; 5 | 6 | export async function DELETE( 7 | req: NextRequest, 8 | query: { params: { userId: string; id: string } } 9 | ) { 10 | try { 11 | const userId = query.params.userId; 12 | const bookmarkId = query.params.id; 13 | 14 | await db.transaction(async (db) => { 15 | await db 16 | .delete(bookmark_tag) 17 | .where(eq(bookmark_tag.bookmark_id, bookmarkId)); 18 | 19 | await db 20 | .delete(bookmark) 21 | .where(and(eq(bookmark.user_id, userId), eq(bookmark.id, bookmarkId))); 22 | }); 23 | 24 | return Response(null, 200, "Bookmark deleted successfully"); 25 | } catch (error) { 26 | console.error(error); 27 | return Response(null, 500, "An error occurred"); 28 | } 29 | } 30 | 31 | export async function POST( 32 | req: NextRequest, 33 | query: { params: { userId: string; id: string } } 34 | ) { 35 | try { 36 | const bookmarkId = query.params.id; 37 | const body = await req.json(); 38 | let { tag_id } = body; 39 | 40 | if (!Array.isArray(tag_id)) { 41 | tag_id = [tag_id]; 42 | } 43 | 44 | tag_id = tag_id.map((id: string) => id.replace(/[\[\]]/g, "").trim()); 45 | 46 | await db.transaction(async (tx) => { 47 | for (const tagId of tag_id) { 48 | // Check if the tag already exists for this bookmark 49 | const existingTag = await tx 50 | .select() 51 | .from(bookmark_tag) 52 | .where( 53 | and( 54 | eq(bookmark_tag.bookmark_id, bookmarkId), 55 | eq(bookmark_tag.tag_id, tagId) 56 | ) 57 | ) 58 | .limit(1); 59 | 60 | // If the tag doesn't exist, insert it 61 | if (existingTag.length === 0) { 62 | await tx.insert(bookmark_tag).values({ 63 | bookmark_id: bookmarkId, 64 | tag_id: tagId, 65 | }); 66 | } 67 | } 68 | }); 69 | 70 | return Response(null, 200, "Bookmark tag created successfully"); 71 | } catch (error) { 72 | return Response(null, 500, "An error occurred"); 73 | } 74 | } 75 | 76 | // to get tags not associated with a bookmark 77 | export async function GET( 78 | req: NextRequest, 79 | { params }: { params: { userId: string; id: string } } 80 | ) { 81 | try { 82 | const { userId, id: bookmarkId } = params; 83 | 84 | const tags = await db 85 | .select({ 86 | id: tag.id, 87 | name: tag.name, 88 | created_at: tag.created_at, 89 | }) 90 | .from(tag) 91 | .where( 92 | and( 93 | eq(tag.user_id, userId), 94 | notExists( 95 | db 96 | .select() 97 | .from(bookmark_tag) 98 | .where( 99 | and( 100 | eq(bookmark_tag.tag_id, tag.id), 101 | eq(bookmark_tag.bookmark_id, bookmarkId) 102 | ) 103 | ) 104 | ) 105 | ) 106 | ) 107 | 108 | return Response(tags, 200, "Unassociated tags fetched successfully"); 109 | } catch (error) { 110 | console.error(error); 111 | return Response(null, 500, "An error occurred"); 112 | } 113 | } 114 | 115 | export async function PATCH( 116 | req: NextRequest, 117 | { params }: { params: { userId: string; id: string } } 118 | ) { 119 | try { 120 | const { userId, id: bookmarkId } = params; 121 | const { folder_id: folderId } = await req.json(); 122 | 123 | 124 | await db 125 | .update(bookmark) 126 | .set({ folder_id: folderId }) 127 | .where(and(eq(bookmark.id, bookmarkId), eq(bookmark.user_id, userId))); 128 | 129 | console.log("Bookmark folder updated successfully"); 130 | return Response(null, 200, "Bookmark folder updated successfully"); 131 | } catch (error) { 132 | return Response(null, 500, "Failed to update bookmark folder"); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /app/api/[userId]/bookmark/[id]/tag/[tagId]/route.tsx: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { eq, and } from 'drizzle-orm'; 3 | import { bookmark_tag } from '@/drizzle/schema'; 4 | import { Response } from '@/app/utils/response'; 5 | import db from '@/lib/database'; 6 | 7 | export async function DELETE( 8 | req: NextRequest, 9 | query: { params: { id: string; tagId: string } } 10 | ) { 11 | const { id: bookmarkId, tagId } = query.params; 12 | 13 | try { 14 | await db.delete(bookmark_tag) 15 | .where( 16 | and( 17 | eq(bookmark_tag.bookmark_id, bookmarkId), 18 | eq(bookmark_tag.tag_id, tagId) 19 | ) 20 | ); 21 | 22 | return Response("", 200, "Bookmark - Delete Tag Success") 23 | } catch (error) { 24 | return Response("", 500, "Bookmark - Delete Tag Failed") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/api/[userId]/bookmark/route.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import puppeteer from "puppeteer"; 4 | import puppeteerCore from "puppeteer-core"; 5 | import chromium from '@sparticuz/chromium'; 6 | import { v4 as uuid } from "uuid"; 7 | import { desc, eq, sql } from "drizzle-orm"; 8 | import { Upload } from "@aws-sdk/lib-storage"; 9 | import { PgSelect } from "drizzle-orm/pg-core"; 10 | import { S3Client } from "@aws-sdk/client-s3"; 11 | import { Response } from "@/app/utils/response"; 12 | import { BookmarkData } from "@/app/utils/definition"; 13 | import { NextRequest, NextResponse } from "next/server"; 14 | import db, { bookmark, bookmark_tag, tag } from "@/lib/database"; 15 | 16 | import { Browser, Page } from 'puppeteer'; 17 | import { Browser as CoreBrowser, Page as CorePage } from 'puppeteer-core'; 18 | 19 | export const maxDuration = 20; // 20 seconds 20 | export const dynamic = 'force-dynamic'; 21 | 22 | async function uploadScreenshot( 23 | data: Buffer, 24 | userId: string, 25 | filename: string 26 | ): Promise { 27 | const storageType = process.env.STORAGE_TYPE || "s3"; 28 | 29 | if (storageType === "local") { 30 | const localPath = path.join(process.env.LOCAL_STORAGE_PATH!, userId); 31 | if (!fs.existsSync(localPath)) { 32 | fs.mkdirSync(localPath, { recursive: true }); 33 | } 34 | const filePath = path.join(localPath, filename); 35 | fs.writeFileSync(filePath, data); 36 | return `file://${filePath}`; 37 | } else { 38 | const uploadInstance = new Upload({ 39 | client: new S3Client({ 40 | region: process.env.AWS_REGION, 41 | credentials: { 42 | accessKeyId: process.env.AWS_ACCESS_KEY_ID!, 43 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, 44 | }, 45 | }), 46 | params: { 47 | Bucket: process.env.AWS_BUCKET_NAME!, 48 | Key: `${userId}-${filename}`, 49 | Body: data, 50 | ACL: "public-read", 51 | }, 52 | }); 53 | 54 | await uploadInstance.done(); 55 | return `${process.env.AWS_IMAGE_URL}${userId}-${filename}`; 56 | } 57 | } 58 | 59 | async function handleTags(tags: string[], userId: string, bookmarkId: string) { 60 | const tagPromises = tags.map(async (tagName) => { 61 | let [existingTag] = await db.execute( 62 | sql`SELECT * FROM tag WHERE lower(name) = lower(${tagName}) AND user_id = ${userId}` 63 | ); 64 | let tagId: string; 65 | 66 | if (!existingTag) { 67 | tagId = uuid(); 68 | await db.insert(tag).values({ 69 | id: tagId, 70 | user_id: userId, 71 | name: tagName, 72 | }); 73 | } else { 74 | tagId = existingTag.id as string; 75 | } 76 | 77 | return { bookmark_id: bookmarkId, tag_id: tagId }; 78 | }); 79 | 80 | const tagValues = await Promise.all(tagPromises); 81 | if (tagValues.length > 0) { 82 | await db.insert(bookmark_tag).values(tagValues); 83 | } 84 | } 85 | 86 | export async function POST( 87 | req: NextRequest, 88 | query: { params: { userId: string } } 89 | ) { 90 | const body = await req.json(); 91 | const userId = query.params.userId; 92 | const { url, folder_id, tags: rawTags } = body; 93 | 94 | const tags = Array.isArray(rawTags) ? rawTags : rawTags ? [rawTags] : []; 95 | 96 | try { 97 | const tmpDir = `/tmp/`; 98 | const filename = `${Math.random()}.jpg`; 99 | 100 | let browser: Browser | CoreBrowser; 101 | let page: Page | CorePage; 102 | 103 | if (process.env.NODE_ENV === 'production') { 104 | const executablePath = await chromium.executablePath(); 105 | browser = await puppeteerCore.launch({ 106 | executablePath, 107 | args: chromium.args, 108 | defaultViewport: chromium.defaultViewport, 109 | headless: chromium.headless, 110 | }) as CoreBrowser; 111 | } else { 112 | browser = await puppeteer.launch({ 113 | headless: true, 114 | args: ['--no-sandbox', '--disable-setuid-sandbox'] 115 | }) as Browser; 116 | } 117 | 118 | page = await browser.newPage(); 119 | const typedPage = page as Page; 120 | 121 | await typedPage.setViewport({ width: 1200, height: 600 }); 122 | await typedPage.goto(url as string, { waitUntil: 'networkidle0' }); 123 | 124 | const screenshot = await typedPage.screenshot({ 125 | type: "jpeg", 126 | quality: 80, 127 | fullPage: false, 128 | path: tmpDir + filename, 129 | }); 130 | 131 | const title = await typedPage.evaluate(() => document.querySelector("title")?.textContent || ''); 132 | const description = await typedPage.evaluate(() => { 133 | const descriptionMeta = document.querySelector("meta[name='description']"); 134 | let content = descriptionMeta?.getAttribute("content") || ''; 135 | return content.substring(0, 255); 136 | }); 137 | 138 | await browser.close(); 139 | 140 | const bookmarkId = uuid(); 141 | const imageUrl = await uploadScreenshot(screenshot as Buffer, userId, filename); 142 | 143 | await db.insert(bookmark).values({ 144 | id: bookmarkId, 145 | user_id: userId, 146 | folder_id: folder_id, 147 | title: title, 148 | description: description, 149 | url: url, 150 | image: imageUrl, 151 | }); 152 | 153 | if (tags.length > 0) { 154 | await handleTags(tags, userId, bookmarkId); 155 | } 156 | 157 | fs.unlinkSync(tmpDir + filename); 158 | 159 | return Response(null, 200, "Bookmark created successfully"); 160 | } catch (error) { 161 | console.error(error); 162 | return NextResponse.json(500); 163 | } 164 | } 165 | 166 | function withFolderId(qb: T, folderId: string) { 167 | return qb.where(sql`folder_id = ${folderId}`); 168 | } 169 | 170 | function withQuery(qb: T, search: string, userId: string) { 171 | return qb.where(sql`(title ILIKE ${search} OR description ILIKE ${search}) AND bookmark.user_id = ${userId}`) 172 | } 173 | 174 | export async function GET( 175 | req: NextRequest, 176 | queryParams: { params: { userId: string } } 177 | ) { 178 | const userId = queryParams.params.userId; 179 | const folderId = req.nextUrl.searchParams.get("folderId"); 180 | const searchQuery = req.nextUrl.searchParams.get("query"); 181 | 182 | let bookmarks; 183 | 184 | bookmarks = db 185 | .select() 186 | .from(bookmark) 187 | .leftJoin(bookmark_tag, eq(bookmark.id, bookmark_tag.bookmark_id)) 188 | .leftJoin(tag, eq(tag.id, bookmark_tag.tag_id)) 189 | .where(eq(bookmark.user_id, userId)) 190 | .orderBy(desc(bookmark.created_at)) 191 | .$dynamic(); 192 | 193 | if (folderId) { 194 | bookmarks = withFolderId(bookmarks, folderId); 195 | } else { 196 | bookmarks = bookmarks.where(sql`folder_id IS NULL AND bookmark.user_id = ${userId}`); 197 | } 198 | 199 | if (searchQuery) { 200 | bookmarks = withQuery(bookmarks, `%${searchQuery}%`, userId); 201 | } 202 | 203 | const bookmarksMap: Map = new Map(); 204 | 205 | (await bookmarks).forEach((result) => { 206 | const { bookmark, tag } = result; 207 | if (!bookmarksMap.has(bookmark.id)) { 208 | bookmarksMap.set(bookmark.id, { 209 | id: bookmark.id, 210 | user_id: bookmark.user_id as string, 211 | folder_id: bookmark.folder_id, 212 | data: { 213 | title: bookmark.title as string, 214 | url: bookmark.url as string, 215 | description: bookmark.description as string, 216 | image: bookmark.image as string, 217 | created_at: bookmark.created_at as unknown as string, 218 | updated_at: bookmark.updated_at as string, 219 | deleted_at: bookmark.deleted_at, 220 | }, 221 | tags: [], 222 | }); 223 | } 224 | const currentBookmark = bookmarksMap.get(bookmark.id)!; 225 | if (tag && tag.id && tag.name && tag.created_at) { 226 | if (!currentBookmark.tags) { 227 | currentBookmark.tags = []; 228 | } 229 | currentBookmark.tags.push({ 230 | id: tag.id, 231 | name: tag.name, 232 | created_at: tag.created_at, 233 | }); 234 | }; 235 | }); 236 | 237 | bookmarks = Array.from(bookmarksMap.values()).map(bookmark => ({ 238 | ...bookmark, 239 | tags: bookmark.tags && bookmark.tags.length > 0 ? bookmark.tags : undefined 240 | })); 241 | 242 | return Response(bookmarks, 200, "Bookmark - GET Success"); 243 | } -------------------------------------------------------------------------------- /app/api/[userId]/folder/route.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from "uuid"; 2 | import { NextRequest } from "next/server"; 3 | import db, { folder, bookmark, bookmark_tag } from "@/lib/database"; 4 | import { Response } from "@/app/utils/response"; 5 | import { and, asc, eq, inArray } from "drizzle-orm"; 6 | 7 | export async function POST( 8 | req: NextRequest, 9 | query: { params: { userId: string } } 10 | ) { 11 | try { 12 | const body = await req.json(); 13 | const name = body.name; 14 | const userId = query.params.userId; 15 | 16 | const folderId = uuid(); 17 | 18 | await db.insert(folder).values({ 19 | id: folderId, 20 | user_id: userId, 21 | name: name, 22 | }); 23 | 24 | const resp = { 25 | user_id: userId, 26 | folder_id: folderId, 27 | name: name, 28 | }; 29 | 30 | return Response(resp, 200, "Folder created successfully"); 31 | } catch (error) { 32 | console.error(error); 33 | return Response(null, 500, "An error occurred"); 34 | } 35 | } 36 | 37 | export async function GET( 38 | req: NextRequest, 39 | query: { params: { userId: string } } 40 | ) { 41 | try { 42 | const userId = query.params.userId; 43 | 44 | const folders = await db 45 | .select({ 46 | id: folder.id, 47 | name: folder.name, 48 | }) 49 | .from(folder).orderBy(asc(folder.name)) 50 | .where(eq(folder.user_id, userId)); 51 | 52 | return Response(folders, 200, "Folders fetched successfully"); 53 | } catch (error) { 54 | console.error(error); 55 | return Response(null, 500, "An error occurred"); 56 | } 57 | } 58 | 59 | export async function PUT( 60 | req: NextRequest, 61 | query: { params: { userId: string } } 62 | ) { 63 | try { 64 | const body = await req.json(); 65 | const name = body.name; 66 | const userId = query.params.userId; 67 | const folderId = req.nextUrl.searchParams.get("id") as string; 68 | 69 | await db 70 | .update(folder) 71 | .set({ 72 | name: name, 73 | }) 74 | .where(and(eq(folder.id, folderId), eq(folder.user_id, userId))); 75 | 76 | return Response(null, 200, "Folder updated successfully"); 77 | } catch (error) { 78 | console.error(error); 79 | return Response(null, 500, "An error occurred"); 80 | } 81 | } 82 | 83 | export async function DELETE( 84 | req: NextRequest, 85 | query: { params: { userId: string } } 86 | ) { 87 | try { 88 | const userId = query.params.userId; 89 | const folderId = req.nextUrl.searchParams.get("id") as string; 90 | 91 | await db.transaction(async (trx) => { 92 | 93 | const bookmarksInFolder = await trx.select({id: bookmark.id}).from(bookmark).where(eq(bookmark.folder_id, folderId)); 94 | if (bookmarksInFolder.length > 0 ) { 95 | const bookmarkIds = bookmarksInFolder.map(b => b.id); 96 | 97 | await trx.delete(bookmark_tag).where(inArray(bookmark_tag.bookmark_id, bookmarkIds)); 98 | } 99 | 100 | await trx.delete(bookmark).where(eq(bookmark.folder_id, folderId)); 101 | 102 | await trx 103 | .delete(folder) 104 | .where(and(eq(folder.id, folderId), eq(folder.user_id, userId))); 105 | }); 106 | 107 | return Response(null, 200, "Folder deleted successfully"); 108 | } catch (error) { 109 | console.error(error); 110 | return Response(null, 500, "An error occurred"); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /app/api/[userId]/tag/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { eq, and } from "drizzle-orm"; 2 | import db, { tag, bookmark_tag } from "@/lib/database"; 3 | import { NextRequest } from "next/server"; 4 | import { Response } from "@/app/utils/response"; 5 | 6 | export async function DELETE( 7 | req: NextRequest, 8 | query: { params: { userId: string; id: string } } 9 | ) { 10 | try { 11 | const userId = query.params.userId; 12 | const tagId = query.params.id; 13 | 14 | await db.transaction(async (trx) => { 15 | await trx.delete(bookmark_tag).where(and(eq(bookmark_tag.tag_id, tagId))); 16 | 17 | await trx 18 | .delete(tag) 19 | .where(and(eq(tag.id, tagId), eq(tag.user_id, userId))); 20 | }); 21 | 22 | return Response(null, 200, "Tag deleted successfully"); 23 | } catch (error) { 24 | console.error(error); 25 | return Response(null, 500, "An error occurred"); 26 | } 27 | } 28 | 29 | export async function PUT( 30 | req: NextRequest, 31 | query: { params: { userId: string; id: string } } 32 | ) { 33 | try { 34 | const userId = query.params.userId; 35 | const tagId = query.params.id; 36 | const body = await req.json(); 37 | const name = body.name; 38 | 39 | await db 40 | .update(tag) 41 | .set({ name: name }) 42 | .where(and(eq(tag.id, tagId), eq(tag.user_id, userId))); 43 | 44 | return Response(null, 200, "Tag updated successfully"); 45 | } catch (error) { 46 | console.error(error); 47 | return Response(null, 500, "An error occurred"); 48 | } 49 | } -------------------------------------------------------------------------------- /app/api/[userId]/tag/route.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import db, { tag } from "@/lib/database"; 3 | import { NextRequest } from "next/server"; 4 | import { Response } from "@/app/utils/response"; 5 | 6 | export async function GET( 7 | req: NextRequest, 8 | query: { params: { userId: string } } 9 | ) { 10 | try { 11 | const userId = query.params.userId; 12 | 13 | const tags = await db 14 | .select({ 15 | id: tag.id, 16 | name: tag.name, 17 | created_at: tag.created_at, 18 | }) 19 | .from(tag) 20 | .where(eq(tag.user_id, userId)); 21 | 22 | return Response(tags, 200, "Tags fetched successfully"); 23 | } catch (error) { 24 | console.error(error); 25 | return Response(null, 500, "An error occurred"); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { authConfig } from "@/lib/auth"; 2 | import NextAuth from "next-auth/next"; 3 | 4 | const handler = NextAuth(authConfig); 5 | 6 | export { handler as GET, handler as POST }; 7 | -------------------------------------------------------------------------------- /app/api/hello/route.ts: -------------------------------------------------------------------------------- 1 | import main from "@/drizzle/health"; 2 | import { NextResponse } from "next/server"; 3 | 4 | main(); 5 | 6 | export async function GET() { 7 | const data = { 8 | name: "Dendi", 9 | age: "21", 10 | }; 11 | 12 | return NextResponse.json({ data }); 13 | } 14 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendianugerah/hooknhold/fb969f9ffd52edf5b4088ffedd9da58677d7000b/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 0 0% 3.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 0 0% 3.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 0 0% 3.9%; 15 | 16 | --primary: 0 0% 9%; 17 | --primary-foreground: 0 0% 98%; 18 | 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | 22 | --muted: 0 0% 96.1%; 23 | --muted-foreground: 0 0% 45.1%; 24 | 25 | --accent: 0 0% 96.1%; 26 | --accent-foreground: 0 0% 9%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 0 0% 89.8%; 32 | --input: 0 0% 89.8%; 33 | --ring: 0 0% 3.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 0 0% 3.9%; 40 | --foreground: 0 0% 98%; 41 | 42 | --card: 0 0% 3.9%; 43 | --card-foreground: 0 0% 98%; 44 | 45 | --popover: 0 0% 3.9%; 46 | --popover-foreground: 0 0% 98%; 47 | 48 | --primary: 0 0% 98%; 49 | --primary-foreground: 0 0% 9%; 50 | 51 | --secondary: 0 0% 14.9%; 52 | --secondary-foreground: 0 0% 98%; 53 | 54 | --muted: 0 0% 14.9%; 55 | --muted-foreground: 0 0% 63.9%; 56 | 57 | --accent: 0 0% 14.9%; 58 | --accent-foreground: 0 0% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 0 0% 98%; 62 | 63 | --border: 0 0% 14.9%; 64 | --input: 0 0% 14.9%; 65 | --ring: 0 0% 83.1%; 66 | } 67 | /* width */ 68 | ::-webkit-scrollbar { 69 | width: 10px; 70 | } 71 | 72 | /* Track */ 73 | ::-webkit-scrollbar-track { 74 | background: #f1f1f1; 75 | } 76 | 77 | /* Handle */ 78 | ::-webkit-scrollbar-thumb { 79 | background: #888; 80 | border-radius: 5px; 81 | } 82 | 83 | /* Handle on hover */ 84 | ::-webkit-scrollbar-thumb:hover { 85 | background: #555; 86 | } 87 | } 88 | 89 | @layer base { 90 | * { 91 | @apply border-border; 92 | } 93 | body { 94 | @apply bg-background text-foreground; 95 | } 96 | } 97 | 98 | html { 99 | scroll-behavior: smooth; 100 | } 101 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import NextAuthProvider from "@/components/container/provider"; 3 | import type { Metadata } from "next"; 4 | import { Inter, Plus_Jakarta_Sans } from "next/font/google"; 5 | 6 | const inter = Inter({ subsets: ["latin"] }); 7 | const plusJakartaSans = Plus_Jakarta_Sans({ subsets: ["latin"] }); 8 | 9 | export const metadata: Metadata = { 10 | title: "Hooknhold", 11 | description: "A simple bookmark manager for everyone.", 12 | }; 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: Readonly<{ 17 | children: React.ReactNode; 18 | }>) { 19 | return ( 20 | 21 | 22 | {children} 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | import NavbarSection from "@/components/container/homepage/navbar"; 4 | import { 5 | BentoCard, 6 | BentoGrid, 7 | Marquee, 8 | } from "@/components/ui/magic"; 9 | import { 10 | Command, 11 | CommandList, 12 | CommandInput, 13 | CommandEmpty, 14 | CommandGroup, 15 | CommandItem, 16 | } from "@/components/ui/command"; 17 | import { FileTextIcon, InputIcon } from "@radix-ui/react-icons"; 18 | import { cn } from "@/lib/utils"; 19 | import { FoldersIcon, FolderSync, SearchIcon, TagsIcon } from "lucide-react"; 20 | 21 | const files = [ 22 | { 23 | name: "Google", 24 | body: "Bitcoin is a cryptocurrency invented in 2008 by an unknown person or group of people using the name Satoshi Nakamoto.", 25 | }, 26 | { 27 | name: "Spreadsheet", 28 | body: "A spreadsheet or worksheet is a file made of rows and columns that help sort data, arrange data easily, and calculate numerical data.", 29 | }, 30 | { 31 | name: "Image", 32 | body: "Scalable Vector Graphics is an Extensible Markup Language-based vector image format for two-dimensional graphics with support for interactivity and animation.", 33 | }, 34 | { 35 | name: "Google Drive", 36 | body: "GPG keys are used to encrypt and decrypt email, files, directories, and whole disk partitions and to authenticate messages.", 37 | }, 38 | { 39 | name: "Seed Phrase", 40 | body: "A seed phrase, seed recovery phrase or backup seed phrase is a list of words which store all the information needed to recover Bitcoin funds on-chain.", 41 | }, 42 | ]; 43 | 44 | const features = [ 45 | { 46 | Icon: FileTextIcon, 47 | name: "Never lose a link again.", 48 | description: 49 | "Of course, you can always rely on this to keep your bookmarks safe and secure.", 50 | href: "/", 51 | cta: "Learn more", 52 | className: "col-span-3 lg:col-span-1", 53 | background: ( 54 | 58 | {files.map((f, idx) => ( 59 |
68 |
69 |
70 |
71 | {f.name} 72 |
73 |
74 |
75 |
{f.body}
76 |
77 | ))} 78 |
79 | ), 80 | }, 81 | { 82 | Icon: InputIcon, 83 | name: "Easy to use.", 84 | description: 85 | "Designed to be intuitive and easy to use. Search your favorite links in seconds.", 86 | href: "/", 87 | cta: "Learn more", 88 | className: "col-span-3 lg:col-span-2", 89 | background: ( 90 | 91 | 92 | 93 | No results found. 94 | 95 | Hardvard Business Review 96 | Quora 97 | Medium 98 | 99 | 100 | 101 | ), 102 | }, 103 | ]; 104 | 105 | export default function Home() { 106 | return ( 107 |
108 |
109 |
110 | 111 |
112 |
113 |
114 |
115 |
116 |
117 | Introducing 118 |
119 |

120 | Hooknhold 121 |

122 |

123 | The modern visual bookmark manager for the web. Save, 124 | organize, and share your favorite links with ease. 125 |

126 |
127 |
128 |
129 |
130 | Image 139 | 140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |

151 | Designed for your web journey. 152 |

153 |
154 |
155 | 156 | {features.map((feature, idx) => ( 157 | 158 | ))} 159 | 160 |
161 |
162 | 163 |
167 |
168 |
169 |
170 |
171 | Features 172 |
173 |

174 | Your bookmarks. Your way. 175 |

176 |

177 | Hooknhold is designed to be the perfect companion for your 178 | web journey. 179 |

180 |
181 |
182 |
183 | 184 | 185 |

186 | Organize Folders 187 |

188 |
189 |

190 | Organize bookmarks into folders that make sense to you. 191 |

192 |
193 |
194 | 195 | 196 |

Tags

197 |
198 |

199 | Add tags to for easy categorization and quick access. 200 |

201 |
202 |
203 | 204 | 205 |

Search

206 |
207 |

208 | Easily find the page you're looking for. 209 |

210 |
211 |
212 | 213 | 214 |

215 | Cross-Device Sync 216 |

217 |
218 |

219 | Access your bookmarks anywhere, anytime. 220 |

221 |
222 |
223 |
224 |
225 |
226 |
227 | 228 |
229 | 230 |

231 | © 2024 Hooknhold. All rights reserved. 232 |

233 | 247 |
248 |
249 |
250 |
251 | ); 252 | } 253 | -------------------------------------------------------------------------------- /app/utils/action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import db, { user } from "@/lib/database"; 3 | import { sql } from "drizzle-orm"; 4 | import { User } from "@/app/utils/definition"; 5 | import { v4 as uuid } from "uuid"; 6 | import { BookmarkData } from "@/app/utils/definition"; 7 | 8 | const API_BASE_URL = process.env.API_BASE_URL; 9 | 10 | export async function checkIfuserExist(email: string): Promise { 11 | try { 12 | const userExists = await db.execute(sql` 13 | SELECT * FROM public.user WHERE email = ${email}; 14 | `); 15 | 16 | return userExists.length > 0; 17 | } catch (error) { 18 | return false; 19 | } 20 | } 21 | 22 | export async function getUserId(email: string): Promise { 23 | try { 24 | const user = await db.execute(sql` 25 | SELECT id FROM public.user WHERE email = ${email}; 26 | `); 27 | 28 | return user[0].id as string; 29 | } catch (error) { 30 | throw new Error("Error while fetching user"); 31 | } 32 | } 33 | 34 | export async function createNewUser(newUser: User): Promise { 35 | try { 36 | const userId = uuid(); 37 | 38 | await db.insert(user).values({ 39 | id: userId, 40 | username: newUser.username, 41 | email: newUser.email, 42 | profile_image: newUser.profile_image, 43 | }); 44 | } catch (error) { 45 | throw new Error("Error while creating user"); 46 | } 47 | } 48 | 49 | export async function getBookmark( 50 | userId: string, 51 | folderId?: string, 52 | query?: string 53 | ): Promise { 54 | try { 55 | let url = `${API_BASE_URL}/${userId}/bookmark`; 56 | 57 | if (folderId) { 58 | url += `?folderId=${folderId}`; 59 | } 60 | 61 | if (query) { 62 | url += `?query=${query}`; 63 | } 64 | 65 | const response = await fetch(url, { 66 | method: "GET", 67 | }); 68 | 69 | const item = await response.json(); 70 | 71 | return item.data as BookmarkData[]; 72 | } catch (error) { 73 | throw error; 74 | } 75 | } 76 | 77 | export async function addBookmark( 78 | userId: string, 79 | url: string, 80 | tags?: string[], 81 | folderId?: string 82 | ): Promise { 83 | try { 84 | const body: any = { 85 | url: url, 86 | }; 87 | 88 | if (tags && tags.length > 0) { 89 | body.tags = tags; 90 | } 91 | 92 | if (folderId) { 93 | body.folder_id = folderId; 94 | } 95 | 96 | const response = await fetch(`${API_BASE_URL}/${userId}/bookmark`, { 97 | method: "POST", 98 | headers: { 99 | "Content-Type": "application/json", 100 | }, 101 | body: JSON.stringify(body), 102 | }); 103 | 104 | return response.json(); 105 | } catch (error) { 106 | throw error; 107 | } 108 | } 109 | 110 | export async function addFolder(userId: string, name: string): Promise { 111 | try { 112 | const response = await fetch(`${API_BASE_URL}/${userId}/folder`, { 113 | method: "POST", 114 | headers: { 115 | "Content-Type": "application/json", 116 | }, 117 | body: JSON.stringify({ name }), 118 | }); 119 | 120 | if (!response.ok) { 121 | throw new Error("An error occurred while creating folder"); 122 | } 123 | 124 | return response.json(); 125 | } catch (error) { 126 | console.error(`Error creating folder: ${(error as Error).message}`); 127 | throw error; 128 | } 129 | } 130 | 131 | export async function createTagInBookmark( 132 | userId: string, 133 | bookmarkId: string, 134 | tagIds: string | string[] 135 | ) { 136 | try { 137 | const body = { 138 | tag_id: Array.isArray(tagIds) ? tagIds : [tagIds], 139 | }; 140 | 141 | const response = await fetch( 142 | `${API_BASE_URL}/${userId}/bookmark/${bookmarkId}`, 143 | { 144 | method: "POST", 145 | headers: { 146 | "Content-Type": "application/json", 147 | }, 148 | body: JSON.stringify(body), 149 | } 150 | ); 151 | 152 | if (!response.ok) { 153 | throw new Error("An error occurred while creating tag"); 154 | } 155 | 156 | return response.json(); 157 | } catch (error) { 158 | throw error; 159 | } 160 | } 161 | 162 | export async function getFolder(userId: string) { 163 | try { 164 | const response = await fetch(`${API_BASE_URL}/${userId}/folder`, { 165 | method: "GET", 166 | }); 167 | 168 | const item = await response.json(); 169 | 170 | return item.data; 171 | } catch (error) { 172 | throw error; 173 | } 174 | } 175 | 176 | export async function deleteFolder(userId: string, id: string) { 177 | try { 178 | const response = await fetch(`${API_BASE_URL}/${userId}/folder?id=${id}`, { 179 | method: "DELETE", 180 | }); 181 | 182 | const item = await response.json(); 183 | 184 | return item.data; 185 | } catch (error) { 186 | throw error; 187 | } 188 | } 189 | 190 | export async function editFolder(userId: string, id: string, name: string) { 191 | try { 192 | const response = await fetch(`${API_BASE_URL}/${userId}/folder?id=${id}`, { 193 | method: "PUT", 194 | headers: { 195 | "Content-Type": "application/json", 196 | }, 197 | body: JSON.stringify({ name }), 198 | }); 199 | 200 | const item = await response.json(); 201 | 202 | return item.data; 203 | } catch (error) { 204 | throw error; 205 | } 206 | } 207 | 208 | export async function editTag(userId: string, id: string, name: string) { 209 | try { 210 | const response = await fetch(`${API_BASE_URL}/${userId}/tag/${id}`, { 211 | method: "PUT", 212 | headers: { 213 | "Content-Type": "application/json", 214 | }, 215 | body: JSON.stringify({ name }), 216 | }); 217 | 218 | const item = await response.json(); 219 | 220 | return item.data; 221 | } catch (error) { 222 | throw error; 223 | } 224 | } 225 | 226 | export async function getTag(userId: string) { 227 | try { 228 | const response = await fetch(`${API_BASE_URL}/${userId}/tag`, { 229 | method: "GET", 230 | }); 231 | 232 | const item = await response.json(); 233 | 234 | return item.data; 235 | } catch (error) { 236 | throw error; 237 | } 238 | } 239 | 240 | export async function getTagNotInBookmark(userId: string, bookmarkId: string) { 241 | try { 242 | const response = await fetch( 243 | `${API_BASE_URL}/${userId}/bookmark/${bookmarkId}`, 244 | { 245 | method: "GET", 246 | } 247 | ); 248 | 249 | const item = await response.json(); 250 | 251 | return item.data; 252 | } catch (error) { 253 | throw error; 254 | } 255 | } 256 | 257 | export async function deleteBookmark(userId: string, id: string) { 258 | try { 259 | const response = await fetch(`${API_BASE_URL}/${userId}/bookmark/${id}`, { 260 | method: "DELETE", 261 | }); 262 | 263 | const item = await response.json(); 264 | 265 | return item.data; 266 | } catch (error) { 267 | throw error; 268 | } 269 | } 270 | 271 | export async function deleteTagInBookmark( 272 | userId: string, 273 | bookmarkId: string, 274 | tagId: string 275 | ) { 276 | try { 277 | const response = await fetch( 278 | `${API_BASE_URL}/${userId}/bookmark/${bookmarkId}/tag/${tagId}`, 279 | { 280 | method: "DELETE", 281 | } 282 | ); 283 | 284 | const item = await response.json(); 285 | 286 | return item.data; 287 | } catch (error) { 288 | throw error; 289 | } 290 | } 291 | 292 | export async function deleteTag(userId: string, id: string) { 293 | try { 294 | const response = await fetch(`${API_BASE_URL}/${userId}/tag/${id}`, { 295 | method: "DELETE", 296 | }); 297 | 298 | const item = await response.json(); 299 | 300 | console.log(""); 301 | return item.data; 302 | } catch (error) { 303 | throw error; 304 | } 305 | } 306 | 307 | export async function updateBookmarkFolder( 308 | userId: string, 309 | bookmarkId: string, 310 | folderId: string 311 | ): Promise { 312 | try { 313 | const response = await fetch(`${API_BASE_URL}/${userId}/bookmark/${bookmarkId}`, { 314 | method: "PATCH", 315 | headers: { 316 | "Content-Type": "application/json", 317 | }, 318 | body: JSON.stringify({ folder_id: folderId }), 319 | }); 320 | 321 | if (!response.ok) { 322 | throw new Error("Failed to update bookmark folder"); 323 | } 324 | 325 | await response.json(); 326 | } catch (error) { 327 | console.error("Error updating bookmark folder:", error); 328 | throw error; 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /app/utils/definition.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | username: string; 3 | email: string; 4 | profile_image: string; 5 | } 6 | 7 | export interface Bookmark { 8 | title: string; 9 | url: string; 10 | description: string | null; 11 | image: string; 12 | created_at: string; 13 | updated_at: string; 14 | deleted_at: string | null; 15 | } 16 | 17 | export interface Tag { 18 | id: string; 19 | name: string; 20 | created_at: string; 21 | } 22 | 23 | export interface BookmarkTag { 24 | bookmark_id: string; 25 | tag_id: string; 26 | } 27 | 28 | export interface BookmarkData { 29 | id: string; 30 | user_id: string; 31 | folder_id: string | null; 32 | data: Bookmark; 33 | tags?: Omit[]; 34 | } 35 | 36 | export interface FolderData { 37 | id: string; 38 | name: string; 39 | } 40 | -------------------------------------------------------------------------------- /app/utils/queryKeys.ts: -------------------------------------------------------------------------------- 1 | export const queryKeys = { 2 | tags: (userId: string) => ['tags', userId], 3 | folders: (userId: string) => ['folders', userId], 4 | bookmarks: (userId: string) => ['bookmarks', userId], 5 | }; -------------------------------------------------------------------------------- /app/utils/response.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | interface ApiResponse { 4 | meta: ApiResponseMeta; 5 | data: T; 6 | } 7 | 8 | interface ApiResponseMeta { 9 | status: number; 10 | message: string; 11 | } 12 | 13 | export function Response( 14 | data: T, 15 | status: number, 16 | message?: string 17 | ): NextResponse { 18 | const defaultMessage: Record = { 19 | 200: "Success", 20 | 400: "Bad Request", 21 | 404: "Not Found", 22 | 500: "Internal Server Error", 23 | }; 24 | 25 | const response: ApiResponse = { 26 | meta: { 27 | status: status, 28 | message: message || defaultMessage[status], 29 | }, 30 | data: data, 31 | }; 32 | 33 | return new NextResponse(JSON.stringify(response, null, 2), { 34 | status: status, 35 | headers: { 36 | "Content-Type": "application/json", 37 | }, 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /components/container/homepage/navbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { Circle } from "lucide-react"; 2 | import Link from "next/link"; 3 | 4 | export default function NavbarSection() { 5 | return ( 6 |
7 | 8 |
9 | 10 |
11 | Hooknhold 12 | 13 | 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /components/container/mind/actions/BookmarkActions.tsx: -------------------------------------------------------------------------------- 1 | import BookmarkDialog from "../dialog/BookmarkDialog"; 2 | import { Button, Dialog, DialogTrigger, DialogContent } from "@/components/ui"; 3 | import { PlusIcon, Share2Icon, SparklesIcon } from "lucide-react"; 4 | 5 | interface BookmarkActionsProps { 6 | isOpen: boolean; 7 | setIsOpen: (isOpen: boolean) => void; 8 | setIsRecommendationsOpen: (isOpen: boolean) => void; 9 | userId: string; 10 | isMindRoute: boolean; 11 | } 12 | 13 | export default function BookmarkActions({ 14 | isOpen, 15 | setIsOpen, 16 | setIsRecommendationsOpen, 17 | userId, 18 | isMindRoute, 19 | }: BookmarkActionsProps) { 20 | return ( 21 |
22 | 30 | 31 | 32 | 40 | 41 | 42 | setIsOpen(false)} /> 43 | 44 | 45 | {!isMindRoute && ( 46 | 50 | )} 51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /components/container/mind/bookmarks/BookmarkCardView.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | import { BookmarkData } from "@/app/utils/definition"; 4 | import { ChevronRight, Loader2, Plus, X, GripVertical } from "lucide-react"; 5 | import { useState } from "react"; 6 | import { 7 | Badge, 8 | Card, 9 | CardContent, 10 | MultipleSelector, 11 | Popover, 12 | PopoverContent, 13 | PopoverTrigger, 14 | Button, 15 | } from "@/components/ui"; 16 | import { Option } from "@/components/ui/multiple-selector"; 17 | import { DeleteConfirmationDialog } from "@/components/container/mind/dialog/DeleteConfirmationDialog"; 18 | import { 19 | useTagsNotInBookmark, 20 | useDeleteTagInBookmark, 21 | useCreateTagInBookmark, 22 | } from "@/hooks"; 23 | import useUserId from "@/hooks/useUserId"; 24 | import { useDraggable } from "@dnd-kit/core"; 25 | 26 | interface BookmarkCardViewProps { 27 | bookmark: BookmarkData; 28 | onDelete: (id: string) => void; 29 | } 30 | 31 | export function BookmarkCardView({ 32 | bookmark, 33 | onDelete, 34 | }: BookmarkCardViewProps) { 35 | const userId = useUserId(); 36 | const { options, invalidateTagsQuery } = useTagsNotInBookmark( 37 | userId, 38 | bookmark.id 39 | ); 40 | const { attributes, listeners, setNodeRef } = useDraggable({ 41 | id: bookmark.id, 42 | data: { type: "bookmark", title: bookmark.data.title }, 43 | }); 44 | const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); 45 | const [isDeletingTag, setIsDeletingTag] = useState(null); 46 | const [isPopoverOpen, setIsPopoverOpen] = useState(false); 47 | const [selectedTags, setSelectedTags] = useState([]); 48 | const createTagInBookmark = useCreateTagInBookmark(userId, bookmark.id); 49 | const deleteTagInBookmark = useDeleteTagInBookmark(userId, bookmark.id); 50 | const hasTags = bookmark.tags && bookmark.tags.length > 0; 51 | 52 | const handleDelete = () => { 53 | onDelete(bookmark.id); 54 | setIsDeleteDialogOpen(false); 55 | }; 56 | 57 | const handleDeleteTag = (tagId: string) => { 58 | setIsDeletingTag(tagId); 59 | deleteTagInBookmark.mutate(tagId, { 60 | onSuccess() { 61 | invalidateTagsQuery(); 62 | setIsDeletingTag(null); 63 | }, 64 | onError() { 65 | setIsDeletingTag(null); 66 | }, 67 | }); 68 | }; 69 | 70 | const handleAddTag = () => { 71 | if (selectedTags.length > 0) { 72 | const tagIds = selectedTags 73 | .map((tag) => tag.id) 74 | .filter((id): id is string => id !== undefined); 75 | createTagInBookmark.mutate(tagIds, { 76 | onSuccess: () => { 77 | setSelectedTags([]); 78 | setIsPopoverOpen(false); 79 | invalidateTagsQuery(); 80 | }, 81 | onError: (error) => { 82 | console.error("Error adding tags:", error); 83 | }, 84 | }); 85 | } 86 | }; 87 | 88 | return ( 89 | <> 90 | 91 | 92 |
98 | 99 |
100 |
101 |
102 |

103 | {bookmark.data.title} 104 |
105 | 108 |
109 |

110 |
111 | 116 | Visit site 117 | 118 | 119 |
120 | {bookmark.data.title} 127 |

128 | {bookmark.data.description || "No description available."} 129 |

130 |
131 |
132 |
133 | {hasTags && bookmark.tags && ( 134 | <> 135 | 136 | Tags: 137 | 138 |
139 | {bookmark.tags.map((tag, index) => ( 140 | 145 | {tag.name} 146 | 157 | 158 | ))} 159 |
160 | 161 | )} 162 | 163 | 164 | 168 | 169 | 170 |
171 | setSelectedTags(value)} 176 | /> 177 | 190 |
191 |
192 |
193 |
194 |
195 | 196 | 197 | {new Date(bookmark.data.created_at).toLocaleDateString( 198 | "en-US", 199 | { 200 | month: "short", 201 | day: "numeric", 202 | year: "numeric", 203 | } 204 | )} 205 | 206 |
207 |
208 |
209 |
210 |
211 | setIsDeleteDialogOpen(false)} 214 | onDelete={handleDelete} 215 | title="Are you sure you want to delete this bookmark?" 216 | description="This action cannot be undone. This will permanently delete the bookmark." 217 | /> 218 | 219 | ); 220 | } 221 | -------------------------------------------------------------------------------- /components/container/mind/bookmarks/BookmarkDragOverlay.tsx: -------------------------------------------------------------------------------- 1 | import { BookmarkIcon } from "lucide-react"; 2 | import React from "react"; 3 | 4 | interface BookmarkDragOverlayProps { 5 | title: string; 6 | } 7 | 8 | export function BookmarkDragOverlay({ title }: BookmarkDragOverlayProps) { 9 | return ( 10 |
11 | 12 | 13 | {title || "Untitled"} 14 | 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /components/container/mind/bookmarks/BookmarkList.tsx: -------------------------------------------------------------------------------- 1 | import BookmarkSkeleton from "@/components/skeleton/bookmark-skeleton"; 2 | import { BookmarkData } from "@/app/utils/definition"; 3 | import { BookmarkCardView } from "./BookmarkCardView"; 4 | import { BookmarkListView } from "./BookmarkListView"; 5 | 6 | interface BookmarkListProps { 7 | isLoadBookmarks: boolean; 8 | bookmarks: BookmarkData[]; 9 | isCardView: boolean; 10 | onDelete: (id: string) => void; 11 | } 12 | 13 | export default function BookmarkList({ 14 | isLoadBookmarks, 15 | bookmarks, 16 | isCardView, 17 | onDelete, 18 | }: BookmarkListProps) { 19 | return ( 20 |
27 | {isLoadBookmarks ? ( 28 | <> 29 | 30 | 31 | 32 | ) : bookmarks && bookmarks.length > 0 ? ( 33 | bookmarks.map((bookmark) => 34 | isCardView ? ( 35 | 40 | ) : ( 41 | 46 | ) 47 | ) 48 | ) : ( 49 |
50 |

51 | No bookmarks available in this folder. 52 |

53 |
54 | )} 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /components/container/mind/bookmarks/BookmarkListView.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { useState } from "react"; 3 | import { Badge } from "@/components/ui"; 4 | import { ChevronRight, X } from "lucide-react"; 5 | import { BookmarkData } from "@/app/utils/definition"; 6 | import { DeleteConfirmationDialog } from "@/components/container/mind/dialog/DeleteConfirmationDialog"; 7 | 8 | interface BookmarkListViewProps { 9 | bookmark: BookmarkData; 10 | onDelete: (id: string) => void; 11 | } 12 | 13 | export function BookmarkListView({ 14 | bookmark, 15 | onDelete, 16 | }: BookmarkListViewProps) { 17 | const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); 18 | const hasTags = bookmark.tags && bookmark.tags.length > 0; 19 | 20 | const handleDelete = () => { 21 | onDelete(bookmark.id); 22 | setIsDeleteDialogOpen(false); 23 | }; 24 | 25 | const handleDeleteTag = (tagId: string) => { 26 | // Logic to delete tag will be implemented later 27 | }; 28 | 29 | return ( 30 |
31 |
32 |

{bookmark.data.title}

33 | 36 |
37 |
38 |
39 | 44 | Visit site 45 | 46 | 47 |
48 |
49 |
50 | {hasTags && bookmark.tags && ( 51 |
52 | 53 | Tags: 54 | 55 |
56 | {bookmark.tags.map((tag, index) => ( 57 | 62 | {tag.name} 63 | 64 | ))} 65 |
66 |
67 | )} 68 |

69 | {new Date(bookmark.data.created_at).toLocaleDateString("en-US", { 70 | month: "short", 71 | day: "numeric", 72 | year: "numeric", 73 | })} 74 |

75 |
76 | setIsDeleteDialogOpen(false)} 79 | onDelete={handleDelete} 80 | title="Are you sure you want to delete this bookmark?" 81 | description="This action cannot be undone. This will permanently delete the bookmark." 82 | /> 83 |
84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /components/container/mind/control/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Rows2, Grid2X2 } from "lucide-react"; 3 | import React from "react"; 4 | 5 | interface ControlSectionProps { 6 | isCardView: boolean; 7 | setIsCardView: (isCardView: boolean) => void; 8 | } 9 | 10 | export default function ControlSection({ 11 | isCardView, 12 | setIsCardView, 13 | }: ControlSectionProps) { 14 | return ( 15 |
16 |
17 | 25 | 33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /components/container/mind/dialog/BookmarkDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { 3 | Input, 4 | Button, 5 | MultipleSelector, 6 | Select, 7 | SelectItem, 8 | SelectTrigger, 9 | SelectValue, 10 | SelectContent, 11 | Card, 12 | CardContent, 13 | } from "@/components/ui"; 14 | import { Option } from "@/components/ui/multiple-selector"; 15 | import { useCreateBookmark, useTags, useFolders } from "@/hooks"; 16 | import { LoaderCircle } from "lucide-react"; 17 | 18 | interface BookmarkDialogProps { 19 | userId: string; 20 | onClose: () => void; 21 | } 22 | 23 | const BookmarkDialog: React.FC = ({ userId, onClose }) => { 24 | const [url, setUrl] = useState(""); 25 | const [error, setError] = useState(""); 26 | const [selectedTags, setSelectedTags] = useState([]); 27 | const [selectedFolderId, setSelectedFolderId] = useState(""); 28 | const [selectedFolderName, setSelectedFolderName] = useState("Select"); 29 | 30 | const { options, invalidateTags } = useTags(userId); 31 | const { folders } = useFolders(userId); 32 | const createBookmark = useCreateBookmark(userId, onClose); 33 | 34 | const handleSubmit = () => { 35 | if (!url.trim()) { 36 | setError("Please enter a valid URL"); 37 | return; 38 | } 39 | 40 | const bookmarkData: any = { url }; 41 | if (selectedTags.length > 0) { 42 | bookmarkData.tags = selectedTags.map((tag) => tag.value); 43 | } 44 | if (selectedFolderId) { 45 | bookmarkData.folderId = selectedFolderId; 46 | } 47 | 48 | createBookmark.mutate(bookmarkData, { 49 | onSuccess: () => { 50 | setUrl(""); 51 | setSelectedTags([]); 52 | setSelectedFolderId(""); 53 | setSelectedFolderName("Select"); 54 | invalidateTags(); 55 | setError(""); 56 | onClose(); 57 | }, 58 | }); 59 | }; 60 | 61 | return ( 62 | 63 | 64 |
65 |

66 | URL (required) 67 |

68 | setUrl(e.target.value)} 74 | /> 75 |
76 |
77 |
78 |

Tags

79 |
80 | 86 | no results found. 87 |

88 | } 89 | onChange={(value: any) => setSelectedTags(value)} 90 | /> 91 |
92 |
93 |
94 |

Folder

95 | 123 |
124 |
125 | {error && ( 126 |
127 | 133 | 138 | 139 | {error} 140 |
141 | )} 142 | 157 |
158 |
159 | ); 160 | }; 161 | 162 | export default BookmarkDialog; 163 | -------------------------------------------------------------------------------- /components/container/mind/dialog/BookmarkRecommendationDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { 3 | Button, 4 | Dialog, 5 | DialogContent, 6 | DialogHeader, 7 | DialogTitle, 8 | DialogDescription, 9 | DialogFooter, 10 | } from "@/components/ui"; 11 | import { CheckIcon, LoaderCircle, PlusIcon, SparklesIcon, XIcon } from "lucide-react"; 12 | import { 13 | Tooltip, 14 | TooltipContent, 15 | TooltipProvider, 16 | TooltipTrigger, 17 | } from "@/components/ui/tooltip"; 18 | 19 | interface BookmarkRecommendationProps { 20 | isOpen: boolean; 21 | setIsOpen: (isOpen: boolean) => void; 22 | } 23 | 24 | export default function BookmarkRecommendation({ 25 | isOpen, 26 | setIsOpen, 27 | }: BookmarkRecommendationProps) { 28 | const [recommendations, setRecommendations] = useState([]); 29 | const [isGeneratingRecommendations, setIsGeneratingRecommendations] = 30 | useState(false); 31 | const [bookmarkedRecommendations, setBookmarkedRecommendations] = useState< 32 | string[] 33 | >([]); 34 | 35 | const generateRecommendations = async () => { 36 | setIsGeneratingRecommendations(true); 37 | // TODO: Implement AI-based recommendation generation 38 | // For now, we'll use dummy data 39 | await new Promise((resolve) => setTimeout(resolve, 2000)); 40 | setRecommendations([ 41 | "https://example.com/recommended1", 42 | "https://example.com/recommended2", 43 | "https://example.com/recommended3", 44 | ]); 45 | setIsGeneratingRecommendations(false); 46 | }; 47 | 48 | const handleAddBookmark = (url: string) => { 49 | // TODO: Implement bookmark saving logic 50 | setBookmarkedRecommendations((prev) => [...prev, url]); 51 | }; 52 | 53 | return ( 54 | 55 | 56 | 57 | 58 | Recommended Websites (Todo) 59 | 60 | 61 | Based on your bookmarks history 62 | 63 | 64 |
65 | {isGeneratingRecommendations ? ( 66 |
67 | 68 |

69 | Generating recommendations... 70 |

71 |
72 | ) : ( 73 |
74 | {recommendations.length > 0 ? ( 75 | recommendations.map((url, index) => ( 76 |
80 |
81 | 87 | {url} 88 | 89 |

90 | Recommended based on your interests 91 |

92 |
93 | 94 | 95 | 96 | 116 | 117 | 118 | {bookmarkedRecommendations.includes(url) 119 | ? "Bookmark saved" 120 | : "Add to bookmarks"} 121 | 122 | 123 | 124 |
125 | )) 126 | ) : ( 127 |
128 |

129 | No recommendations available. 130 |

131 |

132 | Try refreshing or add more bookmarks to improve 133 | recommendations. 134 |

135 |
136 | )} 137 |
138 | )} 139 |
140 | 141 | 150 | 160 | 161 |
162 |
163 | ); 164 | } 165 | -------------------------------------------------------------------------------- /components/container/mind/dialog/DeleteConfirmationDialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AlertDialog, 3 | AlertDialogTitle, 4 | AlertDialogCancel, 5 | AlertDialogHeader, 6 | AlertDialogFooter, 7 | AlertDialogAction, 8 | AlertDialogContent, 9 | AlertDialogDescription, 10 | } from "@/components/ui"; 11 | import { useEffect } from "react"; 12 | 13 | interface DeleteConfirmationDialogProps { 14 | open: boolean; 15 | onClose: () => void; 16 | onDelete: () => void; 17 | title: string; 18 | description: string; 19 | } 20 | 21 | export function DeleteConfirmationDialog({ 22 | open, 23 | onClose, 24 | onDelete, 25 | title, 26 | description, 27 | }: DeleteConfirmationDialogProps) { 28 | useEffect(() => { 29 | const handleKeyDown = (event: KeyboardEvent) => { 30 | if (event.key === "Escape" && open) { 31 | onClose(); 32 | } 33 | }; 34 | 35 | window.addEventListener("keydown", handleKeyDown); 36 | return () => window.removeEventListener("keydown", handleKeyDown); 37 | }, [open, onClose]); 38 | 39 | return ( 40 | 41 | 42 | 43 | {title} 44 | {description} 45 | 46 | 47 | Cancel 48 | { 51 | onDelete(); 52 | onClose(); 53 | }} 54 | > 55 | Delete 56 | 57 | 58 | 59 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /components/container/mind/header/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import Link from "next/link"; 3 | import { signOut } from "next-auth/react"; 4 | import { useSession } from "next-auth/react"; 5 | import { HamburgerMenuIcon } from "@radix-ui/react-icons"; 6 | import { 7 | Button, 8 | DropdownMenu, 9 | DropdownMenuItem, 10 | DropdownMenuLabel, 11 | DropdownMenuContent, 12 | DropdownMenuSeparator, 13 | DropdownMenuTrigger, 14 | } from "@/components/ui"; 15 | import { BookmarkIcon } from "lucide-react"; 16 | 17 | interface HeaderSectionProps { 18 | isSidebarOpen: boolean; 19 | setIsSidebarOpen: React.Dispatch>; 20 | } 21 | 22 | export default function HeaderSection({ isSidebarOpen, setIsSidebarOpen }: HeaderSectionProps) { 23 | const { data: session } = useSession(); 24 | 25 | return ( 26 |
27 | 28 | 29 | Bookmarks 30 | 31 |
32 | 33 | 37 | 38 | 39 | 56 | 57 | 58 | {session?.user?.name} 59 | 60 | Settings 61 | Support 62 | 63 | 64 | { 67 | signOut({ callbackUrl: "/" }); 68 | }} 69 | > 70 | Logout 71 | 72 | 73 | 74 | 75 |
76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /components/container/mind/sidebar/CreateFolder.tsx: -------------------------------------------------------------------------------- 1 | import { FolderIcon, X } from "lucide-react"; 2 | import { useState, useCallback } from "react"; 3 | import useCreateFolder from "@/hooks/createFolder"; 4 | 5 | interface FolderProps { 6 | userId: string; 7 | showCreateFolder: boolean; 8 | setShowCreateFolder: (value: boolean) => void; 9 | } 10 | 11 | function CreateFolder({ 12 | userId, 13 | showCreateFolder, 14 | setShowCreateFolder, 15 | }: FolderProps) { 16 | const [folderName, setFolderName] = useState(""); 17 | const createFolder = useCreateFolder(userId, () => 18 | setShowCreateFolder(false) 19 | ); 20 | 21 | const handleInputChange = useCallback((e: React.ChangeEvent) => { 22 | setFolderName(e.target.value); 23 | }, []); 24 | 25 | const handleInputBlur = useCallback(() => { 26 | if (folderName) { 27 | createFolder.mutate(folderName); 28 | setFolderName(""); 29 | } 30 | }, [folderName, createFolder]); 31 | 32 | const handleInputKeyDown = useCallback( 33 | (e: React.KeyboardEvent) => { 34 | if (e.key === "Enter" && folderName) { 35 | createFolder.mutate(folderName); 36 | setFolderName(""); 37 | } else if (e.key === "Escape") { 38 | setShowCreateFolder(false) 39 | } 40 | }, 41 | [folderName, createFolder] 42 | ); 43 | 44 | return ( 45 |
46 |
47 | 48 | 58 |
59 | 62 |
63 | ); 64 | } 65 | 66 | export default CreateFolder; 67 | -------------------------------------------------------------------------------- /components/container/mind/sidebar/MenuItem.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { 3 | DropdownMenu, 4 | DropdownMenuContent, 5 | DropdownMenuTrigger, 6 | DropdownMenuRadioGroup, 7 | DropdownMenuRadioItem, 8 | } from "@/components/ui"; 9 | import { FolderIcon, Hash, MoreVertical } from "lucide-react"; 10 | import RenameItem from "./RenameItem"; 11 | import React, { useCallback } from "react"; 12 | import { usePathname } from "next/navigation"; 13 | 14 | interface ItemDropdownMenuProps { 15 | itemId: string; 16 | handleDeleteClick: (id: string) => void; 17 | handleRenameClick: (id: string) => void; 18 | } 19 | 20 | function ItemDropdownMenu({ 21 | itemId, 22 | handleDeleteClick, 23 | handleRenameClick, 24 | }: ItemDropdownMenuProps) { 25 | return ( 26 | 27 | 28 | 31 | 32 | 37 | 38 | { 42 | handleRenameClick(itemId); 43 | e.stopPropagation(); 44 | }} 45 | > 46 | Rename 47 | 48 | 49 | { 53 | handleDeleteClick(itemId); 54 | e.stopPropagation(); 55 | }} 56 | > 57 | Delete 58 | 59 | 60 | 61 | 62 | ); 63 | } 64 | 65 | interface MenuItemProps { 66 | item?: { id: string; name: string }; 67 | items?: { id: string; name: string }[]; 68 | handleDeleteClick: (id: string) => void; 69 | handleRenameClick: (id: string) => void; 70 | editingItem: string | null; 71 | onRename: (id: string, newName: string) => void; 72 | type: "folder" | "tag"; 73 | } 74 | 75 | function MenuItem({ 76 | item, 77 | items, 78 | handleDeleteClick, 79 | handleRenameClick, 80 | editingItem, 81 | onRename, 82 | type, 83 | }: MenuItemProps) { 84 | const pathname = usePathname(); 85 | const handleCancel = useCallback(() => { 86 | handleRenameClick(""); 87 | }, [handleRenameClick]); 88 | 89 | const getIcon = (type: "folder" | "tag") => { 90 | return type === "folder" ? ( 91 | 92 | ) : ( 93 | 94 | ); 95 | }; 96 | 97 | return ( 98 | <> 99 | {item ? ( 100 |
101 | {editingItem === item.id ? ( 102 | 108 | ) : ( 109 | 113 |
114 | {getIcon(type)} 115 | 122 | {item.name} 123 | 124 |
125 | 126 | 131 | 132 | 133 | )} 134 |
135 | ) : ( 136 | items?.map((item) => ( 137 |
138 | {editingItem === item.id ? ( 139 | 145 | ) : ( 146 | 150 |
151 | {getIcon(type)} 152 | 159 | {item.name} 160 | 161 |
162 | 163 | 168 | 169 | 170 | )} 171 |
172 | )) 173 | )} 174 | 175 | ); 176 | } 177 | 178 | export default MenuItem; 179 | -------------------------------------------------------------------------------- /components/container/mind/sidebar/RenameItem.tsx: -------------------------------------------------------------------------------- 1 | import { FolderIcon, XIcon } from "lucide-react"; 2 | import { useCallback, useEffect, useRef, useState } from "react"; 3 | import { Hash } from "lucide-react"; 4 | 5 | interface RenameItemProps { 6 | item: { id: string; name: string }; 7 | onRename: (id: string, newName: string) => void; 8 | onCancel: () => void; 9 | type: "folder" | "tag"; 10 | } 11 | 12 | function RenameItem({ item, onRename, onCancel, type }: RenameItemProps) { 13 | const [newName, setNewName] = useState(""); 14 | const inputRef = useRef(null); 15 | 16 | useEffect(() => { 17 | if (inputRef.current) { 18 | inputRef.current.focus(); 19 | inputRef.current.select(); 20 | } 21 | }, []); 22 | 23 | const handleInputChange = useCallback( 24 | (e: React.ChangeEvent) => { 25 | setNewName(e.target.value); 26 | }, 27 | [] 28 | ); 29 | 30 | const handleInputBlur = useCallback(() => { 31 | if (newName.trim()) { 32 | onRename(item.id, newName.trim()); 33 | } 34 | onCancel(); 35 | }, [newName, onRename, item.id, onCancel]); 36 | 37 | const handleInputKeyDown = useCallback( 38 | (e: React.KeyboardEvent) => { 39 | if (e.key === "Enter" && newName.trim()) { 40 | onRename(item.id, newName.trim()); 41 | } else if (e.key === "Escape") { 42 | onCancel(); 43 | } 44 | }, 45 | [newName, onRename, item.id, onCancel] 46 | ); 47 | 48 | const getIcon = (type: "folder" | "tag") => { 49 | return type === "folder" ? ( 50 | 51 | ) : ( 52 | 53 | ); 54 | }; 55 | 56 | return ( 57 |
58 |
59 | {getIcon(type)} 60 | 71 |
72 | 75 |
76 | ); 77 | } 78 | 79 | export default RenameItem; 80 | -------------------------------------------------------------------------------- /components/container/mind/sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Input, 4 | Accordion, 5 | AccordionContent, 6 | AccordionItem, 7 | AccordionTrigger, 8 | } from "@/components/ui"; 9 | import { BookmarkIcon, Circle, FolderIcon, Hash, Package2, PlusIcon, SearchIcon } from "lucide-react"; 10 | import { useDroppable } from "@dnd-kit/core"; 11 | import { SearchContext } from "@/app/(dashboard)/layout"; 12 | import { useCallback, useContext, useState } from "react"; 13 | import { 14 | useTags, 15 | useFolders, 16 | useRenameTag, 17 | useDeleteFolder, 18 | useRenameFolder, 19 | useDeleteTag, 20 | } from "@/hooks"; 21 | import { DeleteConfirmationDialog } from "@/components/container/mind/dialog/DeleteConfirmationDialog"; 22 | import Link from "next/link"; 23 | import useUserId from "@/hooks/useUserId"; 24 | import CreateFolder from "./CreateFolder"; 25 | import MenuItem from "./MenuItem"; 26 | import { AccordionContext } from "@/app/(dashboard)/layout"; 27 | 28 | interface Folder { 29 | id: string; 30 | name: string; 31 | } 32 | 33 | interface FolderItemProps { 34 | folder: Folder; 35 | handleDeleteClick: (id: string) => void; 36 | handleRenameClick: (id: string) => void; 37 | editingItem: string | null; 38 | onRename: (id: string, newName: string) => void; 39 | } 40 | 41 | function SearchBar({ setSearch }: { setSearch: (value: string) => void }) { 42 | return ( 43 |
44 | 45 | setSearch(e.target.value)} 50 | /> 51 |
52 | ); 53 | } 54 | 55 | function FolderItem({ 56 | folder, 57 | handleDeleteClick, 58 | handleRenameClick, 59 | editingItem, 60 | onRename, 61 | className, 62 | }: FolderItemProps & { className?: string }) { 63 | const { setNodeRef, isOver } = useDroppable({ 64 | id: folder.id, 65 | data: { type: "folder", folder }, 66 | }); 67 | 68 | return ( 69 |
75 | 83 |
84 | ); 85 | } 86 | 87 | export default function Sidebar({ isSidebarOpen }: { isSidebarOpen: boolean }) { 88 | const userId = useUserId(); 89 | const deleteFolder = useDeleteFolder(userId); 90 | const deleteTag = useDeleteTag(userId); 91 | const editFolder = useRenameFolder(userId); 92 | const editTag = useRenameTag(userId); 93 | const [showCreateFolder, setShowCreateFolder] = useState(false); 94 | const [showDeleteFolderAlert, setShowDeleteFolderAlert] = useState(false); 95 | const [showDeleteTagAlert, setShowDeleteTagAlert] = useState(false); 96 | const [editFolderId, setEditFolderId] = useState(null); 97 | const [editTagId, setEditTagId] = useState(null); 98 | const [selectedFolderId, setSelectedFolderId] = useState(""); 99 | const [selectedTagId, setSelectedTagId] = useState(""); 100 | const { setSearch } = useContext(SearchContext); 101 | const { folders } = useFolders(userId); 102 | const { options: tags } = useTags(userId); 103 | const { openItems, setOpenItems } = useContext(AccordionContext); 104 | 105 | const handleDeleteFolderClick = useCallback((folderId: string) => { 106 | setSelectedFolderId(folderId); 107 | setShowDeleteFolderAlert(true); 108 | }, []); 109 | 110 | const handleDeleteTagClick = useCallback((tagId: string) => { 111 | setSelectedTagId(tagId); 112 | setShowDeleteTagAlert(true); 113 | }, []); 114 | 115 | const handleFolderRename = useCallback( 116 | (folderId: string, newName: string) => { 117 | editFolder.mutate({ folderId, newName }); 118 | setEditFolderId(null); 119 | }, 120 | [editFolder] 121 | ); 122 | 123 | const handleTagRename = useCallback( 124 | (tagId: string, newName: string) => { 125 | editTag.mutate({ tagId, newName }); 126 | setEditTagId(null); 127 | }, 128 | [editTag] 129 | ); 130 | 131 | const handleCreateFolderClick = useCallback(() => { 132 | setShowCreateFolder(true); 133 | const accordion = document.getElementById("folderAccordion"); 134 | if (accordion && accordion.getAttribute("aria-expanded") === "false") { 135 | accordion.click(); 136 | } 137 | }, []); 138 | 139 | const handleAccordionChange = (type: string) => { 140 | setOpenItems((prev) => ({ ...prev, [type]: !prev[type] })); 141 | }; 142 | 143 | const renderAccordion = (type: "folder" | "tag") => { 144 | const items = 145 | type === "folder" 146 | ? folders 147 | : tags.map((tag) => ({ id: tag.id, name: tag.label })); 148 | const icon = 149 | type === "folder" ? ( 150 | 151 | ) : ( 152 | 153 | ); 154 | const title = type === "folder" ? "Folder" : "Tag"; 155 | const handleDelete = 156 | type === "folder" ? handleDeleteFolderClick : handleDeleteTagClick; 157 | const handleRename = 158 | type === "folder" ? handleFolderRename : handleTagRename; 159 | const editingItem = type === "folder" ? editFolderId : editTagId; 160 | const setEditingItem = type === "folder" ? setEditFolderId : setEditTagId; 161 | 162 | return ( 163 | handleAccordionChange(type)} 169 | > 170 | 171 | {items.length > 0 && ( 172 | 176 | 177 | {icon} 178 | {title} 179 | 180 | 181 | )} 182 | 183 |
184 | {type === "folder" ? ( 185 | (items as Folder[]).map((folder) => ( 186 | 195 | )) 196 | ) : ( 197 | 205 | )} 206 | {type === "folder" && folders.length > 0 && showCreateFolder && ( 207 | 212 | )} 213 |
214 |
215 |
216 |
217 | ); 218 | }; 219 | 220 | return ( 221 |
226 |
227 |
228 | 229 |
230 | 231 |
232 | 233 | Hooknhold 234 | 235 | 236 |
237 |
238 | 291 |
292 |
293 |
294 | ); 295 | } 296 | -------------------------------------------------------------------------------- /components/container/provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SessionProvider } from "next-auth/react"; 4 | 5 | type Props = { 6 | children?: React.ReactNode; 7 | }; 8 | 9 | export const NextAuthProvider = ({ children }: Props) => { 10 | return {children}; 11 | }; 12 | 13 | export default NextAuthProvider; 14 | -------------------------------------------------------------------------------- /components/skeleton/bookmark-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function BookmarkSkeleton() { 4 | return ( 5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | 13 | 14 |
15 |
16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion"; 5 | import { ChevronRight } from "lucide-react"; 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Accordion = AccordionPrimitive.Root; 9 | 10 | const AccordionItem = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )); 23 | AccordionItem.displayName = "AccordionItem"; 24 | 25 | const AccordionTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, children, ...props }, ref) => ( 29 | 30 | svg]:rotate-90 justify-between", 34 | className 35 | )} 36 | {...props} 37 | > 38 | {children} 39 | 40 | 41 | 42 | )); 43 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; 44 | 45 | const AccordionContent = React.forwardRef< 46 | React.ElementRef, 47 | React.ComponentPropsWithoutRef 48 | >(({ className, children, ...props }, ref) => ( 49 | 54 |
{children}
55 |
56 | )); 57 | 58 | AccordionContent.displayName = AccordionPrimitive.Content.displayName; 59 | 60 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; 61 | -------------------------------------------------------------------------------- /components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 | 13 | const AlertDialogPortal = AlertDialogPrimitive.Portal 14 | 15 | const AlertDialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 29 | 30 | const AlertDialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, ...props }, ref) => ( 34 | 35 | 36 | 44 | 45 | )) 46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 47 | 48 | const AlertDialogHeader = ({ 49 | className, 50 | ...props 51 | }: React.HTMLAttributes) => ( 52 |
59 | ) 60 | AlertDialogHeader.displayName = "AlertDialogHeader" 61 | 62 | const AlertDialogFooter = ({ 63 | className, 64 | ...props 65 | }: React.HTMLAttributes) => ( 66 |
73 | ) 74 | AlertDialogFooter.displayName = "AlertDialogFooter" 75 | 76 | const AlertDialogTitle = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef 79 | >(({ className, ...props }, ref) => ( 80 | 85 | )) 86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 87 | 88 | const AlertDialogDescription = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 97 | )) 98 | AlertDialogDescription.displayName = 99 | AlertDialogPrimitive.Description.displayName 100 | 101 | const AlertDialogAction = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef & { variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" } 104 | >(({ className, variant, ...props }, ref) => ( 105 | 110 | )) 111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 112 | 113 | const AlertDialogCancel = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 126 | )) 127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 128 | 129 | export { 130 | AlertDialog, 131 | AlertDialogPortal, 132 | AlertDialogOverlay, 133 | AlertDialogTrigger, 134 | AlertDialogContent, 135 | AlertDialogHeader, 136 | AlertDialogFooter, 137 | AlertDialogTitle, 138 | AlertDialogDescription, 139 | AlertDialogAction, 140 | AlertDialogCancel, 141 | } 142 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "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-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | import { Loader2 } from "lucide-react"; 7 | 8 | const buttonVariants = cva( 9 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 10 | { 11 | variants: { 12 | variant: { 13 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | custom_primary: "bg-[#4169E1] text-white hover:bg-[#3A5FCD]", 23 | none: "rounded-none", 24 | }, 25 | size: { 26 | default: "h-10 px-4 py-2", 27 | sm: "h-9 rounded-md px-3", 28 | lg: "h-11 rounded-md px-8", 29 | icon: "h-10 w-10", 30 | }, 31 | }, 32 | defaultVariants: { 33 | variant: "default", 34 | size: "default", 35 | }, 36 | } 37 | ); 38 | 39 | export interface ButtonProps 40 | extends React.ButtonHTMLAttributes, 41 | VariantProps { 42 | asChild?: boolean; 43 | isLoading?: boolean; 44 | } 45 | 46 | const Button = React.forwardRef( 47 | ({ className, variant, size, isLoading = false, asChild = false, children, ...props }, ref) => { 48 | const Comp = asChild ? Slot : "button"; 49 | return ( 50 | 56 | {isLoading ? ( 57 | <> 58 | {children} 59 | 60 | 61 | ) : ( 62 | children 63 | )} 64 | 65 | ); 66 | } 67 | ); 68 | Button.displayName = "Button"; 69 | 70 | export { Button, buttonVariants }; 71 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { type DialogProps } from "@radix-ui/react-dialog"; 5 | import { Command as CommandPrimitive } from "cmdk"; 6 | import { Search } from "lucide-react"; 7 | 8 | import { cn } from "@/lib/utils"; 9 | import { Dialog, DialogContent } from "@/components/ui/dialog"; 10 | 11 | const Command = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 23 | )); 24 | Command.displayName = CommandPrimitive.displayName; 25 | 26 | interface CommandDialogProps extends DialogProps {} 27 | 28 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => { 29 | return ( 30 | 31 | 32 | 33 | {children} 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | const CommandInput = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 |
45 | 46 | 54 |
55 | )); 56 | 57 | CommandInput.displayName = CommandPrimitive.Input.displayName; 58 | 59 | const CommandList = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, ...props }, ref) => ( 63 | 68 | )); 69 | 70 | CommandList.displayName = CommandPrimitive.List.displayName; 71 | 72 | const CommandEmpty = React.forwardRef< 73 | React.ElementRef, 74 | React.ComponentPropsWithoutRef 75 | >((props, ref) => ( 76 | 81 | )); 82 | 83 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName; 84 | 85 | const CommandGroup = React.forwardRef< 86 | React.ElementRef, 87 | React.ComponentPropsWithoutRef 88 | >(({ className, ...props }, ref) => ( 89 | 97 | )); 98 | 99 | CommandGroup.displayName = CommandPrimitive.Group.displayName; 100 | 101 | const CommandSeparator = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )); 111 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName; 112 | 113 | const CommandItem = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 125 | )); 126 | 127 | CommandItem.displayName = CommandPrimitive.Item.displayName; 128 | 129 | const CommandShortcut = ({ 130 | className, 131 | ...props 132 | }: React.HTMLAttributes) => { 133 | return ( 134 | 141 | ); 142 | }; 143 | CommandShortcut.displayName = "CommandShortcut"; 144 | 145 | export { 146 | Command, 147 | CommandDialog, 148 | CommandInput, 149 | CommandList, 150 | CommandEmpty, 151 | CommandGroup, 152 | CommandItem, 153 | CommandShortcut, 154 | CommandSeparator, 155 | }; 156 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogClose, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )) 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 73 | 74 | )) 75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 76 | 77 | const DropdownMenuItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef & { 80 | inset?: boolean 81 | } 82 | >(({ className, inset, ...props }, ref) => ( 83 | 92 | )) 93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 94 | 95 | const DropdownMenuCheckboxItem = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, children, checked, ...props }, ref) => ( 99 | 108 | 109 | 110 | 111 | 112 | 113 | {children} 114 | 115 | )) 116 | DropdownMenuCheckboxItem.displayName = 117 | DropdownMenuPrimitive.CheckboxItem.displayName 118 | 119 | const DropdownMenuRadioItem = React.forwardRef< 120 | React.ElementRef, 121 | React.ComponentPropsWithoutRef 122 | >(({ className, children, ...props }, ref) => ( 123 | 131 | 132 | 133 | 134 | 135 | 136 | {children} 137 | 138 | )) 139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 140 | 141 | const DropdownMenuLabel = React.forwardRef< 142 | React.ElementRef, 143 | React.ComponentPropsWithoutRef & { 144 | inset?: boolean 145 | } 146 | >(({ className, inset, ...props }, ref) => ( 147 | 156 | )) 157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 158 | 159 | const DropdownMenuSeparator = React.forwardRef< 160 | React.ElementRef, 161 | React.ComponentPropsWithoutRef 162 | >(({ className, ...props }, ref) => ( 163 | 168 | )) 169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 170 | 171 | const DropdownMenuShortcut = ({ 172 | className, 173 | ...props 174 | }: React.HTMLAttributes) => { 175 | return ( 176 | 180 | ) 181 | } 182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 183 | 184 | export { 185 | DropdownMenu, 186 | DropdownMenuTrigger, 187 | DropdownMenuContent, 188 | DropdownMenuItem, 189 | DropdownMenuCheckboxItem, 190 | DropdownMenuRadioItem, 191 | DropdownMenuLabel, 192 | DropdownMenuSeparator, 193 | DropdownMenuShortcut, 194 | DropdownMenuGroup, 195 | DropdownMenuPortal, 196 | DropdownMenuSub, 197 | DropdownMenuSubContent, 198 | DropdownMenuSubTrigger, 199 | DropdownMenuRadioGroup, 200 | } 201 | -------------------------------------------------------------------------------- /components/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "./input"; 2 | import { Button } from "./button"; 3 | import { 4 | DropdownMenu, 5 | DropdownMenuItem, 6 | DropdownMenuLabel, 7 | DropdownMenuTrigger, 8 | DropdownMenuContent, 9 | DropdownMenuRadioItem, 10 | DropdownMenuSeparator, 11 | DropdownMenuRadioGroup, 12 | } from "./dropdown-menu"; 13 | import { 14 | Card, 15 | CardTitle, 16 | CardHeader, 17 | CardContent, 18 | CardDescription, 19 | CardFooter, 20 | } from "./card"; 21 | import { 22 | Dialog, 23 | DialogTitle, 24 | DialogClose, 25 | DialogPortal, 26 | DialogFooter, 27 | DialogHeader, 28 | DialogContent, 29 | DialogOverlay, 30 | DialogTrigger, 31 | DialogDescription, 32 | } from "./dialog"; 33 | import { Popover, PopoverContent, PopoverTrigger } from "./popover"; 34 | import { 35 | Accordion, 36 | AccordionItem, 37 | AccordionContent, 38 | AccordionTrigger, 39 | } from "./accordion"; 40 | import { 41 | Select, 42 | SelectItem, 43 | SelectValue, 44 | SelectContent, 45 | SelectTrigger, 46 | } from "./select"; 47 | import MultipleSelector from "./multiple-selector"; 48 | import { 49 | AlertDialog, 50 | AlertDialogTitle, 51 | AlertDialogCancel, 52 | AlertDialogHeader, 53 | AlertDialogFooter, 54 | AlertDialogPortal, 55 | AlertDialogAction, 56 | AlertDialogContent, 57 | AlertDialogOverlay, 58 | AlertDialogTrigger, 59 | AlertDialogDescription, 60 | } from "./alert-dialog"; 61 | import { Badge } from "./badge"; 62 | 63 | export { 64 | Button, 65 | Badge, 66 | Input, 67 | Card, 68 | CardTitle, 69 | CardHeader, 70 | CardContent, 71 | CardDescription, 72 | CardFooter, 73 | DropdownMenu, 74 | DropdownMenuItem, 75 | DropdownMenuLabel, 76 | DropdownMenuTrigger, 77 | DropdownMenuContent, 78 | DropdownMenuRadioItem, 79 | DropdownMenuSeparator, 80 | DropdownMenuRadioGroup, 81 | Dialog, 82 | DialogTitle, 83 | DialogClose, 84 | DialogPortal, 85 | DialogFooter, 86 | DialogHeader, 87 | DialogContent, 88 | DialogOverlay, 89 | DialogTrigger, 90 | DialogDescription, 91 | Popover, 92 | PopoverContent, 93 | PopoverTrigger, 94 | Accordion, 95 | AccordionItem, 96 | AccordionContent, 97 | AccordionTrigger, 98 | Select, 99 | SelectItem, 100 | SelectValue, 101 | SelectTrigger, 102 | SelectContent, 103 | MultipleSelector, 104 | AlertDialog, 105 | AlertDialogTitle, 106 | AlertDialogCancel, 107 | AlertDialogHeader, 108 | AlertDialogFooter, 109 | AlertDialogPortal, 110 | AlertDialogAction, 111 | AlertDialogContent, 112 | AlertDialogOverlay, 113 | AlertDialogTrigger, 114 | AlertDialogDescription, 115 | }; 116 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | } 22 | ); 23 | Input.displayName = "Input"; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /components/ui/magic/bento-grid.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { cn } from "@/lib/utils"; 3 | import { ArrowRightIcon } from "@radix-ui/react-icons"; 4 | import { ReactNode } from "react"; 5 | 6 | const BentoGrid = ({ 7 | children, 8 | className, 9 | }: { 10 | children: ReactNode; 11 | className?: string; 12 | }) => { 13 | return ( 14 |
20 | {children} 21 |
22 | ); 23 | }; 24 | 25 | const BentoCard = ({ 26 | name, 27 | className, 28 | background, 29 | Icon, 30 | description, 31 | href, 32 | cta, 33 | }: { 34 | name: string; 35 | className: string; 36 | background: ReactNode; 37 | Icon: any; 38 | description: string; 39 | href: string; 40 | cta: string; 41 | }) => ( 42 |
53 |
{background}
54 |
55 | 56 |

57 | {name} 58 |

59 |

{description}

60 |
61 | 62 |
67 | 73 |
74 |
75 |
76 | ); 77 | 78 | export { BentoCard, BentoGrid }; 79 | -------------------------------------------------------------------------------- /components/ui/magic/index.tsx: -------------------------------------------------------------------------------- 1 | import { BentoGrid, BentoCard } from "./bento-grid"; 2 | import Marquee from "./marquee"; 3 | 4 | export { 5 | BentoGrid, 6 | BentoCard, 7 | Marquee, 8 | }; 9 | -------------------------------------------------------------------------------- /components/ui/magic/marquee.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | interface MarqueeProps { 4 | className?: string; 5 | reverse?: boolean; 6 | pauseOnHover?: boolean; 7 | children?: React.ReactNode; 8 | vertical?: boolean; 9 | repeat?: number; 10 | [key: string]: any; 11 | } 12 | 13 | export default function Marquee({ 14 | reverse, 15 | children, 16 | className, 17 | repeat = 4, 18 | vertical = false, 19 | pauseOnHover = false, 20 | ...props 21 | }: MarqueeProps) { 22 | return ( 23 |
34 | {Array(repeat) 35 | .fill(0) 36 | .map((_, i) => ( 37 |
46 | {children} 47 |
48 | ))} 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )) 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 30 | 31 | export { Popover, PopoverTrigger, PopoverContent } 32 | -------------------------------------------------------------------------------- /components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SelectPrimitive from "@radix-ui/react-select" 5 | import { Check, ChevronDown, ChevronUp } from "lucide-react" 6 | import { cn } from "@/lib/utils" 7 | 8 | const Select = SelectPrimitive.Root 9 | 10 | const SelectGroup = SelectPrimitive.Group 11 | 12 | const SelectValue = SelectPrimitive.Value 13 | 14 | const SelectTrigger = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, children, ...props }, ref) => ( 18 | span]:line-clamp-1", 22 | className 23 | )} 24 | {...props} 25 | > 26 | {children} 27 | 28 | 29 | 30 | 31 | )) 32 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 33 | 34 | const SelectScrollUpButton = React.forwardRef< 35 | React.ElementRef, 36 | React.ComponentPropsWithoutRef 37 | >(({ className, ...props }, ref) => ( 38 | 46 | 47 | 48 | )) 49 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 50 | 51 | const SelectScrollDownButton = React.forwardRef< 52 | React.ElementRef, 53 | React.ComponentPropsWithoutRef 54 | >(({ className, ...props }, ref) => ( 55 | 63 | 64 | 65 | )) 66 | SelectScrollDownButton.displayName = 67 | SelectPrimitive.ScrollDownButton.displayName 68 | 69 | const SelectContent = React.forwardRef< 70 | React.ElementRef, 71 | React.ComponentPropsWithoutRef 72 | >(({ className, children, position = "popper", ...props }, ref) => ( 73 | 74 | 85 | 86 | 93 | {children} 94 | 95 | 96 | 97 | 98 | )) 99 | SelectContent.displayName = SelectPrimitive.Content.displayName 100 | 101 | const SelectLabel = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | SelectLabel.displayName = SelectPrimitive.Label.displayName 112 | 113 | const SelectItem = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, children, ...props }, ref) => ( 117 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | {children} 132 | 133 | )) 134 | SelectItem.displayName = SelectPrimitive.Item.displayName 135 | 136 | const SelectSeparator = React.forwardRef< 137 | React.ElementRef, 138 | React.ComponentPropsWithoutRef 139 | >(({ className, ...props }, ref) => ( 140 | 145 | )) 146 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 147 | 148 | export { 149 | Select, 150 | SelectGroup, 151 | SelectValue, 152 | SelectTrigger, 153 | SelectContent, 154 | SelectLabel, 155 | SelectItem, 156 | SelectSeparator, 157 | SelectScrollUpButton, 158 | SelectScrollDownButton, 159 | } 160 | -------------------------------------------------------------------------------- /components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 27 | )) 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 31 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | import dotenv from "dotenv" 3 | 4 | dotenv.config({ path: ".env" }); 5 | 6 | dotenv.config(); 7 | export default { 8 | schema: "./drizzle/schema/*.ts", 9 | out: "./drizzle", 10 | driver: "pg", 11 | dbCredentials: { 12 | connectionString: process.env.DATABASE_URL as string, 13 | }, 14 | } as Config; 15 | -------------------------------------------------------------------------------- /drizzle/0000_real_quasimodo.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "bookmark" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "user_id" uuid, 4 | "folder_id" uuid, 5 | "title" varchar(255), 6 | "url" varchar(255), 7 | "description" varchar(255), 8 | "image" varchar(255), 9 | "created_at" timestamp with time zone DEFAULT now(), 10 | "updated_at" time with time zone DEFAULT now(), 11 | "deleted_at" time 12 | ); 13 | --> statement-breakpoint 14 | CREATE TABLE IF NOT EXISTS "bookmark_tag" ( 15 | "bookmark_id" uuid, 16 | "tag_id" uuid 17 | ); 18 | --> statement-breakpoint 19 | CREATE TABLE IF NOT EXISTS "folder" ( 20 | "id" uuid PRIMARY KEY NOT NULL, 21 | "user_id" uuid, 22 | "name" varchar(255), 23 | "is_public" boolean DEFAULT false, 24 | "created_at" timestamp with time zone DEFAULT now(), 25 | "updated_at" timestamp with time zone DEFAULT now(), 26 | "deleted_at" timestamp with time zone 27 | ); 28 | --> statement-breakpoint 29 | CREATE TABLE IF NOT EXISTS "folder_share" ( 30 | "id" uuid PRIMARY KEY NOT NULL, 31 | "folder_id" uuid, 32 | "shared_with_user_id" uuid, 33 | "permission" varchar(20) DEFAULT 'view', 34 | "created_at" timestamp with time zone DEFAULT now(), 35 | "updated_at" timestamp with time zone DEFAULT now() 36 | ); 37 | --> statement-breakpoint 38 | CREATE TABLE IF NOT EXISTS "tag" ( 39 | "id" uuid PRIMARY KEY NOT NULL, 40 | "user_id" uuid, 41 | "name" varchar(255), 42 | "created_at" time with time zone DEFAULT now() 43 | ); 44 | --> statement-breakpoint 45 | CREATE TABLE IF NOT EXISTS "user" ( 46 | "id" uuid PRIMARY KEY NOT NULL, 47 | "username" varchar(255), 48 | "email" varchar(255), 49 | "profile_image" varchar(255), 50 | "password" varchar(255), 51 | "created_at" time with time zone DEFAULT now() 52 | ); 53 | --> statement-breakpoint 54 | DO $$ BEGIN 55 | ALTER TABLE "bookmark" ADD CONSTRAINT "bookmark_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action; 56 | EXCEPTION 57 | WHEN duplicate_object THEN null; 58 | END $$; 59 | --> statement-breakpoint 60 | DO $$ BEGIN 61 | ALTER TABLE "bookmark" ADD CONSTRAINT "bookmark_folder_id_folder_id_fk" FOREIGN KEY ("folder_id") REFERENCES "folder"("id") ON DELETE no action ON UPDATE no action; 62 | EXCEPTION 63 | WHEN duplicate_object THEN null; 64 | END $$; 65 | --> statement-breakpoint 66 | DO $$ BEGIN 67 | ALTER TABLE "bookmark_tag" ADD CONSTRAINT "bookmark_tag_bookmark_id_bookmark_id_fk" FOREIGN KEY ("bookmark_id") REFERENCES "bookmark"("id") ON DELETE no action ON UPDATE no action; 68 | EXCEPTION 69 | WHEN duplicate_object THEN null; 70 | END $$; 71 | --> statement-breakpoint 72 | DO $$ BEGIN 73 | ALTER TABLE "bookmark_tag" ADD CONSTRAINT "bookmark_tag_tag_id_tag_id_fk" FOREIGN KEY ("tag_id") REFERENCES "tag"("id") ON DELETE no action ON UPDATE no action; 74 | EXCEPTION 75 | WHEN duplicate_object THEN null; 76 | END $$; 77 | --> statement-breakpoint 78 | DO $$ BEGIN 79 | ALTER TABLE "folder" ADD CONSTRAINT "folder_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action; 80 | EXCEPTION 81 | WHEN duplicate_object THEN null; 82 | END $$; 83 | --> statement-breakpoint 84 | DO $$ BEGIN 85 | ALTER TABLE "folder_share" ADD CONSTRAINT "folder_share_folder_id_folder_id_fk" FOREIGN KEY ("folder_id") REFERENCES "folder"("id") ON DELETE no action ON UPDATE no action; 86 | EXCEPTION 87 | WHEN duplicate_object THEN null; 88 | END $$; 89 | --> statement-breakpoint 90 | DO $$ BEGIN 91 | ALTER TABLE "folder_share" ADD CONSTRAINT "folder_share_shared_with_user_id_user_id_fk" FOREIGN KEY ("shared_with_user_id") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action; 92 | EXCEPTION 93 | WHEN duplicate_object THEN null; 94 | END $$; 95 | --> statement-breakpoint 96 | DO $$ BEGIN 97 | ALTER TABLE "tag" ADD CONSTRAINT "tag_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action; 98 | EXCEPTION 99 | WHEN duplicate_object THEN null; 100 | END $$; 101 | -------------------------------------------------------------------------------- /drizzle/health.ts: -------------------------------------------------------------------------------- 1 | import db, { client } from "@/lib/database"; 2 | import { sql } from "drizzle-orm"; 3 | 4 | async function main() { 5 | try { 6 | await getUser(); 7 | 8 | console.info("🟢 Database check completed successfully"); 9 | } catch (error) { 10 | console.error("❌ Database check failed:", error); 11 | process.exit(1); 12 | } finally { 13 | await client?.end(); 14 | } 15 | } 16 | 17 | async function getUser() { 18 | console.info("🔍 Fetching user data..."); 19 | 20 | const users = await db.execute(sql`SELECT * FROM user`); 21 | 22 | console.log("✅ Retrieved users:", users); 23 | } 24 | 25 | export default main; 26 | -------------------------------------------------------------------------------- /drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "3fbdfb7d-f6fc-41ff-b969-241b407a32cc", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "5", 5 | "dialect": "pg", 6 | "tables": { 7 | "bookmark": { 8 | "name": "bookmark", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "uuid", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "user_id": { 18 | "name": "user_id", 19 | "type": "uuid", 20 | "primaryKey": false, 21 | "notNull": false 22 | }, 23 | "folder_id": { 24 | "name": "folder_id", 25 | "type": "uuid", 26 | "primaryKey": false, 27 | "notNull": false 28 | }, 29 | "title": { 30 | "name": "title", 31 | "type": "varchar(255)", 32 | "primaryKey": false, 33 | "notNull": false 34 | }, 35 | "url": { 36 | "name": "url", 37 | "type": "varchar(255)", 38 | "primaryKey": false, 39 | "notNull": false 40 | }, 41 | "description": { 42 | "name": "description", 43 | "type": "varchar(255)", 44 | "primaryKey": false, 45 | "notNull": false 46 | }, 47 | "image": { 48 | "name": "image", 49 | "type": "varchar(255)", 50 | "primaryKey": false, 51 | "notNull": false 52 | }, 53 | "created_at": { 54 | "name": "created_at", 55 | "type": "timestamp with time zone", 56 | "primaryKey": false, 57 | "notNull": false, 58 | "default": "now()" 59 | }, 60 | "updated_at": { 61 | "name": "updated_at", 62 | "type": "time with time zone", 63 | "primaryKey": false, 64 | "notNull": false, 65 | "default": "now()" 66 | }, 67 | "deleted_at": { 68 | "name": "deleted_at", 69 | "type": "time", 70 | "primaryKey": false, 71 | "notNull": false 72 | } 73 | }, 74 | "indexes": {}, 75 | "foreignKeys": { 76 | "bookmark_user_id_user_id_fk": { 77 | "name": "bookmark_user_id_user_id_fk", 78 | "tableFrom": "bookmark", 79 | "tableTo": "user", 80 | "columnsFrom": [ 81 | "user_id" 82 | ], 83 | "columnsTo": [ 84 | "id" 85 | ], 86 | "onDelete": "no action", 87 | "onUpdate": "no action" 88 | }, 89 | "bookmark_folder_id_folder_id_fk": { 90 | "name": "bookmark_folder_id_folder_id_fk", 91 | "tableFrom": "bookmark", 92 | "tableTo": "folder", 93 | "columnsFrom": [ 94 | "folder_id" 95 | ], 96 | "columnsTo": [ 97 | "id" 98 | ], 99 | "onDelete": "no action", 100 | "onUpdate": "no action" 101 | } 102 | }, 103 | "compositePrimaryKeys": {}, 104 | "uniqueConstraints": {} 105 | }, 106 | "bookmark_tag": { 107 | "name": "bookmark_tag", 108 | "schema": "", 109 | "columns": { 110 | "bookmark_id": { 111 | "name": "bookmark_id", 112 | "type": "uuid", 113 | "primaryKey": false, 114 | "notNull": false 115 | }, 116 | "tag_id": { 117 | "name": "tag_id", 118 | "type": "uuid", 119 | "primaryKey": false, 120 | "notNull": false 121 | } 122 | }, 123 | "indexes": {}, 124 | "foreignKeys": { 125 | "bookmark_tag_bookmark_id_bookmark_id_fk": { 126 | "name": "bookmark_tag_bookmark_id_bookmark_id_fk", 127 | "tableFrom": "bookmark_tag", 128 | "tableTo": "bookmark", 129 | "columnsFrom": [ 130 | "bookmark_id" 131 | ], 132 | "columnsTo": [ 133 | "id" 134 | ], 135 | "onDelete": "no action", 136 | "onUpdate": "no action" 137 | }, 138 | "bookmark_tag_tag_id_tag_id_fk": { 139 | "name": "bookmark_tag_tag_id_tag_id_fk", 140 | "tableFrom": "bookmark_tag", 141 | "tableTo": "tag", 142 | "columnsFrom": [ 143 | "tag_id" 144 | ], 145 | "columnsTo": [ 146 | "id" 147 | ], 148 | "onDelete": "no action", 149 | "onUpdate": "no action" 150 | } 151 | }, 152 | "compositePrimaryKeys": {}, 153 | "uniqueConstraints": {} 154 | }, 155 | "folder": { 156 | "name": "folder", 157 | "schema": "", 158 | "columns": { 159 | "id": { 160 | "name": "id", 161 | "type": "uuid", 162 | "primaryKey": true, 163 | "notNull": true 164 | }, 165 | "user_id": { 166 | "name": "user_id", 167 | "type": "uuid", 168 | "primaryKey": false, 169 | "notNull": false 170 | }, 171 | "name": { 172 | "name": "name", 173 | "type": "varchar(255)", 174 | "primaryKey": false, 175 | "notNull": false 176 | }, 177 | "is_public": { 178 | "name": "is_public", 179 | "type": "boolean", 180 | "primaryKey": false, 181 | "notNull": false, 182 | "default": false 183 | }, 184 | "created_at": { 185 | "name": "created_at", 186 | "type": "timestamp with time zone", 187 | "primaryKey": false, 188 | "notNull": false, 189 | "default": "now()" 190 | }, 191 | "updated_at": { 192 | "name": "updated_at", 193 | "type": "timestamp with time zone", 194 | "primaryKey": false, 195 | "notNull": false, 196 | "default": "now()" 197 | }, 198 | "deleted_at": { 199 | "name": "deleted_at", 200 | "type": "timestamp with time zone", 201 | "primaryKey": false, 202 | "notNull": false 203 | } 204 | }, 205 | "indexes": {}, 206 | "foreignKeys": { 207 | "folder_user_id_user_id_fk": { 208 | "name": "folder_user_id_user_id_fk", 209 | "tableFrom": "folder", 210 | "tableTo": "user", 211 | "columnsFrom": [ 212 | "user_id" 213 | ], 214 | "columnsTo": [ 215 | "id" 216 | ], 217 | "onDelete": "no action", 218 | "onUpdate": "no action" 219 | } 220 | }, 221 | "compositePrimaryKeys": {}, 222 | "uniqueConstraints": {} 223 | }, 224 | "folder_share": { 225 | "name": "folder_share", 226 | "schema": "", 227 | "columns": { 228 | "id": { 229 | "name": "id", 230 | "type": "uuid", 231 | "primaryKey": true, 232 | "notNull": true 233 | }, 234 | "folder_id": { 235 | "name": "folder_id", 236 | "type": "uuid", 237 | "primaryKey": false, 238 | "notNull": false 239 | }, 240 | "shared_with_user_id": { 241 | "name": "shared_with_user_id", 242 | "type": "uuid", 243 | "primaryKey": false, 244 | "notNull": false 245 | }, 246 | "permission": { 247 | "name": "permission", 248 | "type": "varchar(20)", 249 | "primaryKey": false, 250 | "notNull": false, 251 | "default": "'view'" 252 | }, 253 | "created_at": { 254 | "name": "created_at", 255 | "type": "timestamp with time zone", 256 | "primaryKey": false, 257 | "notNull": false, 258 | "default": "now()" 259 | }, 260 | "updated_at": { 261 | "name": "updated_at", 262 | "type": "timestamp with time zone", 263 | "primaryKey": false, 264 | "notNull": false, 265 | "default": "now()" 266 | } 267 | }, 268 | "indexes": {}, 269 | "foreignKeys": { 270 | "folder_share_folder_id_folder_id_fk": { 271 | "name": "folder_share_folder_id_folder_id_fk", 272 | "tableFrom": "folder_share", 273 | "tableTo": "folder", 274 | "columnsFrom": [ 275 | "folder_id" 276 | ], 277 | "columnsTo": [ 278 | "id" 279 | ], 280 | "onDelete": "no action", 281 | "onUpdate": "no action" 282 | }, 283 | "folder_share_shared_with_user_id_user_id_fk": { 284 | "name": "folder_share_shared_with_user_id_user_id_fk", 285 | "tableFrom": "folder_share", 286 | "tableTo": "user", 287 | "columnsFrom": [ 288 | "shared_with_user_id" 289 | ], 290 | "columnsTo": [ 291 | "id" 292 | ], 293 | "onDelete": "no action", 294 | "onUpdate": "no action" 295 | } 296 | }, 297 | "compositePrimaryKeys": {}, 298 | "uniqueConstraints": {} 299 | }, 300 | "tag": { 301 | "name": "tag", 302 | "schema": "", 303 | "columns": { 304 | "id": { 305 | "name": "id", 306 | "type": "uuid", 307 | "primaryKey": true, 308 | "notNull": true 309 | }, 310 | "user_id": { 311 | "name": "user_id", 312 | "type": "uuid", 313 | "primaryKey": false, 314 | "notNull": false 315 | }, 316 | "name": { 317 | "name": "name", 318 | "type": "varchar(255)", 319 | "primaryKey": false, 320 | "notNull": false 321 | }, 322 | "created_at": { 323 | "name": "created_at", 324 | "type": "time with time zone", 325 | "primaryKey": false, 326 | "notNull": false, 327 | "default": "now()" 328 | } 329 | }, 330 | "indexes": {}, 331 | "foreignKeys": { 332 | "tag_user_id_user_id_fk": { 333 | "name": "tag_user_id_user_id_fk", 334 | "tableFrom": "tag", 335 | "tableTo": "user", 336 | "columnsFrom": [ 337 | "user_id" 338 | ], 339 | "columnsTo": [ 340 | "id" 341 | ], 342 | "onDelete": "no action", 343 | "onUpdate": "no action" 344 | } 345 | }, 346 | "compositePrimaryKeys": {}, 347 | "uniqueConstraints": {} 348 | }, 349 | "user": { 350 | "name": "user", 351 | "schema": "", 352 | "columns": { 353 | "id": { 354 | "name": "id", 355 | "type": "uuid", 356 | "primaryKey": true, 357 | "notNull": true 358 | }, 359 | "username": { 360 | "name": "username", 361 | "type": "varchar(255)", 362 | "primaryKey": false, 363 | "notNull": false 364 | }, 365 | "email": { 366 | "name": "email", 367 | "type": "varchar(255)", 368 | "primaryKey": false, 369 | "notNull": false 370 | }, 371 | "profile_image": { 372 | "name": "profile_image", 373 | "type": "varchar(255)", 374 | "primaryKey": false, 375 | "notNull": false 376 | }, 377 | "password": { 378 | "name": "password", 379 | "type": "varchar(255)", 380 | "primaryKey": false, 381 | "notNull": false 382 | }, 383 | "created_at": { 384 | "name": "created_at", 385 | "type": "time with time zone", 386 | "primaryKey": false, 387 | "notNull": false, 388 | "default": "now()" 389 | } 390 | }, 391 | "indexes": {}, 392 | "foreignKeys": {}, 393 | "compositePrimaryKeys": {}, 394 | "uniqueConstraints": {} 395 | } 396 | }, 397 | "enums": {}, 398 | "schemas": {}, 399 | "_meta": { 400 | "columns": {}, 401 | "schemas": {}, 402 | "tables": {} 403 | } 404 | } -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | {"version":"5","dialect":"pg","entries":[{"idx":0,"version":"5","when":1728032944513,"tag":"0000_real_quasimodo","breakpoints":true}]} -------------------------------------------------------------------------------- /drizzle/schema/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | pgTable, 3 | uuid, 4 | varchar, 5 | time, 6 | timestamp, 7 | boolean, 8 | } from "drizzle-orm/pg-core"; 9 | 10 | export const user = pgTable("user", { 11 | id: uuid("id").primaryKey(), 12 | username: varchar("username", { length: 255 }), 13 | email: varchar("email", { length: 255 }), 14 | profile_image: varchar("profile_image", { length: 255 }), 15 | password: varchar("password", { length: 255 }), 16 | created_at: time("created_at", { withTimezone: true }).defaultNow(), 17 | }); 18 | 19 | export const bookmark = pgTable("bookmark", { 20 | id: uuid("id").primaryKey(), 21 | user_id: uuid("user_id").references(() => user.id), 22 | folder_id: uuid("folder_id").references(() => folder.id), 23 | title: varchar("title", { length: 255 }), 24 | url: varchar("url", { length: 255 }), 25 | description: varchar("description", { length: 255 }), 26 | image: varchar("image", { length: 255 }), 27 | created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), 28 | updated_at: time("updated_at", { withTimezone: true }).defaultNow(), 29 | deleted_at: time("deleted_at", {}), 30 | }); 31 | 32 | export const folder = pgTable("folder", { 33 | id: uuid("id").primaryKey(), 34 | user_id: uuid("user_id").references(() => user.id), 35 | name: varchar("name", { length: 255 }), 36 | is_public: boolean("is_public").default(false), 37 | created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), 38 | updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), 39 | deleted_at: timestamp("deleted_at", { withTimezone: true }), 40 | }); 41 | 42 | export const tag = pgTable("tag", { 43 | id: uuid("id").primaryKey(), 44 | user_id: uuid("user_id").references(() => user.id), 45 | name: varchar("name", { length: 255 }), 46 | created_at: time("created_at", { withTimezone: true }).defaultNow(), 47 | }); 48 | 49 | export const bookmark_tag = pgTable("bookmark_tag", { 50 | bookmark_id: uuid("bookmark_id").references(() => bookmark.id), 51 | tag_id: uuid("tag_id").references(() => tag.id), 52 | }); 53 | 54 | export const folder_share = pgTable("folder_share", { 55 | id: uuid("id").primaryKey(), 56 | folder_id: uuid("folder_id").references(() => folder.id), 57 | shared_with_user_id: uuid("shared_with_user_id").references(() => user.id), 58 | permission: varchar("permission", { length: 20 }).default("view"), 59 | created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), 60 | updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), 61 | }); 62 | -------------------------------------------------------------------------------- /hooks/createBookmark.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "react-query"; 2 | import { addBookmark } from "@/app/utils/action"; 3 | import { queryKeys } from "@/app/utils/queryKeys"; 4 | 5 | export const useCreateBookmark = ( 6 | userId: string, 7 | onSuccessCallback = () => {} 8 | ) => { 9 | const queryClient = useQueryClient(); 10 | 11 | const createBookmark = useMutation({ 12 | mutationFn: (data: { url: string; tags?: string[]; folderId?: string }) => 13 | addBookmark(userId, data.url, data.tags, data.folderId), 14 | onError: (error) => { 15 | console.error("Error creating bookmark:", error); 16 | }, 17 | onSuccess: () => { 18 | queryClient.invalidateQueries(queryKeys.bookmarks(userId)); 19 | onSuccessCallback(); 20 | }, 21 | }); 22 | 23 | return createBookmark; 24 | }; 25 | 26 | export default useCreateBookmark; 27 | -------------------------------------------------------------------------------- /hooks/createFolder.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "react-query"; 2 | import { addFolder } from "@/app/utils/action"; 3 | import { queryKeys } from "@/app/utils/queryKeys"; 4 | 5 | export const useCreateFolder = ( 6 | userId: string, 7 | onSuccessCallback = () => {} 8 | ) => { 9 | const queryClient = useQueryClient(); 10 | 11 | const createFolder = useMutation({ 12 | mutationFn: (folderName: string) => addFolder(userId, folderName), 13 | onError: (error) => { 14 | console.error("Error creating folder:", error); 15 | }, 16 | onSuccess: () => { 17 | queryClient.invalidateQueries(queryKeys.folders(userId)); 18 | onSuccessCallback(); 19 | }, 20 | }); 21 | 22 | return createFolder; 23 | }; 24 | 25 | export default useCreateFolder; 26 | -------------------------------------------------------------------------------- /hooks/createTagInBookmark.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "react-query"; 2 | import { createTagInBookmark } from "@/app/utils/action"; 3 | import { queryKeys } from "@/app/utils/queryKeys"; 4 | 5 | export const useCreateTagInBookmark = ( 6 | userId: string, 7 | bookmarkId: string, 8 | onSuccessCallback = () => {} 9 | ) => { 10 | const queryClient = useQueryClient(); 11 | 12 | const createTagInBookmarkMutation = useMutation({ 13 | mutationFn: (tagIds: string | string[]) => createTagInBookmark(userId, bookmarkId, tagIds), 14 | onSuccess: () => { 15 | queryClient.invalidateQueries(queryKeys.bookmarks(userId)); 16 | onSuccessCallback(); 17 | }, 18 | onError: (error) => { 19 | console.error("Error creating tag:", error); 20 | } 21 | }); 22 | 23 | return createTagInBookmarkMutation; 24 | } 25 | 26 | export default useCreateTagInBookmark; -------------------------------------------------------------------------------- /hooks/deleteBookmark.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "react-query"; 2 | import { deleteBookmark } from "@/app/utils/action"; 3 | import { queryKeys } from "@/app/utils/queryKeys"; 4 | 5 | export const useDeleteBookmark = ( 6 | userId: string, 7 | onSuccessCallback = () => {} 8 | ) => { 9 | const queryClient = useQueryClient(); 10 | 11 | const deleteBookmarkMutation = useMutation({ 12 | mutationFn: (bookmarkId: string) => deleteBookmark(userId, bookmarkId), 13 | onError: (error) => { 14 | console.error("Error deleting folder:", error); 15 | }, 16 | onSuccess: () => { 17 | queryClient.invalidateQueries(queryKeys.bookmarks(userId)); 18 | onSuccessCallback(); 19 | }, 20 | }); 21 | 22 | return deleteBookmarkMutation; 23 | }; 24 | 25 | export default useDeleteBookmark; 26 | -------------------------------------------------------------------------------- /hooks/deleteFolder.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "react-query"; 2 | import { deleteFolder } from "@/app/utils/action"; 3 | import { queryKeys } from "@/app/utils/queryKeys"; 4 | 5 | export const useDeleteFolder = ( 6 | userId: string, 7 | onSuccessCallback = () => {} 8 | ) => { 9 | const queryClient = useQueryClient(); 10 | 11 | const deleteFolderMutation = useMutation({ 12 | mutationFn: (folderId: string) => deleteFolder(userId, folderId), 13 | onError: (error) => { 14 | console.error("Error deleting folder:", error); 15 | }, 16 | onSuccess: () => { 17 | queryClient.invalidateQueries(queryKeys.folders(userId)); 18 | queryClient.invalidateQueries(queryKeys.bookmarks(userId)); 19 | onSuccessCallback(); 20 | }, 21 | }); 22 | 23 | return deleteFolderMutation; 24 | }; 25 | 26 | export default useDeleteFolder; 27 | -------------------------------------------------------------------------------- /hooks/deleteTag.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "react-query"; 2 | import { deleteTag } from "@/app/utils/action"; 3 | import { queryKeys } from "@/app/utils/queryKeys"; 4 | 5 | export const useDeleteTag = (userId: string) => { 6 | const queryClient = useQueryClient(); 7 | 8 | const deleteTagMutation = useMutation({ 9 | mutationFn: (tagId: string) => deleteTag(userId, tagId), 10 | onError: (error) => { 11 | console.error("Error deleting tag:", error); 12 | }, 13 | onSuccess: () => { 14 | queryClient.invalidateQueries(queryKeys.tags(userId)); 15 | queryClient.invalidateQueries(queryKeys.bookmarks(userId)); 16 | }, 17 | }); 18 | 19 | return deleteTagMutation; 20 | }; 21 | 22 | export default useDeleteTag; 23 | -------------------------------------------------------------------------------- /hooks/deleteTagInBookmark.ts: -------------------------------------------------------------------------------- 1 | import { deleteTagInBookmark } from "@/app/utils/action"; 2 | import { queryKeys } from "@/app/utils/queryKeys"; 3 | import { useMutation, useQueryClient } from "react-query"; 4 | 5 | export const useDeleteTagInBookmark = (userId: string, bookmarkId: string) => { 6 | const queryClient = useQueryClient(); 7 | 8 | const deleteTagInBookmarkMutation = useMutation({ 9 | mutationFn: (tagId: string) => deleteTagInBookmark(userId, bookmarkId, tagId), 10 | onError: (error) => { 11 | console.error("Error deleting tag:", error); 12 | }, 13 | onSuccess: () => { 14 | queryClient.invalidateQueries(queryKeys.bookmarks(userId)); 15 | }, 16 | }) 17 | 18 | return deleteTagInBookmarkMutation; 19 | } 20 | 21 | export default useDeleteTagInBookmark; -------------------------------------------------------------------------------- /hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useBookmarks"; 2 | export * from "./createBookmark"; 3 | export * from "./deleteBookmark"; 4 | export * from "./useFolder"; 5 | export * from "./createFolder"; 6 | export * from "./renameFolder"; 7 | export * from "./renameTag"; 8 | export * from "./useTags"; 9 | export * from "./useUserId"; 10 | export * from "./deleteFolder"; 11 | export * from "./deleteTag"; 12 | 13 | export * from "./useTagNotInBookmark"; 14 | export * from "./deleteTagInBookmark"; 15 | export * from "./createTagInBookmark"; 16 | -------------------------------------------------------------------------------- /hooks/renameFolder.ts: -------------------------------------------------------------------------------- 1 | import { editFolder } from "@/app/utils/action"; 2 | import { queryKeys } from "@/app/utils/queryKeys"; 3 | import { useMutation, useQueryClient } from "react-query"; 4 | 5 | export const useRenameFolder = ( 6 | userId: string, 7 | onSuccessCallback = () => {} 8 | ) => { 9 | const queryClient = useQueryClient(); 10 | 11 | const renameFolderMutation = useMutation({ 12 | mutationFn: ({ 13 | folderId, 14 | newName, 15 | }: { 16 | folderId: string; 17 | newName: string; 18 | }) => editFolder(userId, folderId, newName), 19 | onError: (error) => { 20 | console.error("Error editing folder:", error); 21 | }, 22 | onSuccess: () => { 23 | queryClient.invalidateQueries(queryKeys.folders(userId)); 24 | onSuccessCallback(); 25 | }, 26 | }); 27 | 28 | return renameFolderMutation; 29 | }; 30 | 31 | export default useRenameFolder; 32 | -------------------------------------------------------------------------------- /hooks/renameTag.ts: -------------------------------------------------------------------------------- 1 | import { editTag } from "@/app/utils/action"; 2 | import { queryKeys } from "@/app/utils/queryKeys"; 3 | import { useMutation, useQueryClient } from "react-query"; 4 | 5 | export const useRenameTag = (userId: string, onSuccessCallback = () => {}) => { 6 | const queryClient = useQueryClient(); 7 | 8 | const renameFolderMutation = useMutation({ 9 | mutationFn: ({ tagId, newName }: { tagId: string; newName: string }) => 10 | editTag(userId, tagId, newName), 11 | onError: (error) => { 12 | console.error("Error editing folder:", error); 13 | }, 14 | onSuccess: () => { 15 | queryClient.invalidateQueries(queryKeys.tags(userId)); 16 | queryClient.invalidateQueries(queryKeys.bookmarks(userId)); 17 | queryClient.invalidateQueries(["tagNotInBookmark", userId]); 18 | onSuccessCallback(); 19 | }, 20 | }); 21 | 22 | return renameFolderMutation; 23 | }; 24 | 25 | export default useRenameTag; 26 | -------------------------------------------------------------------------------- /hooks/useBookmarks.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "react-query"; 2 | import { getBookmark } from "@/app/utils/action"; 3 | import { BookmarkData } from "@/app/utils/definition"; 4 | 5 | export const useBookmarks = ( 6 | userId: string, 7 | folderId?: string, 8 | query?: string 9 | ) => { 10 | const { data = [], isLoading, refetch } = useQuery({ 11 | queryKey: ["bookmarks", userId, folderId, query], 12 | queryFn: () => getBookmark(userId, folderId, query), 13 | enabled: !!userId, 14 | staleTime: 5 * 60 * 1000, 15 | cacheTime: 10 * 60 * 1000, 16 | }); 17 | 18 | return { bookmarks: data, isLoading, refetch }; 19 | }; 20 | 21 | export default useBookmarks; 22 | -------------------------------------------------------------------------------- /hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | function useDebounce(value: T, delay: number): T { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | 6 | useEffect(() => { 7 | const handler = setTimeout(() => { 8 | setDebouncedValue(value); 9 | }, delay); 10 | 11 | return () => { 12 | clearTimeout(handler); 13 | }; 14 | }, [value, delay]); 15 | 16 | return debouncedValue; 17 | } 18 | 19 | export default useDebounce; 20 | -------------------------------------------------------------------------------- /hooks/useFolder.ts: -------------------------------------------------------------------------------- 1 | import { getFolder } from "@/app/utils/action"; 2 | import { FolderData } from "@/app/utils/definition"; 3 | import { useQuery } from "react-query"; 4 | 5 | export const useFolders = (userId: string) => { 6 | const { data, isLoading } = useQuery({ 7 | queryKey: ["folders", userId], 8 | queryFn: async () => getFolder(userId), 9 | enabled: !!userId, 10 | }); 11 | 12 | return { folders: data || [], isLoading }; 13 | }; 14 | 15 | export default useFolders; -------------------------------------------------------------------------------- /hooks/useTagNotInBookmark.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, useQueryClient } from "react-query"; 2 | import { getTagNotInBookmark } from "@/app/utils/action"; 3 | import { Option } from "@/components/ui/multiple-selector"; 4 | 5 | export const useTagsNotInBookmark = (userId: string, bookmarkId: string) => { 6 | const queryClient = useQueryClient(); 7 | 8 | const { data = [], isLoading } = useQuery({ 9 | queryKey: ["tagNotInBookmark", userId, bookmarkId], 10 | queryFn: () => getTagNotInBookmark(userId, bookmarkId), 11 | enabled: !!userId && !!bookmarkId, 12 | }); 13 | 14 | const options = data.map((tag) => ({ 15 | id: tag.id as string, 16 | label: tag.name as string, 17 | value: tag.name as string, 18 | })); 19 | 20 | const invalidateTagsQuery = () => { 21 | queryClient.invalidateQueries(["tagNotInBookmark", userId, bookmarkId]); 22 | }; 23 | 24 | return { options, isLoading, invalidateTagsQuery }; 25 | }; 26 | 27 | export default useTagsNotInBookmark; -------------------------------------------------------------------------------- /hooks/useTags.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, useQueryClient } from "react-query"; 2 | import { getTag } from "@/app/utils/action"; 3 | import { Option } from "@/components/ui/multiple-selector"; 4 | import { queryKeys } from "@/app/utils/queryKeys"; 5 | 6 | export const useTags = (userId: string) => { 7 | const queryClient = useQueryClient(); 8 | 9 | const { data = [], isLoading } = useQuery({ 10 | queryKey: ["tags", userId], 11 | queryFn: () => getTag(userId), 12 | enabled: !!userId, 13 | }); 14 | 15 | const options = data.map((tag) => ({ 16 | id: tag.id as string, 17 | label: tag.name as string, 18 | value: tag.name as string, 19 | })); 20 | 21 | const invalidateTags = () => { 22 | queryClient.invalidateQueries(queryKeys.tags(userId)); 23 | }; 24 | 25 | return { options, isLoading, invalidateTags }; 26 | }; 27 | 28 | export default useTags; 29 | -------------------------------------------------------------------------------- /hooks/useUpdateBookmarkFolder.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "react-query"; 2 | import { updateBookmarkFolder } from "@/app/utils/action"; 3 | import { queryKeys } from "@/app/utils/queryKeys"; 4 | 5 | export const useUpdateBookmarkFolder = (userId: string) => { 6 | const queryClient = useQueryClient(); 7 | 8 | return useMutation( 9 | ({ bookmarkId, folderId }: { bookmarkId: string; folderId: string }) => 10 | updateBookmarkFolder(userId, bookmarkId, folderId), 11 | { 12 | onSuccess: () => { 13 | queryClient.invalidateQueries(queryKeys.bookmarks(userId)); 14 | queryClient.invalidateQueries(queryKeys.folders(userId)) 15 | }, 16 | } 17 | ); 18 | }; -------------------------------------------------------------------------------- /hooks/useUserId.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "react-query"; 2 | import { useSession } from "next-auth/react"; 3 | import { getUserId } from "@/app/utils/action"; 4 | 5 | export default function useUserId() { 6 | const { data: session } = useSession(); 7 | 8 | const { data: userId } = useQuery( 9 | ["userId", session?.user?.email], 10 | () => { 11 | if (session?.user?.email) { 12 | return getUserId(session.user.email); 13 | } 14 | return null; 15 | }, 16 | { 17 | enabled: !!session?.user?.email, 18 | staleTime: 1000 * 60 * 10, // 10 minutes 19 | } 20 | ); 21 | 22 | return userId as string; 23 | } -------------------------------------------------------------------------------- /lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { checkIfuserExist, createNewUser } from "@/app/utils/action"; 2 | import { NextAuthOptions } from "next-auth"; 3 | import GithubProvider from "next-auth/providers/github"; 4 | 5 | export const authConfig: NextAuthOptions = { 6 | providers: [ 7 | GithubProvider({ 8 | clientId: process.env.GITHUB_CLIENT_ID as string, 9 | clientSecret: process.env.GITHUB_CLIENT_SECRET as string, 10 | }), 11 | ], 12 | callbacks: { 13 | async session({ session, user }) { 14 | if (user && session.user) { 15 | session.user.email = user.email; 16 | } 17 | 18 | return { 19 | ...session, 20 | }; 21 | }, 22 | 23 | async signIn({ user }) { 24 | const userEmail = user?.email as string; 25 | const response = await checkIfuserExist(userEmail as string); 26 | 27 | if (response == true) { 28 | return true; 29 | } else { 30 | const data = { 31 | username: user?.name as string, 32 | email: user?.email as string, 33 | profile_image: user?.image as string, 34 | }; 35 | await createNewUser(data); 36 | return true; 37 | } 38 | }, 39 | }, 40 | session: { 41 | strategy: "jwt", 42 | }, 43 | secret: process.env.NEXTAUTH_SECRET, 44 | }; 45 | -------------------------------------------------------------------------------- /lib/database.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from "drizzle-orm/postgres-js"; 2 | import postgres from "postgres"; 3 | 4 | const connectionURL = process.env.DATABASE_URL; 5 | if (!connectionURL) { 6 | throw new Error("DATABASE_URL environment variable is not provided."); 7 | } 8 | 9 | let client: ReturnType | undefined; 10 | let db: ReturnType; 11 | 12 | const getClient = (): ReturnType => { 13 | if (!client) { 14 | client = postgres(connectionURL, { 15 | max: 20, 16 | idle_timeout: 20, 17 | }); 18 | } 19 | return client; 20 | }; 21 | 22 | if (process.env.NODE_ENV === "development") { 23 | client = getClient(); 24 | } else { 25 | client = postgres(connectionURL); 26 | } 27 | 28 | db = drizzle(client); 29 | 30 | export * from "@/drizzle/schema"; 31 | export { client }; 32 | export default db; 33 | -------------------------------------------------------------------------------- /lib/queryClient.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "react-query"; 2 | 3 | const queryClient = new QueryClient({ 4 | defaultOptions: { 5 | queries: { 6 | // Disable automatic refetching when window regains focus 7 | refetchOnWindowFocus: false, 8 | // Keep data fresh for 5 minutes 9 | staleTime: 5 * 60 * 1000, 10 | // Cache data for 10 minutes 11 | cacheTime: 10 * 60 * 1000, 12 | }, 13 | }, 14 | }); 15 | 16 | export default queryClient; 17 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { withAuth } from "next-auth/middleware"; 2 | import { NextResponse } from "next/server"; 3 | 4 | export default withAuth( 5 | function middleware(req) { 6 | const isAuthPage = req.nextUrl.pathname.startsWith("/sign-in"); 7 | if (isAuthPage) { 8 | if (req.nextauth.token) { 9 | return NextResponse.redirect(new URL("/mind", req.url)); 10 | } 11 | 12 | return null; 13 | } 14 | 15 | if (!req.nextauth.token) { 16 | return NextResponse.redirect(new URL("/sign-in", req.url)); 17 | } 18 | }, 19 | { 20 | callbacks: { 21 | async authorized() { 22 | // This is a work-around for handling redirect on auth pages. 23 | // We return true here so that the middleware function above 24 | // is always called. 25 | return true; 26 | }, 27 | }, 28 | } 29 | ); 30 | 31 | export const config = { 32 | matcher: ["/sign-in", "/mind/:path*"], 33 | }; 34 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | domains: ["hooknhold.s3-ap-southeast-1.amazonaws.com"], 5 | }, 6 | experimental: { 7 | serverComponentsExternalPackages: ['puppeteer-core', '@sparticuz/chromium'], 8 | }, 9 | }; 10 | 11 | export default nextConfig; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hooknhold", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --port 3002", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "migration:generate": "drizzle-kit generate:pg", 11 | "migration:push": "drizzle-kit push:pg --config=drizzle.config.ts", 12 | "migration:drop": "drizzle-kit drop --config=drizzle.config.ts", 13 | "migrate": "drizzle-kit generate:pg && drizzle-kit push:pg --config=drizzle.config.ts" 14 | }, 15 | "dependencies": { 16 | "@aws-sdk/client-s3": "^3.550.0", 17 | "@aws-sdk/lib-storage": "^3.550.0", 18 | "@aws-sdk/s3-request-presigner": "^3.550.0", 19 | "@dnd-kit/core": "^6.1.0", 20 | "@dnd-kit/sortable": "^8.0.0", 21 | "@dnd-kit/utilities": "^3.2.2", 22 | "@radix-ui/react-accordion": "^1.1.2", 23 | "@radix-ui/react-alert-dialog": "^1.0.5", 24 | "@radix-ui/react-dialog": "^1.0.5", 25 | "@radix-ui/react-dropdown-menu": "^2.0.6", 26 | "@radix-ui/react-icons": "^1.3.0", 27 | "@radix-ui/react-popover": "^1.0.7", 28 | "@radix-ui/react-select": "^2.0.0", 29 | "@radix-ui/react-slot": "^1.0.2", 30 | "@radix-ui/react-tooltip": "^1.1.3", 31 | "@sparticuz/chromium": "^126.0.0", 32 | "class-variance-authority": "^0.7.0", 33 | "cmdk": "^1.0.0", 34 | "dotenv": "^16.4.5", 35 | "drizzle-orm": "^0.30.7", 36 | "lucide-react": "^0.365.0", 37 | "next": "14.1.4", 38 | "next-auth": "^4.24.7", 39 | "postgres": "^3.4.4", 40 | "puppeteer": "^22.13.1", 41 | "puppeteer-core": "^22.13.1", 42 | "react": "^18.0.0", 43 | "react-day-picker": "^8.10.1", 44 | "react-dom": "^18.0.0", 45 | "react-query": "^3.39.3", 46 | "tailwindcss-animate": "^1.0.7", 47 | "uuid": "^9.0.1" 48 | }, 49 | "devDependencies": { 50 | "@sparticuz/chromium": "^126.0.0", 51 | "@types/node": "^20.0.0", 52 | "@types/react": "^18.0.0", 53 | "@types/react-dom": "^18.0.0", 54 | "@types/uuid": "^9.0.8", 55 | "autoprefixer": "^10.0.1", 56 | "clsx": "^2.1.1", 57 | "drizzle-kit": "^0.20.14", 58 | "eslint": "^8.0.0", 59 | "eslint-config-next": "14.1.4", 60 | "framer-motion": "^11.2.10", 61 | "postcss": "^8.0.0", 62 | "tailwind-merge": "^2.3.0", 63 | "tailwindcss": "^3.4.4", 64 | "typescript": "^5.0.2" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/image/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendianugerah/hooknhold/fb969f9ffd52edf5b4088ffedd9da58677d7000b/public/image/hero.png -------------------------------------------------------------------------------- /public/image/hooknhold-preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendianugerah/hooknhold/fb969f9ffd52edf5b4088ffedd9da58677d7000b/public/image/hooknhold-preview.gif -------------------------------------------------------------------------------- /public/image/hooknhold-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendianugerah/hooknhold/fb969f9ffd52edf5b4088ffedd9da58677d7000b/public/image/hooknhold-preview.png -------------------------------------------------------------------------------- /public/image/icon/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendianugerah/hooknhold/fb969f9ffd52edf5b4088ffedd9da58677d7000b/public/image/icon/github.png -------------------------------------------------------------------------------- /public/image/icon/google.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendianugerah/hooknhold/fb969f9ffd52edf5b4088ffedd9da58677d7000b/public/image/icon/google.png -------------------------------------------------------------------------------- /public/image/tailwind.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dendianugerah/hooknhold/fb969f9ffd52edf5b4088ffedd9da58677d7000b/public/image/tailwind.png -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{ts,tsx}", 7 | "./components/**/*.{ts,tsx}", 8 | "./app/**/*.{ts,tsx}", 9 | "./src/**/*.{ts,tsx}", 10 | ], 11 | prefix: "", 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: "2rem", 16 | screens: { 17 | "2xl": "1400px", 18 | }, 19 | }, 20 | extend: { 21 | colors: { 22 | border: "hsl(var(--border))", 23 | input: "hsl(var(--input))", 24 | ring: "hsl(var(--ring))", 25 | background: "hsl(var(--background))", 26 | foreground: "hsl(var(--foreground))", 27 | primary: { 28 | DEFAULT: "hsl(var(--primary))", 29 | foreground: "hsl(var(--primary-foreground))", 30 | }, 31 | secondary: { 32 | DEFAULT: "hsl(var(--secondary))", 33 | foreground: "hsl(var(--secondary-foreground))", 34 | }, 35 | destructive: { 36 | DEFAULT: "hsl(var(--destructive))", 37 | foreground: "hsl(var(--destructive-foreground))", 38 | }, 39 | muted: { 40 | DEFAULT: "hsl(var(--muted))", 41 | foreground: "hsl(var(--muted-foreground))", 42 | }, 43 | accent: { 44 | DEFAULT: "hsl(var(--accent))", 45 | foreground: "hsl(var(--accent-foreground))", 46 | }, 47 | popover: { 48 | DEFAULT: "hsl(var(--popover))", 49 | foreground: "hsl(var(--popover-foreground))", 50 | }, 51 | card: { 52 | DEFAULT: "hsl(var(--card))", 53 | foreground: "hsl(var(--card-foreground))", 54 | }, 55 | }, 56 | borderRadius: { 57 | lg: "var(--radius)", 58 | md: "calc(var(--radius) - 2px)", 59 | sm: "calc(var(--radius) - 4px)", 60 | }, 61 | keyframes: { 62 | "accordion-down": { 63 | from: { height: "0" }, 64 | to: { height: "var(--radix-accordion-content-height)" }, 65 | }, 66 | "accordion-up": { 67 | from: { height: "var(--radix-accordion-content-height)" }, 68 | to: { height: "0" }, 69 | }, 70 | "shine-pulse": { 71 | "0%": { 72 | "background-position": "0% 0%", 73 | }, 74 | "50%": { 75 | "background-position": "100% 100%", 76 | }, 77 | to: { 78 | "background-position": "0% 0%", 79 | }, 80 | }, 81 | marquee: { 82 | from: { transform: "translateX(0)" }, 83 | to: { transform: "translateX(calc(-100% - var(--gap)))" }, 84 | }, 85 | "marquee-vertical": { 86 | from: { transform: "translateY(0)" }, 87 | to: { transform: "translateY(calc(-100% - var(--gap)))" }, 88 | }, 89 | }, 90 | animation: { 91 | "accordion-down": "accordion-down 0.2s ease-out", 92 | "accordion-up": "accordion-up 0.2s ease-out", 93 | marquee: "marquee var(--duration) linear infinite", 94 | "marquee-vertical": "marquee-vertical var(--duration) linear infinite", 95 | }, 96 | }, 97 | }, 98 | plugins: [require("tailwindcss-animate")], 99 | } satisfies Config; 100 | 101 | export default config; 102 | -------------------------------------------------------------------------------- /tmp/tmp.md: -------------------------------------------------------------------------------- 1 | hello world -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | --------------------------------------------------------------------------------