From 80bb8a108f29331cdb2f2695f6801beee104dc89 Mon Sep 17 00:00:00 2001 From: MohamedBassem Date: Thu, 8 Feb 2024 15:14:23 +0000 Subject: [refactor] Move the different packages to the package subdir --- packages/db/index.ts | 9 + packages/db/package.json | 11 ++ .../20240205153748_add_users/migration.sql | 56 ++++++ .../20240206000813_add_links/migration.sql | 43 +++++ .../20240206192241_add_favicon/migration.sql | 16 ++ .../migration.sql | 21 +++ packages/db/prisma/migrations/migration_lock.toml | 3 + packages/db/prisma/schema.prisma | 105 +++++++++++ packages/shared/index.ts | 1 + packages/shared/logger.ts | 15 ++ packages/shared/package.json | 10 ++ packages/shared/queues.ts | 29 +++ packages/web/.env.sample | 8 + packages/web/Dockerfile | 57 ++++++ packages/web/README.md | 36 ++++ packages/web/app/api/auth/[...nextauth]/route.tsx | 3 + packages/web/app/api/v1/links/[linkId]/route.ts | 32 ++++ packages/web/app/api/v1/links/route.ts | 49 +++++ .../app/dashboard/bookmarks/components/AddLink.tsx | 67 +++++++ .../dashboard/bookmarks/components/LinkCard.tsx | 96 ++++++++++ .../dashboard/bookmarks/components/LinksGrid.tsx | 21 +++ packages/web/app/dashboard/bookmarks/page.tsx | 20 +++ packages/web/app/dashboard/components/Sidebar.tsx | 56 ++++++ packages/web/app/dashboard/layout.tsx | 15 ++ packages/web/app/favicon.ico | Bin 0 -> 25931 bytes packages/web/app/globals.css | 76 ++++++++ packages/web/app/layout.tsx | 27 +++ packages/web/app/page.tsx | 19 ++ packages/web/bun.lockb | Bin 0 -> 158558 bytes packages/web/components.json | 17 ++ packages/web/components/auth/login.tsx | 17 ++ packages/web/components/auth/logout.tsx | 17 ++ packages/web/components/ui/badge.tsx | 36 ++++ packages/web/components/ui/button.tsx | 56 ++++++ packages/web/components/ui/card.tsx | 86 +++++++++ packages/web/components/ui/dropdown-menu.tsx | 200 +++++++++++++++++++++ packages/web/components/ui/form.tsx | 176 ++++++++++++++++++ packages/web/components/ui/imageCard.tsx | 56 ++++++ packages/web/components/ui/input.tsx | 25 +++ packages/web/components/ui/label.tsx | 26 +++ packages/web/components/ui/toast.tsx | 127 +++++++++++++ packages/web/components/ui/toaster.tsx | 35 ++++ packages/web/components/ui/use-toast.ts | 189 +++++++++++++++++++ packages/web/lib/api.ts | 81 +++++++++ packages/web/lib/auth.ts | 25 +++ packages/web/lib/config.ts | 22 +++ packages/web/lib/services/links.ts | 78 ++++++++ packages/web/lib/types/api/links.ts | 33 ++++ packages/web/lib/types/api/tags.ts | 6 + packages/web/lib/types/next-auth.d.ts | 12 ++ packages/web/lib/utils.ts | 6 + packages/web/next.config.mjs | 4 + packages/web/package.json | 41 +++++ packages/web/postcss.config.js | 6 + packages/web/public/next.svg | 1 + packages/web/public/vercel.svg | 1 + packages/web/tailwind.config.ts | 80 +++++++++ packages/web/tsconfig.json | 27 +++ packages/workers/crawler.ts | 78 ++++++++ packages/workers/index.ts | 58 ++++++ packages/workers/openai.ts | 163 +++++++++++++++++ packages/workers/package.json | 20 +++ 62 files changed, 2706 insertions(+) create mode 100644 packages/db/index.ts create mode 100644 packages/db/package.json create mode 100644 packages/db/prisma/migrations/20240205153748_add_users/migration.sql create mode 100644 packages/db/prisma/migrations/20240206000813_add_links/migration.sql create mode 100644 packages/db/prisma/migrations/20240206192241_add_favicon/migration.sql create mode 100644 packages/db/prisma/migrations/20240207204211_drop_extra_field_in_tags_links/migration.sql create mode 100644 packages/db/prisma/migrations/migration_lock.toml create mode 100644 packages/db/prisma/schema.prisma create mode 100644 packages/shared/index.ts create mode 100644 packages/shared/logger.ts create mode 100644 packages/shared/package.json create mode 100644 packages/shared/queues.ts create mode 100644 packages/web/.env.sample create mode 100644 packages/web/Dockerfile create mode 100644 packages/web/README.md create mode 100644 packages/web/app/api/auth/[...nextauth]/route.tsx create mode 100644 packages/web/app/api/v1/links/[linkId]/route.ts create mode 100644 packages/web/app/api/v1/links/route.ts create mode 100644 packages/web/app/dashboard/bookmarks/components/AddLink.tsx create mode 100644 packages/web/app/dashboard/bookmarks/components/LinkCard.tsx create mode 100644 packages/web/app/dashboard/bookmarks/components/LinksGrid.tsx create mode 100644 packages/web/app/dashboard/bookmarks/page.tsx create mode 100644 packages/web/app/dashboard/components/Sidebar.tsx create mode 100644 packages/web/app/dashboard/layout.tsx create mode 100644 packages/web/app/favicon.ico create mode 100644 packages/web/app/globals.css create mode 100644 packages/web/app/layout.tsx create mode 100644 packages/web/app/page.tsx create mode 100755 packages/web/bun.lockb create mode 100644 packages/web/components.json create mode 100644 packages/web/components/auth/login.tsx create mode 100644 packages/web/components/auth/logout.tsx create mode 100644 packages/web/components/ui/badge.tsx create mode 100644 packages/web/components/ui/button.tsx create mode 100644 packages/web/components/ui/card.tsx create mode 100644 packages/web/components/ui/dropdown-menu.tsx create mode 100644 packages/web/components/ui/form.tsx create mode 100644 packages/web/components/ui/imageCard.tsx create mode 100644 packages/web/components/ui/input.tsx create mode 100644 packages/web/components/ui/label.tsx create mode 100644 packages/web/components/ui/toast.tsx create mode 100644 packages/web/components/ui/toaster.tsx create mode 100644 packages/web/components/ui/use-toast.ts create mode 100644 packages/web/lib/api.ts create mode 100644 packages/web/lib/auth.ts create mode 100644 packages/web/lib/config.ts create mode 100644 packages/web/lib/services/links.ts create mode 100644 packages/web/lib/types/api/links.ts create mode 100644 packages/web/lib/types/api/tags.ts create mode 100644 packages/web/lib/types/next-auth.d.ts create mode 100644 packages/web/lib/utils.ts create mode 100644 packages/web/next.config.mjs create mode 100644 packages/web/package.json create mode 100644 packages/web/postcss.config.js create mode 100644 packages/web/public/next.svg create mode 100644 packages/web/public/vercel.svg create mode 100644 packages/web/tailwind.config.ts create mode 100644 packages/web/tsconfig.json create mode 100644 packages/workers/crawler.ts create mode 100644 packages/workers/index.ts create mode 100644 packages/workers/openai.ts create mode 100644 packages/workers/package.json (limited to 'packages') diff --git a/packages/db/index.ts b/packages/db/index.ts new file mode 100644 index 00000000..fa46ca1f --- /dev/null +++ b/packages/db/index.ts @@ -0,0 +1,9 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +// For some weird reason accessing @prisma/client from any package is causing problems (specially in error handling). +// Re export them here instead. +export * from "@prisma/client"; + +export default prisma; diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 00000000..b5222f8a --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@remember/db", + "version": "0.1.0", + "private": true, + "main": "index.ts", + "dependencies": { + "prisma": "^5.9.1", + "@prisma/client": "^5.9.1" + } +} diff --git a/packages/db/prisma/migrations/20240205153748_add_users/migration.sql b/packages/db/prisma/migrations/20240205153748_add_users/migration.sql new file mode 100644 index 00000000..cbf47073 --- /dev/null +++ b/packages/db/prisma/migrations/20240205153748_add_users/migration.sql @@ -0,0 +1,56 @@ +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "refresh_token" TEXT, + "access_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionToken" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expires" DATETIME NOT NULL, + CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT, + "email" TEXT, + "emailVerified" DATETIME, + "image" TEXT +); + +-- CreateTable +CREATE TABLE "VerificationToken" ( + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); diff --git a/packages/db/prisma/migrations/20240206000813_add_links/migration.sql b/packages/db/prisma/migrations/20240206000813_add_links/migration.sql new file mode 100644 index 00000000..38c8d938 --- /dev/null +++ b/packages/db/prisma/migrations/20240206000813_add_links/migration.sql @@ -0,0 +1,43 @@ +-- CreateTable +CREATE TABLE "BookmarkedLink" ( + "id" TEXT NOT NULL PRIMARY KEY, + "url" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" TEXT NOT NULL, + CONSTRAINT "BookmarkedLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "BookmarkedLinkDetails" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "imageUrl" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "BookmarkedLinkDetails_id_fkey" FOREIGN KEY ("id") REFERENCES "BookmarkedLink" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "BookmarkTags" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" TEXT NOT NULL, + CONSTRAINT "BookmarkTags_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "TagsOnLinks" ( + "linkId" TEXT NOT NULL, + "tagId" TEXT NOT NULL, + "attachedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "bookmarkTagsId" TEXT NOT NULL, + CONSTRAINT "TagsOnLinks_linkId_fkey" FOREIGN KEY ("linkId") REFERENCES "BookmarkedLink" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "TagsOnLinks_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "BookmarkTags" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "BookmarkTags_name_key" ON "BookmarkTags"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "TagsOnLinks_linkId_tagId_key" ON "TagsOnLinks"("linkId", "tagId"); diff --git a/packages/db/prisma/migrations/20240206192241_add_favicon/migration.sql b/packages/db/prisma/migrations/20240206192241_add_favicon/migration.sql new file mode 100644 index 00000000..330575e9 --- /dev/null +++ b/packages/db/prisma/migrations/20240206192241_add_favicon/migration.sql @@ -0,0 +1,16 @@ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_BookmarkedLinkDetails" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT, + "description" TEXT, + "imageUrl" TEXT, + "favicon" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "BookmarkedLinkDetails_id_fkey" FOREIGN KEY ("id") REFERENCES "BookmarkedLink" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_BookmarkedLinkDetails" ("createdAt", "description", "id", "imageUrl", "title") SELECT "createdAt", "description", "id", "imageUrl", "title" FROM "BookmarkedLinkDetails"; +DROP TABLE "BookmarkedLinkDetails"; +ALTER TABLE "new_BookmarkedLinkDetails" RENAME TO "BookmarkedLinkDetails"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/packages/db/prisma/migrations/20240207204211_drop_extra_field_in_tags_links/migration.sql b/packages/db/prisma/migrations/20240207204211_drop_extra_field_in_tags_links/migration.sql new file mode 100644 index 00000000..78184041 --- /dev/null +++ b/packages/db/prisma/migrations/20240207204211_drop_extra_field_in_tags_links/migration.sql @@ -0,0 +1,21 @@ +/* + Warnings: + + - You are about to drop the column `bookmarkTagsId` on the `TagsOnLinks` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_TagsOnLinks" ( + "linkId" TEXT NOT NULL, + "tagId" TEXT NOT NULL, + "attachedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "TagsOnLinks_linkId_fkey" FOREIGN KEY ("linkId") REFERENCES "BookmarkedLink" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "TagsOnLinks_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "BookmarkTags" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_TagsOnLinks" ("attachedAt", "linkId", "tagId") SELECT "attachedAt", "linkId", "tagId" FROM "TagsOnLinks"; +DROP TABLE "TagsOnLinks"; +ALTER TABLE "new_TagsOnLinks" RENAME TO "TagsOnLinks"; +CREATE UNIQUE INDEX "TagsOnLinks_linkId_tagId_key" ON "TagsOnLinks"("linkId", "tagId"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/packages/db/prisma/migrations/migration_lock.toml b/packages/db/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..e5e5c470 --- /dev/null +++ b/packages/db/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "sqlite" \ No newline at end of file diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma new file mode 100644 index 00000000..0e6d080c --- /dev/null +++ b/packages/db/prisma/schema.prisma @@ -0,0 +1,105 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model Account { + id String @id @default(cuid()) + userId String + type String + provider String + providerAccountId String + refresh_token String? + access_token String? + expires_at Int? + token_type String? + scope String? + id_token String? + session_state String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId String + expires DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model User { + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? + image String? + accounts Account[] + sessions Session[] + links BookmarkedLink[] + tags BookmarkTags[] +} + +model VerificationToken { + identifier String + token String @unique + expires DateTime + + @@unique([identifier, token]) +} + +model BookmarkedLink { + id String @id @default(cuid()) + url String + createdAt DateTime @default(now()) + + userId String + + details BookmarkedLinkDetails? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + tags TagsOnLinks[] +} + +model BookmarkedLinkDetails { + id String @id + title String? + description String? + imageUrl String? + favicon String? + createdAt DateTime @default(now()) + + link BookmarkedLink @relation(fields: [id], references: [id], onDelete: Cascade) +} + +model BookmarkTags { + id String @id @default(cuid()) + name String @unique + createdAt DateTime @default(now()) + + userId String + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + attachedLinks TagsOnLinks[] +} + +model TagsOnLinks { + link BookmarkedLink @relation(fields: [linkId], references: [id], onDelete: Cascade) + linkId String + + tag BookmarkTags @relation(fields: [tagId], references: [id], onDelete: Cascade) + tagId String + + attachedAt DateTime @default(now()) + + @@unique([linkId, tagId]) +} diff --git a/packages/shared/index.ts b/packages/shared/index.ts new file mode 100644 index 00000000..8b93520f --- /dev/null +++ b/packages/shared/index.ts @@ -0,0 +1 @@ +export * as Queues from "./queues.ts"; diff --git a/packages/shared/logger.ts b/packages/shared/logger.ts new file mode 100644 index 00000000..8cd2f808 --- /dev/null +++ b/packages/shared/logger.ts @@ -0,0 +1,15 @@ +import winston from "winston"; + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || "debug", + format: winston.format.combine( + winston.format.timestamp(), + winston.format.colorize(), + winston.format.printf( + (info) => `${info.timestamp} ${info.level}: ${info.message}`, + ), + ), + transports: [new winston.transports.Console()], +}); + +export default logger; diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 00000000..b75b3ac3 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@remember/shared", + "version": "0.1.0", + "private": true, + "dependencies": { + "winston": "^3.11.0" + }, + "main": "index.ts" +} diff --git a/packages/shared/queues.ts b/packages/shared/queues.ts new file mode 100644 index 00000000..a607131d --- /dev/null +++ b/packages/shared/queues.ts @@ -0,0 +1,29 @@ +import { Queue } from "bullmq"; +import { z } from "zod"; + +export const queueConnectionDetails = { + host: process.env.REDIS_HOST || "localhost", + port: parseInt(process.env.REDIS_PORT || "6379"), +}; + +// Link Crawler +export const zCrawlLinkRequestSchema = z.object({ + linkId: z.string(), + url: z.string().url(), +}); +export type ZCrawlLinkRequest = z.infer; + +export const LinkCrawlerQueue = new Queue( + "link_crawler_queue", + { connection: queueConnectionDetails }, +); + +// OpenAI Worker +export const zOpenAIRequestSchema = z.object({ + linkId: z.string(), +}); +export type ZOpenAIRequest = z.infer; + +export const OpenAIQueue = new Queue("openai_queue", { + connection: queueConnectionDetails, +}); diff --git a/packages/web/.env.sample b/packages/web/.env.sample new file mode 100644 index 00000000..a48054f0 --- /dev/null +++ b/packages/web/.env.sample @@ -0,0 +1,8 @@ +DATABASE_URL="file:./dev.db" +NEXTAUTH_URL= +NEXTAUTH_SECRET= + +# Oauth +AUTHENTIK_ID= +AUTHENTIK_SECRET= +AUTHENTIK_ISSUER= diff --git a/packages/web/Dockerfile b/packages/web/Dockerfile new file mode 100644 index 00000000..30a46bd3 --- /dev/null +++ b/packages/web/Dockerfile @@ -0,0 +1,57 @@ +FROM oven/bun:1.0-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +# RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json bun.lockb ./ +RUN bun install --frozen-lockfile + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NODE_ENV production + +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN bun run build + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV production + +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT 3000 +# set hostname to localhost +ENV HOSTNAME "0.0.0.0" + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/next-config-js/output +CMD ["node", "server.js"] diff --git a/packages/web/README.md b/packages/web/README.md new file mode 100644 index 00000000..c4033664 --- /dev/null +++ b/packages/web/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/packages/web/app/api/auth/[...nextauth]/route.tsx b/packages/web/app/api/auth/[...nextauth]/route.tsx new file mode 100644 index 00000000..e722926b --- /dev/null +++ b/packages/web/app/api/auth/[...nextauth]/route.tsx @@ -0,0 +1,3 @@ +import { authHandler } from "@/lib/auth"; + +export { authHandler as GET, authHandler as POST }; diff --git a/packages/web/app/api/v1/links/[linkId]/route.ts b/packages/web/app/api/v1/links/[linkId]/route.ts new file mode 100644 index 00000000..39449d6d --- /dev/null +++ b/packages/web/app/api/v1/links/[linkId]/route.ts @@ -0,0 +1,32 @@ +import { authOptions } from "@/lib/auth"; +import { unbookmarkLink } from "@/lib/services/links"; +import { Prisma } from "@remember/db"; + +import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; + +export async function DELETE( + _request: NextRequest, + { params }: { params: { linkId: string } }, +) { + // TODO: We probably should be using an API key here instead of the session; + const session = await getServerSession(authOptions); + if (!session) { + return new Response(null, { status: 401 }); + } + + try { + await unbookmarkLink(params.linkId, session.user.id); + } catch (e: unknown) { + if ( + e instanceof Prisma.PrismaClientKnownRequestError && + e.code === "P2025" // RecordNotFound + ) { + return new Response(null, { status: 404 }); + } else { + throw e; + } + } + + return new Response(null, { status: 201 }); +} diff --git a/packages/web/app/api/v1/links/route.ts b/packages/web/app/api/v1/links/route.ts new file mode 100644 index 00000000..87541634 --- /dev/null +++ b/packages/web/app/api/v1/links/route.ts @@ -0,0 +1,49 @@ +import { authOptions } from "@/lib/auth"; +import { bookmarkLink, getLinks } from "@/lib/services/links"; + +import { + zNewBookmarkedLinkRequestSchema, + ZGetLinksResponse, + ZBookmarkedLink, +} from "@/lib/types/api/links"; +import { getServerSession } from "next-auth"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(request: NextRequest) { + // TODO: We probably should be using an API key here instead of the session; + const session = await getServerSession(authOptions); + if (!session) { + return new Response(null, { status: 401 }); + } + + const linkRequest = zNewBookmarkedLinkRequestSchema.safeParse( + await request.json(), + ); + + if (!linkRequest.success) { + return NextResponse.json( + { + error: linkRequest.error.toString(), + }, + { status: 400 }, + ); + } + + const link = await bookmarkLink(linkRequest.data.url, session.user.id); + + let response: ZBookmarkedLink = { ...link }; + return NextResponse.json(response, { status: 201 }); +} + +export async function GET() { + // TODO: We probably should be using an API key here instead of the session; + const session = await getServerSession(authOptions); + if (!session) { + return new Response(null, { status: 401 }); + } + + const links = await getLinks(session.user.id); + + let response: ZGetLinksResponse = { links }; + return NextResponse.json(response); +} diff --git a/packages/web/app/dashboard/bookmarks/components/AddLink.tsx b/packages/web/app/dashboard/bookmarks/components/AddLink.tsx new file mode 100644 index 00000000..fb77786c --- /dev/null +++ b/packages/web/app/dashboard/bookmarks/components/AddLink.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import APIClient from "@/lib/api"; +import { Plus } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useForm, SubmitErrorHandler } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast } from "@/components/ui/use-toast"; + +const formSchema = z.object({ + url: z.string().url({ message: "The link must be a valid URL" }), +}); + +export default function AddLink() { + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + }); + + async function onSubmit(value: z.infer) { + const [_resp, error] = await APIClient.bookmarkLink(value.url); + if (error) { + toast({ description: error.message, variant: "destructive" }); + return; + } + router.refresh(); + } + + const onError: SubmitErrorHandler> = (errors) => { + toast({ + description: Object.values(errors) + .map((v) => v.message) + .join("\n"), + variant: "destructive", + }); + }; + + return ( +
+ +
+ { + return ( + + + + + + ); + }} + /> + +
+
+ + ); +} diff --git a/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx b/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx new file mode 100644 index 00000000..da59d9da --- /dev/null +++ b/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + ImageCard, + ImageCardBody, + ImageCardFooter, + ImageCardTitle, +} from "@/components/ui/imageCard"; +import { useToast } from "@/components/ui/use-toast"; +import APIClient from "@/lib/api"; +import { ZBookmarkedLink } from "@/lib/types/api/links"; +import { MoreHorizontal, Trash2 } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +export function LinkOptions({ linkId }: { linkId: string }) { + const { toast } = useToast(); + const router = useRouter(); + + const unbookmarkLink = async () => { + let [_, error] = await APIClient.unbookmarkLink(linkId); + + if (error) { + toast({ + variant: "destructive", + title: "Something went wrong", + description: "There was a problem with your request.", + }); + } else { + toast({ + description: "The link has been deleted!", + }); + } + + router.refresh(); + }; + return ( + + + + + + + + Delete + + + + ); +} + +export default function LinkCard({ link }: { link: ZBookmarkedLink }) { + const parsedUrl = new URL(link.url); + + return ( + + + + {link.details?.title ?? parsedUrl.host} + + + + {link.tags.map((t) => ( + + #{t.name} + + ))} + + +
+
+ + {parsedUrl.host} + +
+ +
+
+
+ ); +} diff --git a/packages/web/app/dashboard/bookmarks/components/LinksGrid.tsx b/packages/web/app/dashboard/bookmarks/components/LinksGrid.tsx new file mode 100644 index 00000000..66f0d766 --- /dev/null +++ b/packages/web/app/dashboard/bookmarks/components/LinksGrid.tsx @@ -0,0 +1,21 @@ +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; +import { authOptions } from "@/lib/auth"; +import { getLinks } from "@/lib/services/links"; +import LinkCard from "./LinkCard"; + +export default async function LinksGrid() { + const session = await getServerSession(authOptions); + if (!session) { + redirect("/"); + } + const links = await getLinks(session.user.id); + + return ( +
+ {links.map((l) => ( + + ))} +
+ ); +} diff --git a/packages/web/app/dashboard/bookmarks/page.tsx b/packages/web/app/dashboard/bookmarks/page.tsx new file mode 100644 index 00000000..b4158893 --- /dev/null +++ b/packages/web/app/dashboard/bookmarks/page.tsx @@ -0,0 +1,20 @@ +import AddLink from "./components/AddLink"; +import LinksGrid from "./components/LinksGrid"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Remember - Bookmarks", +}; + +export default async function Bookmarks() { + return ( +
+
+ +
+
+ +
+
+ ); +} diff --git a/packages/web/app/dashboard/components/Sidebar.tsx b/packages/web/app/dashboard/components/Sidebar.tsx new file mode 100644 index 00000000..0ed87daf --- /dev/null +++ b/packages/web/app/dashboard/components/Sidebar.tsx @@ -0,0 +1,56 @@ +import { Button } from "@/components/ui/button"; +import { authOptions } from "@/lib/auth"; +import { Archive, MoreHorizontal, Star, Tag, Home, Brain} from "lucide-react"; +import { getServerSession } from "next-auth"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +function SidebarItem({ + name, + logo, + path, +}: { + name: string; + logo: React.ReactNode; + path: string; +}) { + return ( +
  • + + {logo} + {name} + +
  • + ); +} + +export default async function Sidebar() { + const session = await getServerSession(authOptions); + if (!session) { + redirect("/"); + } + + return ( + + ); +} diff --git a/packages/web/app/dashboard/layout.tsx b/packages/web/app/dashboard/layout.tsx new file mode 100644 index 00000000..9b21271e --- /dev/null +++ b/packages/web/app/dashboard/layout.tsx @@ -0,0 +1,15 @@ +import Bookmarks from "@/app/dashboard/bookmarks/page"; +import Sidebar from "@/app/dashboard/components/Sidebar"; + +export default async function Dashboard() { + return ( +
    +
    + +
    +
    + +
    +
    + ); +} diff --git a/packages/web/app/favicon.ico b/packages/web/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/packages/web/app/favicon.ico differ diff --git a/packages/web/app/globals.css b/packages/web/app/globals.css new file mode 100644 index 00000000..8abdb15c --- /dev/null +++ b/packages/web/app/globals.css @@ -0,0 +1,76 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/packages/web/app/layout.tsx b/packages/web/app/layout.tsx new file mode 100644 index 00000000..a2d34046 --- /dev/null +++ b/packages/web/app/layout.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import React from "react"; +import { Toaster } from "@/components/ui/toaster"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Remember", + description: "Your AI powered second brain", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + + ); +} diff --git a/packages/web/app/page.tsx b/packages/web/app/page.tsx new file mode 100644 index 00000000..ffc128a5 --- /dev/null +++ b/packages/web/app/page.tsx @@ -0,0 +1,19 @@ +import { LoginButton } from "@/components/auth/login"; +import { LogoutButton } from "@/components/auth/logout"; +import Link from "next/link"; + +export default function Home() { + return ( +
    +
    + +
    +
    + +
    +
    + Bookmarks +
    +
    + ); +} diff --git a/packages/web/bun.lockb b/packages/web/bun.lockb new file mode 100755 index 00000000..7925e942 Binary files /dev/null and b/packages/web/bun.lockb differ diff --git a/packages/web/components.json b/packages/web/components.json new file mode 100644 index 00000000..fa674c93 --- /dev/null +++ b/packages/web/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/packages/web/components/auth/login.tsx b/packages/web/components/auth/login.tsx new file mode 100644 index 00000000..4cd55546 --- /dev/null +++ b/packages/web/components/auth/login.tsx @@ -0,0 +1,17 @@ +"use client"; +import { signIn } from "next-auth/react"; + +export const LoginButton = () => { + return ( + + ); +}; diff --git a/packages/web/components/auth/logout.tsx b/packages/web/components/auth/logout.tsx new file mode 100644 index 00000000..8d627f68 --- /dev/null +++ b/packages/web/components/auth/logout.tsx @@ -0,0 +1,17 @@ +"use client"; +import { signOut } from "next-auth/react"; + +export const LogoutButton = () => { + return ( + + ); +}; diff --git a/packages/web/components/ui/badge.tsx b/packages/web/components/ui/badge.tsx new file mode 100644 index 00000000..d3d5d604 --- /dev/null +++ b/packages/web/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "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", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
    + ); +} + +export { Badge, badgeVariants }; diff --git a/packages/web/components/ui/button.tsx b/packages/web/components/ui/button.tsx new file mode 100644 index 00000000..57c9fe47 --- /dev/null +++ b/packages/web/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "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", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/packages/web/components/ui/card.tsx b/packages/web/components/ui/card.tsx new file mode 100644 index 00000000..dc3b01de --- /dev/null +++ b/packages/web/components/ui/card.tsx @@ -0,0 +1,86 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

    +)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

    +)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

    +)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +CardFooter.displayName = "CardFooter"; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/packages/web/components/ui/dropdown-menu.tsx b/packages/web/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..3a0c7fed --- /dev/null +++ b/packages/web/components/ui/dropdown-menu.tsx @@ -0,0 +1,200 @@ +"use client"; + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/packages/web/components/ui/form.tsx b/packages/web/components/ui/form.tsx new file mode 100644 index 00000000..4603f8b3 --- /dev/null +++ b/packages/web/components/ui/form.tsx @@ -0,0 +1,176 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
    + + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +