diff --git a/.gitignore b/.gitignore
index 96fab4f..31f8f51 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,3 +36,5 @@ yarn-error.log*
# Misc
.DS_Store
*.pem
+
+**/landing-context.json
\ No newline at end of file
diff --git a/README.md b/README.md
index 240569f..71970ac 100644
--- a/README.md
+++ b/README.md
@@ -1,135 +1,102 @@
-# Turborepo starter
+# CoBrain
-This Turborepo starter is maintained by the Turborepo core team.
+> **CoBrain is your second brain for the internet. Save anything, organize it into slices, and recall it when you need it. Simple, flexible, and built for how you think.**
-## Using this example
+CoBrain is a modern full-stack web application designed to help users capture and organize information efficiently. It features a responsive dashboard, intuitive brain/memo management, and "slices" for categorizing thoughts.
-Run the following command:
+## 🏗 Repository Structure
-```sh
-npx create-turbo@latest
-```
+This is a monorepo managed by [Turborepo](https://turbo.build/repo), containing the following workspaces:
-## What's inside?
+### Apps
-This Turborepo includes the following packages/apps:
+- **`apps/landing`**: The frontend application built with [Next.js 15](https://nextjs.org/) (App Router), Tailwind CSS, and Framer Motion. Handles the landing page, authentication UI, and the user dashboard.
+- **`apps/server`**: The backend API server built with [Express.js](https://expressjs.com/). handles business logic, authentication (Google OAuth + JWT), and database communication.
-### Apps and Packages
+### Packages
-- `docs`: a [Next.js](https://nextjs.org/) app
-- `web`: another [Next.js](https://nextjs.org/) app
-- `@repo/ui`: a stub React component library shared by both `web` and `docs` applications
-- `@repo/eslint-config`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`)
-- `@repo/typescript-config`: `tsconfig.json`s used throughout the monorepo
+- **`packages/db`**: Database schema and ORM configuration using [Drizzle ORM](https://orm.drizzle.team/) and PostgreSQL.
+- **`@repo/ui`**: Shared UI component library.
+- **`@repo/types`**: Shared TypeScript type definitions (DTOs, interfaces).
+- **`@repo/eslint-config`**, **`@repo/typescript-config`**, **`@repo/tailwind-config`**: Shared configuration files.
-Each package/app is 100% [TypeScript](https://www.typescriptlang.org/).
+## 🚀 Getting Started
-### Utilities
+### Prerequisites
-This Turborepo has some additional tools already setup for you:
+- **[Bun](https://bun.sh/)** (v1.1+): This project uses Bun as the package manager and runtime for scripts.
+- **Node.js** (v18+): Required for some Turbo/Next.js operations.
+- **Docker**: (Optional but recommended) For running the PostgreSQL database locally.
-- [TypeScript](https://www.typescriptlang.org/) for static type checking
-- [ESLint](https://eslint.org/) for code linting
-- [Prettier](https://prettier.io) for code formatting
+### Installation
-### Build
+1. **Clone the repository:**
-To build all apps and packages, run the following command:
+ ```bash
+ git clone https://github.com/your-username/cobrain.git
+ cd cobrain
+ ```
-```
-cd my-turborepo
+2. **Install dependencies:**
+ ```bash
+ bun install
+ ```
-# With [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation) installed (recommended)
-turbo build
+### Environment Setup
-# Without [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation), use your package manager
-npx turbo build
-yarn dlx turbo build
-pnpm exec turbo build
-```
+You need to configure environment variables for the apps and database. Check the `README.md` in `apps/landing`, `apps/server`, and `packages/db` for specific details.
-You can build a specific package by using a [filter](https://turborepo.com/docs/crafting-your-repository/running-tasks#using-filters):
+Broadly, you will need to set up `.env` files in:
-```
-# With [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation) installed (recommended)
-turbo build --filter=docs
+- `apps/landing/.env`
+- `apps/server/.env`
+- `packages/db/.env`
-# Without [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation), use your package manager
-npx turbo build --filter=docs
-yarn exec turbo build --filter=docs
-pnpm exec turbo build --filter=docs
-```
+### Database Setup
-### Develop
+1. **Start PostgreSQL:**
+ Navigate to `packages/db` and start the database container:
-To develop all apps and packages, run the following command:
+ ```bash
+ cd packages/db
+ docker-compose up -d
+ ```
-```
-cd my-turborepo
+2. **Push Schema:**
+ Apply the database schema:
+ ```bash
+ bun run drizzle:push
+ ```
-# With [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation) installed (recommended)
-turbo dev
+### Running the Project
-# Without [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation), use your package manager
-npx turbo dev
-yarn exec turbo dev
-pnpm exec turbo dev
-```
-
-You can develop a specific package by using a [filter](https://turborepo.com/docs/crafting-your-repository/running-tasks#using-filters):
+To start both the frontend and backend in development mode:
+```bash
+bun run dev
```
-# With [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation) installed (recommended)
-turbo dev --filter=web
-
-# Without [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation), use your package manager
-npx turbo dev --filter=web
-yarn exec turbo dev --filter=web
-pnpm exec turbo dev --filter=web
-```
-
-### Remote Caching
-> [!TIP]
-> Vercel Remote Cache is free for all plans. Get started today at [vercel.com](https://vercel.com/signup?/signup?utm_source=remote-cache-sdk&utm_campaign=free_remote_cache).
+This uses Turborepo to run `dev` scripts in all apps simultaneously.
-Turborepo can use a technique known as [Remote Caching](https://turborepo.com/docs/core-concepts/remote-caching) to share cache artifacts across machines, enabling you to share build caches with your team and CI/CD pipelines.
+- **Frontend**: http://localhost:3000
+- **Backend**: http://localhost:3001 (or configured port)
-By default, Turborepo will cache locally. To enable Remote Caching you will need an account with Vercel. If you don't have an account you can [create one](https://vercel.com/signup?utm_source=turborepo-examples), then enter the following commands:
+## 🛠 Commands
-```
-cd my-turborepo
-
-# With [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation) installed (recommended)
-turbo login
-
-# Without [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation), use your package manager
-npx turbo login
-yarn exec turbo login
-pnpm exec turbo login
-```
-
-This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview).
-
-Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your Turborepo:
-
-```
-# With [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation) installed (recommended)
-turbo link
-
-# Without [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation), use your package manager
-npx turbo link
-yarn exec turbo link
-pnpm exec turbo link
-```
+Run these commands from the root directory:
-## Useful Links
+| Command | Description |
+|Str |---|
+| `bun run dev` | Start development server for all apps |
+| `bun run build` | Build all apps and packages |
+| `bun run lint` | shared linting check |
+| `bun run format` | Format code with Prettier |
+| `bun run check-types` | Run TypeScript type checking |
-Learn more about the power of Turborepo:
+## 🤝 Contribution
-- [Tasks](https://turborepo.com/docs/crafting-your-repository/running-tasks)
-- [Caching](https://turborepo.com/docs/crafting-your-repository/caching)
-- [Remote Caching](https://turborepo.com/docs/core-concepts/remote-caching)
-- [Filtering](https://turborepo.com/docs/crafting-your-repository/running-tasks#using-filters)
-- [Configuration Options](https://turborepo.com/docs/reference/configuration)
-- [CLI Usage](https://turborepo.com/docs/reference/command-line-reference)
+1. Fork the repo
+2. Create your feature branch (`git checkout -b feature/amazing-feature`)
+3. Commit your changes (`git commit -m 'Add some amazing feature'`)
+4. Push to the branch (`git push origin feature/amazing-feature`)
+5. Open a Pull Request
diff --git a/apps/landing/README.md b/apps/landing/README.md
index a98bfa8..3e89d52 100644
--- a/apps/landing/README.md
+++ b/apps/landing/README.md
@@ -1,36 +1,78 @@
-This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app).
+# CoBrain Frontend (Landing & Dashboard)
-## Getting Started
+This directory contains the user interface for CoBrain, built with **Next.js 15 (App Router)** and **Tailwind CSS**.
-First, run the development server:
+## ✨ Features
+
+- **Landing Page**: Modern, responsive landing page explaining the product value.
+- **Authentication**: Seamless sign-in/sign-up integration with Google OAuth (handled via backend).
+- **Dashboard**:
+ - View and manage your "Brains" (Memos).
+ - Organize content into "Slices".
+ - Responsive sidebar and navigation.
+- **Modern UI**: Polished look with custom fonts, Framer Motion animations, and specific design tokens (Glassmorphism, gradients).
+
+## 🛠 Tech Stack
+
+- **Framework**: [Next.js 15](https://nextjs.org/)
+- **Styling**: [Tailwind CSS v4](https://tailwindcss.com/)
+- **Icons**: [React Icons](https://react-icons.github.io/react-icons/) (Lucide, Tabler)
+- **Animations**: [Framer Motion](https://www.framer.com/motion/)
+- **HTTP Client**: [Axios](https://axios-http.com/)
+
+## 🚀 Getting Started
+
+### 1. Environment Variables
+
+Create a `.env` file in `apps/landing` based on `.env_example`:
```bash
-npm run dev
-# or
-yarn dev
-# or
-pnpm dev
-# or
-bun dev
+cp .env_example .env
```
-Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+**Required Variables:**
-You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+```env
+NEXT_PUBLIC_API_URL=http://localhost:3001 # URL of the running server app
+```
-This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load Inter, a custom Google Font.
+### 2. Run Development Server
-## Learn More
+You can run the frontend in isolation:
-To learn more about Next.js, take a look at the following resources:
+```bash
+bun dev
+```
-- [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.
+Open [http://localhost:3000](http://localhost:3000) with your browser.
-You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+## 📂 Project Structure
+
+```
+apps/landing/
+├── app/ # Next.js App Router pages
+│ ├── (auth)/ # Authentication routes (sign-in, sign-up)
+│ ├── dashboard/ # Main app interface (protected)
+│ ├── api/ # Local API routes (if any)
+│ ├── globals.css # Global styles and Tailwind directives
+│ ├── layout.tsx # Root layout
+│ └── page.tsx # Landing page
+├── components/ # React components
+│ ├── dashboard/ # Dashboard-specific components (Sidebar, Nav)
+│ ├── landing/ # Landing page sections (Hero, Features, Footer)
+│ └── ui/ # Reusable UI elements (Buttons, Cards, Inputs)
+├── hooks/ # Custom React hooks
+├── lib/ # Utility libraries (Axios setup, Utils)
+├── services/ # API service layers (Auth, Slice, etc.)
+└── public/ # Static assets
+```
-## Deploy on Vercel
+## 🔐 Authentication Flow
-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.
+The frontend relies on cookie-based authentication managed by the backend.
-Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+1. User clicks "Sign in with Google".
+2. Redirects to backend OAuth endpoint.
+3. Backend sets `access_token` and `refresh_token` httpOnly cookies.
+4. Frontend Middleware protects `/dashboard` routes by checking for these tokens.
+5. `axios` interceptors handle 401 errors by attempting a silent token refresh via the `/token-refresh` route.
diff --git a/apps/landing/app/(auth)/signin/page.tsx b/apps/landing/app/(auth)/signin/page.tsx
index 64c92b7..4a510bf 100644
--- a/apps/landing/app/(auth)/signin/page.tsx
+++ b/apps/landing/app/(auth)/signin/page.tsx
@@ -1,59 +1,8 @@
"use client";
import React from "react";
-import { AuthCard } from "../../../components/auth/auth-card";
-import { Button, Input, Capsule } from "@repo/ui";
-import { FcGoogle } from "react-icons/fc";
+import { SignInView } from "../../../components/auth/signin-view";
export default function SignInPage() {
- return (
- }
- >
-
-
-
-
-
-
-
-
- Or continue with
-
-
-
-
-
- Login with Google
-
-
-
-
- );
+ return ;
}
diff --git a/apps/landing/app/(auth)/signup/page.tsx b/apps/landing/app/(auth)/signup/page.tsx
index ee4cdd0..bdae3fd 100644
--- a/apps/landing/app/(auth)/signup/page.tsx
+++ b/apps/landing/app/(auth)/signup/page.tsx
@@ -1,59 +1,8 @@
"use client";
import React from "react";
-import { AuthCard } from "../../../components/auth/auth-card";
-import { Button, Input, Capsule } from "@repo/ui";
-import { FcGoogle } from "react-icons/fc";
+import { SignUpView } from "../../../components/auth/signup-view";
export default function SignUpPage() {
- return (
- }
- >
-
-
-
-
-
-
-
-
- Or continue with
-
-
-
-
-
- Login with Google
-
-
-
-
- );
+ return ;
}
diff --git a/apps/landing/app/dashboard/[id]/page.tsx b/apps/landing/app/dashboard/[id]/page.tsx
new file mode 100644
index 0000000..d2408ba
--- /dev/null
+++ b/apps/landing/app/dashboard/[id]/page.tsx
@@ -0,0 +1,128 @@
+"use client";
+
+import React, { useState } from "react";
+import { useRouter } from "next/navigation";
+import { TbLoader } from "react-icons/tb";
+import { Button } from "@repo/ui";
+import type { Memo } from "@repo/types";
+import { UpdateSliceDialog } from "../../../components/dashboard/slice/update-slice-dialog";
+import { DeleteSliceDialog } from "../../../components/dashboard/slice/delete-slice-dialog";
+import { ShareSliceDialog } from "../../../components/dashboard/slice/share-slice-dialog";
+import { CreateBrainDialog } from "../../../components/dashboard/brain/create-brain-dialog";
+import { DeleteBrainDialog } from "../../../components/dashboard/brain/delete-brain-dialog";
+import { useSliceData } from "../../../hooks/use-slice-data";
+import { SliceHeader } from "../../../components/dashboard/slice/ui/slice-header";
+import { BrainGrid } from "../../../components/dashboard/slice/ui/brain-grid";
+
+export default function SliceDashboardPage() {
+ const router = useRouter();
+ const { slice, brains, loading, error, refresh } = useSliceData();
+
+ // Slice Dialog states
+ const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
+ const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+
+ // Brain Dialog States
+ const [isCreateBrainDialogOpen, setIsCreateBrainDialogOpen] = useState(false);
+ const [isDeleteBrainDialogOpen, setIsDeleteBrainDialogOpen] = useState(false);
+ const [selectedBrain, setSelectedBrain] = useState(null);
+
+ const handleEditBrain = (brain: Memo) => {
+ setSelectedBrain(brain);
+ setIsCreateBrainDialogOpen(true);
+ };
+
+ const handleDeleteBrain = (brain: Memo) => {
+ setSelectedBrain(brain);
+ setIsDeleteBrainDialogOpen(true);
+ };
+
+ const handleCloseCreateDialog = () => {
+ setIsCreateBrainDialogOpen(false);
+ setTimeout(() => setSelectedBrain(null), 300); // Clear after animation
+ };
+
+ const handleNewBrain = () => {
+ setSelectedBrain(null);
+ setIsCreateBrainDialogOpen(true);
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error || !slice) {
+ return (
+
+
+ {error || "Slice not found"}
+
+
router.push("/dashboard")}>
+ Go to Dashboard
+
+
+ );
+ }
+
+ return (
+
+
setIsUpdateDialogOpen(true)}
+ onShareSlice={() => setIsShareDialogOpen(true)}
+ onDeleteSlice={() => setIsDeleteDialogOpen(true)}
+ />
+
+
+
+
+
+ {/* Dialogs */}
+ setIsUpdateDialogOpen(false)}
+ onSuccess={refresh}
+ slice={slice}
+ />
+
+ setIsShareDialogOpen(false)}
+ slice={slice}
+ />
+
+ setIsDeleteDialogOpen(false)}
+ onSuccess={() => router.push("/dashboard")}
+ slice={slice}
+ />
+
+
+
+ setIsDeleteBrainDialogOpen(false)}
+ onSuccess={refresh}
+ memo={selectedBrain}
+ />
+
+ );
+}
diff --git a/apps/landing/app/dashboard/layout.tsx b/apps/landing/app/dashboard/layout.tsx
new file mode 100644
index 0000000..528655e
--- /dev/null
+++ b/apps/landing/app/dashboard/layout.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import React from "react";
+import {
+ DashboardLayout,
+ Sidebar,
+ SidebarHeader,
+ SidebarFooter,
+ SidebarSearchSlice,
+ // SidebarOptions,
+} from "../../components/dashboard/sidebar";
+import { RefreshProvider } from "../../components/dashboard/refresh-context";
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ const sidebar = (
+ }>
+
+
+ {/* */}
+
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/landing/app/dashboard/page.tsx b/apps/landing/app/dashboard/page.tsx
new file mode 100644
index 0000000..9738f9d
--- /dev/null
+++ b/apps/landing/app/dashboard/page.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import React from "react";
+import { DashboardView } from "../../components/dashboard/dashboard-view";
+
+export default function Dashboard() {
+ return ;
+}
diff --git a/apps/landing/app/fonts/GeistMonoVF.woff b/apps/landing/app/fonts/GeistMonoVF.woff
deleted file mode 100644
index f2ae185..0000000
Binary files a/apps/landing/app/fonts/GeistMonoVF.woff and /dev/null differ
diff --git a/apps/landing/app/fonts/GeistVF.woff b/apps/landing/app/fonts/GeistVF.woff
deleted file mode 100644
index 1b62daa..0000000
Binary files a/apps/landing/app/fonts/GeistVF.woff and /dev/null differ
diff --git a/apps/landing/app/layout.tsx b/apps/landing/app/layout.tsx
index fbc7747..0059876 100644
--- a/apps/landing/app/layout.tsx
+++ b/apps/landing/app/layout.tsx
@@ -1,17 +1,7 @@
import type { Metadata } from "next";
-import localFont from "next/font/local";
import "./globals.css";
import { ThemeProvider } from "../components/theme/theme-provider";
-const geistSans = localFont({
- src: "./fonts/GeistVF.woff",
- variable: "--font-geist-sans",
-});
-const geistMono = localFont({
- src: "./fonts/GeistMonoVF.woff",
- variable: "--font-geist-mono",
-});
-
export const metadata: Metadata = {
title: "CoBrain",
description: "CoBrain is a platform for sharing a slice of your mind.",
@@ -25,7 +15,6 @@ export default function RootLayout({
return (
diff --git a/apps/landing/app/token-refresh/page.tsx b/apps/landing/app/token-refresh/page.tsx
new file mode 100644
index 0000000..f188240
--- /dev/null
+++ b/apps/landing/app/token-refresh/page.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { useEffect, Suspense } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { refresh } from "../../services/auth.service";
+
+function TokenRefreshContent() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const from = searchParams.get("from") || "/dashboard";
+
+ useEffect(() => {
+ const performRefresh = async () => {
+ try {
+ await refresh();
+ // Refresh successful, retry the original navigation
+ router.replace(from);
+ } catch {
+ // Refresh failed, user must sign in again
+ router.replace("/signin");
+ }
+ };
+
+ performRefresh();
+ }, [router, from]);
+
+ return (
+
+
+
+
Refreshing session...
+
+
+ );
+}
+
+export default function TokenRefreshPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/landing/components/auth/auth-card.tsx b/apps/landing/components/auth/auth-card.tsx
index 02e39f7..2a6cf82 100644
--- a/apps/landing/components/auth/auth-card.tsx
+++ b/apps/landing/components/auth/auth-card.tsx
@@ -28,7 +28,7 @@ export function AuthCard({
alt="Auth Background"
width={1000}
height={1000}
- className="h-full w-full object-contain"
+ className="h-full w-full object-cover"
priority
/>
{imageOverlay && (
diff --git a/apps/landing/components/auth/signin-view.tsx b/apps/landing/components/auth/signin-view.tsx
new file mode 100644
index 0000000..7dba94d
--- /dev/null
+++ b/apps/landing/components/auth/signin-view.tsx
@@ -0,0 +1,117 @@
+"use client";
+
+import React, { useState } from "react";
+import { useRouter } from "next/navigation";
+import { AuthCard } from "./auth-card";
+import { Button, Input, Capsule } from "@repo/ui";
+import { FcGoogle } from "react-icons/fc";
+import { signin } from "../../services/auth.service";
+
+export function SignInView() {
+ const router = useRouter();
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Form State
+ const [formData, setFormData] = useState({
+ email: "",
+ password: "",
+ });
+
+ const handleChange = (e: React.ChangeEvent) => {
+ setFormData({ ...formData, [e.target.id]: e.target.value });
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ setError(null);
+
+ try {
+ await signin(formData);
+ router.push("/dashboard");
+ } catch (err: unknown) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const msg = (err as any)?.response?.data?.error || "Invalid credentials";
+ setError(msg);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleGoogleLogin = () => {
+ window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/auth/google`;
+ };
+
+ return (
+ }
+ >
+
+
+
+
+
+
+
+
+ Or continue with
+
+
+
+
+
+ Login with Google
+
+
+
+
+ );
+}
diff --git a/apps/landing/components/auth/signup-view.tsx b/apps/landing/components/auth/signup-view.tsx
new file mode 100644
index 0000000..5513602
--- /dev/null
+++ b/apps/landing/components/auth/signup-view.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import React, { useState } from "react";
+import { useRouter } from "next/navigation";
+import { AuthCard } from "./auth-card";
+import { Button, Input, Capsule } from "@repo/ui";
+import { FcGoogle } from "react-icons/fc";
+import { signup } from "../../services/auth.service";
+
+export function SignUpView() {
+ const router = useRouter();
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Form State
+ const [formData, setFormData] = useState({
+ name: "",
+ email: "",
+ password: "",
+ });
+
+ const handleChange = (e: React.ChangeEvent) => {
+ setFormData({ ...formData, [e.target.id]: e.target.value });
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ setError(null);
+
+ try {
+ await signup(formData);
+ router.push("/dashboard");
+ } catch (err: unknown) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const msg = (err as any)?.response?.data?.error || "Failed to sign up";
+ setError(msg);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleGoogleLogin = () => {
+ window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/auth/google`;
+ };
+
+ return (
+ }
+ >
+
+
+
+
+
+
+
+
+ Or continue with
+
+
+
+
+
+ Login with Google
+
+
+
+
+ );
+}
diff --git a/apps/landing/components/dashboard/brain/create-brain-dialog.tsx b/apps/landing/components/dashboard/brain/create-brain-dialog.tsx
new file mode 100644
index 0000000..3176f50
--- /dev/null
+++ b/apps/landing/components/dashboard/brain/create-brain-dialog.tsx
@@ -0,0 +1,548 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Button, Input, cn } from "@repo/ui";
+import { createMemo, updateMemo } from "../../../services/memo.service";
+import { TbLoader, TbX, TbPlus, TbTrash } from "react-icons/tb";
+import { motion, AnimatePresence } from "motion/react";
+import type { MemoType, Memo } from "@repo/types";
+import type { CreateMemoRequest } from "../../../services/memo.service";
+
+// --- Sub-components (Inlined) ---
+
+const MEMO_TYPES: { value: MemoType; label: string }[] = [
+ { value: "TEXT", label: "Text Note" },
+ { value: "TODO", label: "Todo List" },
+ { value: "LINK", label: "Bookmark / Link" },
+ { value: "QA", label: "Q&A" },
+ { value: "CODE", label: "Code Snippet" },
+];
+
+const BrainTypeSelector = ({
+ currentType,
+ onSelect,
+ disabled,
+}: {
+ currentType: MemoType;
+ onSelect: (type: MemoType) => void;
+ disabled?: boolean;
+}) => {
+ return (
+
+ {MEMO_TYPES.map((t) => (
+ onSelect(t.value)}
+ disabled={disabled}
+ className={cn(
+ "flex flex-col items-center justify-center py-3 px-1 rounded-lg border text-xs font-medium transition-all",
+ currentType === t.value
+ ? "bg-primary text-primary-foreground border-primary"
+ : "bg-background border-input hover:bg-muted text-muted-foreground",
+ disabled &&
+ currentType !== t.value &&
+ "opacity-50 cursor-not-allowed text-muted-foreground",
+ )}
+ >
+ {t.label}
+
+ ))}
+
+ );
+};
+
+interface TodoItem {
+ id: string;
+ text: string;
+ completed: boolean;
+}
+
+interface BrainFormContentProps {
+ type: "TEXT" | "LINK" | "QA" | "CODE" | "TODO";
+ // Text
+ textContent: string;
+ setTextContent: (value: string) => void;
+ // Link
+ linkUrl: string;
+ setLinkUrl: (value: string) => void;
+ linkNote: string;
+ setLinkNote: (value: string) => void;
+ // QA
+ qaQuestion: string;
+ setQaQuestion: (value: string) => void;
+ qaAnswer: string;
+ setQaAnswer: (value: string) => void;
+ // Code
+ codeLanguage: string;
+ setCodeLanguage: (value: string) => void;
+ codeSnippet: string;
+ setCodeSnippet: (value: string) => void;
+ codeNote: string;
+ setCodeNote: (value: string) => void;
+ // Todo
+ todoItems: TodoItem[];
+ setTodoItems: (items: TodoItem[]) => void;
+}
+
+const BrainFormContent = ({
+ type,
+ textContent,
+ setTextContent,
+ linkUrl,
+ setLinkUrl,
+ linkNote,
+ setLinkNote,
+ qaQuestion,
+ setQaQuestion,
+ qaAnswer,
+ setQaAnswer,
+ codeLanguage,
+ setCodeLanguage,
+ codeSnippet,
+ setCodeSnippet,
+ codeNote,
+ setCodeNote,
+ todoItems,
+ setTodoItems,
+}: BrainFormContentProps) => {
+ // Todo helpers
+ const addTodoItem = () => {
+ setTodoItems([
+ ...todoItems,
+ { id: crypto.randomUUID(), text: "", completed: false },
+ ]);
+ };
+
+ const removeTodoItem = (id: string) => {
+ setTodoItems(todoItems.filter((item) => item.id !== id));
+ };
+
+ const updateTodoItem = (id: string, text: string) => {
+ setTodoItems(
+ todoItems.map((item) => (item.id === id ? { ...item, text } : item)),
+ );
+ };
+
+ return (
+
+
+ Content
+
+
+ {type === "TEXT" && (
+
+ );
+};
+
+interface CreateBrainDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: () => void;
+ sliceId: string;
+ initialData?: Memo | null;
+}
+
+export function CreateBrainDialog({
+ isOpen,
+ onClose,
+ onSuccess,
+ sliceId,
+ initialData,
+}: CreateBrainDialogProps) {
+ const [type, setType] = useState("TEXT");
+ const [title, setTitle] = useState("");
+ const [pinned, setPinned] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Content states
+ const [textContent, setTextContent] = useState("");
+ const [linkUrl, setLinkUrl] = useState("");
+ const [linkNote, setLinkNote] = useState("");
+ const [qaQuestion, setQaQuestion] = useState("");
+ const [qaAnswer, setQaAnswer] = useState("");
+ const [codeLanguage, setCodeLanguage] = useState("typescript");
+ const [codeSnippet, setCodeSnippet] = useState("");
+ const [codeNote, setCodeNote] = useState("");
+ const [todoItems, setTodoItems] = useState<
+ { id: string; text: string; completed: boolean }[]
+ >([{ id: crypto.randomUUID(), text: "", completed: false }]);
+
+ const resetForm = React.useCallback(() => {
+ if (!initialData) {
+ setType("TEXT");
+ setTitle("");
+ setPinned(false);
+ setTextContent("");
+ setLinkUrl("");
+ setLinkNote("");
+ setQaQuestion("");
+ setQaAnswer("");
+ setCodeLanguage("typescript");
+ setCodeSnippet("");
+ setCodeNote("");
+ setTodoItems([{ id: crypto.randomUUID(), text: "", completed: false }]);
+ }
+ setError(null);
+ }, [initialData]);
+
+ useEffect(() => {
+ if (initialData && isOpen) {
+ setType(initialData.type);
+ setTitle(initialData.title || "");
+ setPinned(initialData.pinned || false);
+
+ // Reset all content states first
+ setTextContent("");
+ setLinkUrl("");
+ setLinkNote("");
+ setQaQuestion("");
+ setQaAnswer("");
+ setCodeLanguage("typescript");
+ setCodeSnippet("");
+ setCodeNote("");
+ setTodoItems([{ id: crypto.randomUUID(), text: "", completed: false }]);
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const content = initialData.content as any;
+ switch (initialData.type) {
+ case "TEXT":
+ setTextContent(content.text || "");
+ break;
+ case "LINK":
+ setLinkUrl(content.url || "");
+ setLinkNote(content.note || "");
+ break;
+ case "QA":
+ setQaQuestion(content.question || "");
+ setQaAnswer(content.answer || "");
+ break;
+ case "CODE":
+ setCodeLanguage(content.language || "typescript");
+ setCodeSnippet(content.code || "");
+ setCodeNote(content.note || "");
+ break;
+ case "TODO":
+ setTodoItems(
+ content.items || [
+ { id: crypto.randomUUID(), text: "", completed: false },
+ ],
+ );
+ break;
+ }
+ } else if (!isOpen) {
+ resetForm();
+ }
+ }, [initialData, isOpen, resetForm]);
+
+ const handleClose = () => {
+ resetForm();
+ onClose();
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ setError(null);
+
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let content: any = {};
+
+ switch (type) {
+ case "TEXT":
+ content = { text: textContent };
+ break;
+ case "LINK":
+ content = { url: linkUrl, note: linkNote, source: "manual" };
+ break;
+ case "QA":
+ content = { question: qaQuestion, answer: qaAnswer };
+ break;
+ case "CODE":
+ content = {
+ language: codeLanguage,
+ code: codeSnippet,
+ note: codeNote,
+ };
+ break;
+ case "TODO":
+ content = {
+ items: todoItems.filter((item) => item.text.trim().length > 0),
+ // Preserve other properties like completed status if needed, but for creation/update usually we just send items
+ };
+ if (content.items.length === 0) {
+ throw new Error("Add at least one todo item");
+ }
+ break;
+ }
+
+ const payload: CreateMemoRequest = {
+ type,
+ title: title || undefined,
+ content,
+ sliceId,
+ pinned,
+ };
+
+ if (initialData) {
+ await updateMemo(initialData.id, payload);
+ } else {
+ await createMemo(payload);
+ }
+ onSuccess();
+ handleClose();
+ } catch (err: unknown) {
+ console.error("Failed to save brain", err);
+ const errorMessage =
+ err instanceof Error
+ ? err.message
+ : `Failed to ${initialData ? "update" : "create"} brain. Please try again.`;
+ setError(errorMessage);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+ {isOpen && (
+ <>
+ {/* Backdrop */}
+
+ {/* Dialog */}
+
+
+ {/* Header */}
+
+
+ {initialData ? "Edit Brain" : "Create New Brain"}
+
+
+
+
+
+
+ {/* Scrollable Content */}
+
+
+ {/* Footer */}
+
+
+ Cancel
+
+
+ {loading ? : null}
+ {initialData ? "Save Changes" : "Create Brain"}
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/landing/components/dashboard/brain/delete-brain-dialog.tsx b/apps/landing/components/dashboard/brain/delete-brain-dialog.tsx
new file mode 100644
index 0000000..1050003
--- /dev/null
+++ b/apps/landing/components/dashboard/brain/delete-brain-dialog.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import React, { useState } from "react";
+import { Button } from "@repo/ui";
+import { deleteMemo } from "../../../services/memo.service";
+import { TbLoader, TbAlertTriangle } from "react-icons/tb";
+import { motion, AnimatePresence } from "motion/react";
+import type { Memo } from "@repo/types";
+
+interface DeleteBrainDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: () => void;
+ memo: Memo | null;
+}
+
+export function DeleteBrainDialog({
+ isOpen,
+ onClose,
+ onSuccess,
+ memo,
+}: DeleteBrainDialogProps) {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleDelete = async () => {
+ if (!memo) return;
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ await deleteMemo(memo.id);
+ onSuccess();
+ onClose();
+ } catch (err: unknown) {
+ console.error("Failed to delete memo", err);
+ const errorMessage =
+ err instanceof Error
+ ? err.message
+ : "Failed to delete memo. Please try again.";
+ setError(errorMessage);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (!isOpen || !memo) return null;
+
+ return (
+
+ {isOpen && (
+ <>
+ {/* Backdrop */}
+
+ {/* Dialog */}
+
+
+
+
+
+
+
+
+
+ Delete Brain?
+
+
+
+ Are you sure you want to delete{" "}
+
+ "{memo.title || "this brain"}"
+
+ ? This action cannot be undone.
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ Cancel
+
+
+ {loading ? (
+
+ ) : null}
+ Delete
+
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/landing/components/dashboard/brain/ui/brain-form-content.tsx b/apps/landing/components/dashboard/brain/ui/brain-form-content.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/apps/landing/components/dashboard/brain/ui/brain-type-selector.tsx b/apps/landing/components/dashboard/brain/ui/brain-type-selector.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/apps/landing/components/dashboard/dashboard-view.tsx b/apps/landing/components/dashboard/dashboard-view.tsx
new file mode 100644
index 0000000..4dedbce
--- /dev/null
+++ b/apps/landing/components/dashboard/dashboard-view.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import React, { useState } from "react";
+import { SidebarTrigger } from "./sidebar";
+import ThemeToggler from "../theme/theme-toggler";
+import { CreateSliceDialog } from "./slice/create-slice-dialog";
+import { Button } from "@repo/ui";
+import { TbPlus, TbSection } from "react-icons/tb";
+
+export function DashboardView() {
+ const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Welcome to CoBrain
+
+
+ Organize your thoughts, projects, and ideas into focused
+ "Slices". Each slice focuses on a specific context,
+ allowing you to manage related notes, links, and tasks efficiently.
+
+
+
+
setIsCreateDialogOpen(true)}
+ >
+
+ Create New Slice
+
+
+
+
setIsCreateDialogOpen(false)}
+ onSuccess={() => {
+ // We might want to refresh the sidebar list here, but currently that might happen automatically
+ // if the sidebar data fetching relies on something that updates.
+ // For now, we can perhaps just close. The sidebar component itself usually handles its own fetching.
+ // Ideally, we might trigger a global refetch or router refresh.
+ window.location.reload(); // Simple way to ensure sidebar updates for now
+ }}
+ />
+
+ );
+}
diff --git a/apps/landing/components/dashboard/refresh-context.tsx b/apps/landing/components/dashboard/refresh-context.tsx
new file mode 100644
index 0000000..01f6bc1
--- /dev/null
+++ b/apps/landing/components/dashboard/refresh-context.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import React, { createContext, useContext, useState, useCallback } from "react";
+
+interface RefreshContextType {
+ refreshKey: number;
+ triggerRefresh: () => void;
+}
+
+const RefreshContext = createContext(undefined);
+
+export function RefreshProvider({ children }: { children: React.ReactNode }) {
+ const [refreshKey, setRefreshKey] = useState(0);
+
+ const triggerRefresh = useCallback(() => {
+ setRefreshKey((prev) => prev + 1);
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useRefresh() {
+ const context = useContext(RefreshContext);
+ if (context === undefined) {
+ throw new Error("useRefresh must be used within a RefreshProvider");
+ }
+ return context;
+}
diff --git a/apps/landing/components/dashboard/sidebar/dashboard-layout.tsx b/apps/landing/components/dashboard/sidebar/dashboard-layout.tsx
new file mode 100644
index 0000000..071fab0
--- /dev/null
+++ b/apps/landing/components/dashboard/sidebar/dashboard-layout.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import React from "react";
+import { SidebarProvider } from "./sidebar-context";
+
+interface DashboardLayoutProps {
+ children: React.ReactNode;
+ sidebar: React.ReactNode;
+}
+
+export function DashboardLayout({ children, sidebar }: DashboardLayoutProps) {
+ return (
+
+
+ {sidebar}
+
+ {children}
+
+
+
+ );
+}
diff --git a/apps/landing/components/dashboard/sidebar/index.ts b/apps/landing/components/dashboard/sidebar/index.ts
new file mode 100644
index 0000000..8af7716
--- /dev/null
+++ b/apps/landing/components/dashboard/sidebar/index.ts
@@ -0,0 +1,8 @@
+export * from "./sidebar-context";
+export * from "./sidebar-item";
+export * from "./sidebar";
+export * from "./dashboard-layout";
+export * from "./sidebar-trigger";
+export * from "./sidebar-header";
+export * from "./sidebar-footer";
+export * from "./sidebar-search-slice";
diff --git a/apps/landing/components/dashboard/sidebar/sidebar-context.tsx b/apps/landing/components/dashboard/sidebar/sidebar-context.tsx
new file mode 100644
index 0000000..2c7c6cc
--- /dev/null
+++ b/apps/landing/components/dashboard/sidebar/sidebar-context.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import React, { createContext, useContext, useEffect, useState } from "react";
+
+interface SidebarContextType {
+ isCollapsed: boolean;
+ isMobileOpen: boolean;
+ toggleSidebar: () => void;
+ toggleMobileSidebar: () => void;
+ closeMobileSidebar: () => void;
+}
+
+const SidebarContext = createContext(undefined);
+
+const SIDEBAR_COLLAPSED_KEY = "sidebar-collapsed";
+
+export function SidebarProvider({ children }: { children: React.ReactNode }) {
+ const [isCollapsed, setIsCollapsed] = useState(false);
+ const [isMobileOpen, setIsMobileOpen] = useState(false);
+
+ useEffect(() => {
+ const saved = localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
+ if (saved !== null) {
+ setIsCollapsed(JSON.parse(saved));
+ }
+ }, []);
+
+ useEffect(() => {
+ const handleResize = () => {
+ if (window.innerWidth >= 768) {
+ setIsMobileOpen(false);
+ }
+ };
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, []);
+
+ const toggleSidebar = () => {
+ const newState = !isCollapsed;
+ setIsCollapsed(newState);
+ localStorage.setItem(SIDEBAR_COLLAPSED_KEY, JSON.stringify(newState));
+ };
+
+ const toggleMobileSidebar = () => {
+ setIsMobileOpen(!isMobileOpen);
+ };
+
+ const closeMobileSidebar = () => {
+ setIsMobileOpen(false);
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useSidebar() {
+ const context = useContext(SidebarContext);
+ if (context === undefined) {
+ throw new Error("useSidebar must be used within a SidebarProvider");
+ }
+ return context;
+}
diff --git a/apps/landing/components/dashboard/sidebar/sidebar-footer.tsx b/apps/landing/components/dashboard/sidebar/sidebar-footer.tsx
new file mode 100644
index 0000000..1755aa6
--- /dev/null
+++ b/apps/landing/components/dashboard/sidebar/sidebar-footer.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { signout } from "../../../services/auth.service";
+import { SidebarItem } from "./sidebar-item";
+import { MdLogout } from "react-icons/md";
+import ThemeToggler from "../../theme/theme-toggler";
+
+export function SidebarFooter() {
+ const handleLogout = async () => {
+ await signout();
+ window.location.href = "/";
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/landing/components/dashboard/sidebar/sidebar-header.tsx b/apps/landing/components/dashboard/sidebar/sidebar-header.tsx
new file mode 100644
index 0000000..6c7ad9a
--- /dev/null
+++ b/apps/landing/components/dashboard/sidebar/sidebar-header.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { useSidebar } from "./sidebar-context";
+import { cn } from "@repo/ui";
+import Image from "next/image";
+import Link from "next/link";
+
+export function SidebarHeader() {
+ const { isCollapsed } = useSidebar();
+
+ return (
+
+ {!isCollapsed && (
+
+
+ CoBrain
+
+ )}
+
+ );
+}
diff --git a/apps/landing/components/dashboard/sidebar/sidebar-item.tsx b/apps/landing/components/dashboard/sidebar/sidebar-item.tsx
new file mode 100644
index 0000000..ac9ae71
--- /dev/null
+++ b/apps/landing/components/dashboard/sidebar/sidebar-item.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import Link from "next/link";
+import type { IconType } from "react-icons";
+import { motion, AnimatePresence } from "motion/react";
+import { useSidebar } from "./sidebar-context";
+import { cn } from "@repo/ui";
+
+interface SidebarItemProps {
+ icon: IconType;
+ label: string;
+ href?: string;
+ isActive?: boolean;
+ onClick?: () => void;
+}
+
+export function SidebarItem({
+ icon: Icon,
+ label,
+ href,
+ isActive,
+ onClick,
+}: SidebarItemProps) {
+ const { isCollapsed } = useSidebar();
+
+ const content = (
+ <>
+
+
+ {!isCollapsed && (
+
+ {label}
+
+ )}
+
+ >
+ );
+
+ const parentClasses = cn(
+ "flex items-center px-4 py-3 cursor-pointer transition-colors duration-200 rounded-lg mx-2 my-1",
+ isActive
+ ? "bg-primary/10 text-primary hover:bg-primary/20"
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
+ isCollapsed ? "justify-center px-0" : "justify-start",
+ );
+
+ if (href) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+}
diff --git a/apps/landing/components/dashboard/sidebar/sidebar-options.tsx b/apps/landing/components/dashboard/sidebar/sidebar-options.tsx
new file mode 100644
index 0000000..9d92d56
--- /dev/null
+++ b/apps/landing/components/dashboard/sidebar/sidebar-options.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { SidebarItem } from "./sidebar-item";
+import { MdDashboard, MdSettings, MdPerson, MdFolder } from "react-icons/md";
+
+export function SidebarOptions() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/landing/components/dashboard/sidebar/sidebar-search-slice.tsx b/apps/landing/components/dashboard/sidebar/sidebar-search-slice.tsx
new file mode 100644
index 0000000..80a77e5
--- /dev/null
+++ b/apps/landing/components/dashboard/sidebar/sidebar-search-slice.tsx
@@ -0,0 +1,287 @@
+"use client";
+
+import React, { useState, useEffect, useRef } from "react";
+import { useSidebar } from "./sidebar-context";
+import { useRefresh } from "../refresh-context";
+import { Button, cn } from "@repo/ui";
+import { ShareSliceDialog } from "../slice/share-slice-dialog";
+
+import {
+ TbSearch,
+ TbPlus,
+ TbLoader,
+ TbDotsVertical,
+ TbTrash,
+ TbPencil,
+ TbShare,
+} from "react-icons/tb";
+import { motion, AnimatePresence } from "motion/react";
+import Link from "next/link";
+import { getSlices } from "../../../services/slice.service";
+import type { Slice } from "@repo/types";
+import { CreateSliceDialog } from "../slice/create-slice-dialog";
+import { UpdateSliceDialog } from "../slice/update-slice-dialog";
+import { DeleteSliceDialog } from "../slice/delete-slice-dialog";
+import { SearchSliceDialog } from "../slice/search-slice-dialog";
+
+import { useParams, useRouter } from "next/navigation";
+
+export function SidebarSearchSlice() {
+ const { isCollapsed } = useSidebar();
+ const { refreshKey, triggerRefresh } = useRefresh();
+ const [slices, setSlices] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
+ const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
+ const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
+ const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
+ const [selectedSlice, setSelectedSlice] = useState(null);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [sliceToDelete, setSliceToDelete] = useState(null);
+ const [activeMenuId, setActiveMenuId] = useState(null);
+ const menuRef = useRef(null);
+
+ const params = useParams();
+ const router = useRouter();
+
+ // Helper to decode ID from slug if needed, but we check against raw ID usually.
+ // Actually params.id is base64, need to compare carefully.
+ const currentSliceId = params.id
+ ? (() => {
+ try {
+ return atob(params.id as string);
+ } catch {
+ return null;
+ }
+ })()
+ : null;
+
+ const fetchSlices = async () => {
+ setLoading(true);
+ try {
+ const response = await getSlices();
+ setSlices(response.slices);
+ } catch (error) {
+ console.error("Failed to fetch slices", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchSlices();
+ }, [refreshKey]);
+
+ // Close menu when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+ setActiveMenuId(null);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, []);
+
+ const handleDeleteClick = (slice: Slice, event: React.MouseEvent) => {
+ event.preventDefault(); // Prevent navigation
+ event.stopPropagation();
+ setSliceToDelete(slice);
+ setIsDeleteDialogOpen(true);
+ setActiveMenuId(null);
+ };
+
+ const handleDeleteSuccess = () => {
+ if (sliceToDelete && currentSliceId === sliceToDelete.id) {
+ router.push("/dashboard");
+ }
+ triggerRefresh();
+ };
+
+ const handleEdit = (slice: Slice, event: React.MouseEvent) => {
+ event.preventDefault(); // Prevent navigation
+ event.stopPropagation();
+ setSelectedSlice(slice);
+ setIsUpdateDialogOpen(true);
+ setActiveMenuId(null);
+ };
+
+ const handleShare = (slice: Slice, event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setSelectedSlice(slice);
+ setIsShareDialogOpen(true);
+ setActiveMenuId(null);
+ };
+
+ const toggleMenu = (sliceId: string, event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setActiveMenuId(activeMenuId === sliceId ? null : sliceId);
+ };
+
+ return (
+
+
setIsCreateDialogOpen(false)}
+ onSuccess={triggerRefresh}
+ />
+
+ {
+ setIsUpdateDialogOpen(false);
+ setSelectedSlice(null);
+ }}
+ onSuccess={triggerRefresh}
+ slice={selectedSlice}
+ />
+
+ {
+ setIsShareDialogOpen(false);
+ setSelectedSlice(null);
+ }}
+ slice={selectedSlice}
+ />
+
+ {
+ setIsDeleteDialogOpen(false);
+ setSliceToDelete(null);
+ }}
+ onSuccess={handleDeleteSuccess}
+ slice={sliceToDelete}
+ />
+
+ setIsSearchDialogOpen(false)}
+ />
+
+ {/* Search Button */}
+ {!isCollapsed && (
+ setIsSearchDialogOpen(true)}
+ >
+
+ Search slices
+
+ )}
+
+ {/* Search Icon for collapsed state - expands sidebar when clicked */}
+ {isCollapsed && (
+ setIsSearchDialogOpen(true)}
+ >
+
+
+ )}
+
+ {/* New Slice Button */}
+ setIsCreateDialogOpen(true)}
+ >
+
+ {!isCollapsed && New Slice }
+
+
+ {/* Recent Slices */}
+ {!isCollapsed && (
+
+
+ All Slices
+
+ {loading ? (
+
+
+
+ ) : (
+ slices.map((slice) => (
+
+
+
{slice.name}
+
+ {/* Action Menu Trigger */}
+
toggleMenu(slice.id, e)}
+ >
+
+
+
+
+ {/* Dropdown Menu */}
+
+ {activeMenuId === slice.id && (
+
+ handleEdit(slice, e)}
+ className="flex items-center gap-2 w-full px-3 py-2 text-xs hover:bg-muted text-left"
+ >
+ Edit
+
+ handleShare(slice, e)}
+ className="flex items-center gap-2 w-full px-3 py-2 text-xs hover:bg-muted text-left"
+ >
+ Share
+
+ handleDeleteClick(slice, e)}
+ className="flex items-center gap-2 w-full px-3 py-2 text-xs hover:bg-muted text-left text-danger"
+ >
+ Delete
+
+
+ )}
+
+
+ ))
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/landing/components/dashboard/sidebar/sidebar-trigger.tsx b/apps/landing/components/dashboard/sidebar/sidebar-trigger.tsx
new file mode 100644
index 0000000..3b38c89
--- /dev/null
+++ b/apps/landing/components/dashboard/sidebar/sidebar-trigger.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { FiSidebar } from "react-icons/fi";
+import { useSidebar } from "./sidebar-context";
+import { IconButton } from "@repo/ui";
+
+export function SidebarTrigger() {
+ const { toggleSidebar, toggleMobileSidebar } = useSidebar();
+
+ const handleClick = () => {
+ // Simple check for desktop vs mobile action
+ if (window.innerWidth >= 768) {
+ toggleSidebar();
+ } else {
+ toggleMobileSidebar();
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/landing/components/dashboard/sidebar/sidebar.tsx b/apps/landing/components/dashboard/sidebar/sidebar.tsx
new file mode 100644
index 0000000..8943826
--- /dev/null
+++ b/apps/landing/components/dashboard/sidebar/sidebar.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { useSidebar } from "./sidebar-context";
+import { motion, AnimatePresence } from "motion/react";
+import { cn } from "@repo/ui";
+import { MdClose } from "react-icons/md"; // Example icons, user asked for react-icons
+import Image from "next/image";
+import React from "react";
+import Link from "next/link";
+
+export function Sidebar({
+ children,
+ footer,
+}: {
+ children: React.ReactNode;
+ footer?: React.ReactNode;
+}) {
+ const { isCollapsed, isMobileOpen, closeMobileSidebar } = useSidebar();
+
+ return (
+ <>
+ {/* Desktop Sidebar */}
+
+ {children}
+ {footer && {footer}
}
+
+
+ {/* Mobile Sidebar Overlay */}
+
+ {isMobileOpen && (
+ <>
+ {/* Backdrop */}
+
+ {/* Sidebar Panel */}
+
+
+
+
+ CoBrain
+
+
+
+
+
+ {children}
+ {footer && {footer}
}
+
+ >
+ )}
+
+ >
+ );
+}
diff --git a/apps/landing/components/dashboard/slice/create-slice-dialog.tsx b/apps/landing/components/dashboard/slice/create-slice-dialog.tsx
new file mode 100644
index 0000000..8e8f7d6
--- /dev/null
+++ b/apps/landing/components/dashboard/slice/create-slice-dialog.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import React, { useState } from "react";
+import { Button, Input } from "@repo/ui";
+import { createSlice } from "../../../services/slice.service";
+import { TbLoader } from "react-icons/tb";
+import { motion, AnimatePresence } from "motion/react";
+
+// Inline Dialog for simplicity if @repo/ui doesn't have it, or reusing what we can
+// Given I don't see Dialog in @repo/ui export, I'll build a simple overlay modal
+// or check if I missed it.
+// I will build a simple custom modal for now to avoid dependency hell if not present.
+
+interface CreateSliceDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: () => void;
+}
+
+export function CreateSliceDialog({
+ isOpen,
+ onClose,
+ onSuccess,
+}: CreateSliceDialogProps) {
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ setError(null);
+
+ try {
+ await createSlice({ name, description });
+ setName("");
+ setDescription("");
+ onSuccess();
+ onClose();
+ } catch (err) {
+ console.error("Failed to create slice", err);
+ setError("Failed to create slice. Please try again.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+ {isOpen && (
+ <>
+ {/* Backdrop */}
+
+ {/* Dialog */}
+
+
+
Create New Slice
+
+ setName(e.target.value)}
+ placeholder="e.g. Marketing Q1"
+ required
+ className="bg-background"
+ />
+
+
+ Description
+
+ setDescription(e.target.value)}
+ placeholder="Optional description..."
+ className="flex min-h-20 w-full rounded-2xl border-[1.5px] border-input bg-transparent px-4 py-2 text-body-medium shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
+ />
+
+
+ {error && {error}
}
+
+
+
+ Cancel
+
+
+ {loading ? : "Create"}
+
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/landing/components/dashboard/slice/delete-slice-dialog.tsx b/apps/landing/components/dashboard/slice/delete-slice-dialog.tsx
new file mode 100644
index 0000000..7fd4b69
--- /dev/null
+++ b/apps/landing/components/dashboard/slice/delete-slice-dialog.tsx
@@ -0,0 +1,135 @@
+"use client";
+
+import React, { useState } from "react";
+import { Button, Input } from "@repo/ui";
+import { deleteSlice } from "../../../services/slice.service";
+import { TbLoader, TbAlertTriangle } from "react-icons/tb";
+import { motion, AnimatePresence } from "motion/react";
+import type { Slice } from "@repo/types";
+
+interface DeleteSliceDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: () => void; // This will trigger refetch and navigation if needed
+ slice: Slice | null;
+}
+
+export function DeleteSliceDialog({
+ isOpen,
+ onClose,
+ onSuccess,
+ slice,
+}: DeleteSliceDialogProps) {
+ const [confirmation, setConfirmation] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleDelete = async () => {
+ if (!slice) return;
+
+ if (confirmation !== slice.name) {
+ setError("Slice name does not match.");
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ await deleteSlice(slice.id);
+ setConfirmation("");
+ onSuccess();
+ onClose();
+ } catch (err) {
+ console.error("Failed to delete slice", err);
+ setError("Failed to delete slice. Please try again.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (!isOpen || !slice) return null;
+
+ return (
+
+ {isOpen && (
+ <>
+ {/* Backdrop */}
+
+ {/* Dialog */}
+
+
+
+
+
Delete Slice
+
+
+
+ Cannot be undone. This will permanently delete the slice{" "}
+
+ "{slice.name}"
+ {" "}
+ and all associated brains.
+
+
+
+
+
+ Type "{slice.name}" to confirm
+
+ {
+ setConfirmation(e.target.value);
+ if (error) setError(null);
+ }}
+ placeholder={slice.name}
+ className="bg-background"
+ />
+
+
+ {error && (
+
{error}
+ )}
+
+
+
+ Cancel
+
+
+ {loading ? : "Delete"}
+
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/landing/components/dashboard/slice/index.ts b/apps/landing/components/dashboard/slice/index.ts
new file mode 100644
index 0000000..112d231
--- /dev/null
+++ b/apps/landing/components/dashboard/slice/index.ts
@@ -0,0 +1,4 @@
+export * from "./create-slice-dialog";
+export * from "./update-slice-dialog";
+export * from "./delete-slice-dialog";
+export * from "./share-slice-dialog";
diff --git a/apps/landing/components/dashboard/slice/search-slice-dialog.tsx b/apps/landing/components/dashboard/slice/search-slice-dialog.tsx
new file mode 100644
index 0000000..d575eb7
--- /dev/null
+++ b/apps/landing/components/dashboard/slice/search-slice-dialog.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { cn } from "@repo/ui";
+import { getSlices } from "../../../services/slice.service";
+import { TbSearch } from "react-icons/tb";
+import { motion, AnimatePresence } from "motion/react";
+import { useDebounce } from "../../../hooks/use-debounce";
+import type { Slice } from "@repo/types";
+import Link from "next/link";
+
+interface SearchSliceDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export function SearchSliceDialog({ isOpen, onClose }: SearchSliceDialogProps) {
+ const [searchQuery, setSearchQuery] = useState("");
+ const debouncedQuery = useDebounce(searchQuery, 300);
+ const [slices, setSlices] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const fetchSlices = async (query?: string) => {
+ setLoading(true);
+ try {
+ const response = await getSlices(query);
+ setSlices(response.slices);
+ } catch (error) {
+ console.error("Failed to fetch slices", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (isOpen) {
+ fetchSlices(debouncedQuery);
+ }
+ }, [isOpen, debouncedQuery]);
+
+ // Reset when closed
+ useEffect(() => {
+ if (!isOpen) {
+ setSearchQuery("");
+ setSlices([]);
+ }
+ }, [isOpen]);
+
+ if (!isOpen) return null;
+
+ return (
+
+ {isOpen && (
+ <>
+ {/* Backdrop */}
+
+ {/* Dialog */}
+
+
+
+
+
setSearchQuery(e.target.value)}
+ />
+
+ ESC
+
+
+
+
+ {loading ? (
+ // Skeleton Loader
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : slices.length > 0 ? (
+ slices.map((slice) => (
+
+
+
+ {slice.name}
+
+ {slice.description && (
+
+ {slice.description}
+
+ )}
+
+
+ Go
+
+
+ ))
+ ) : (
+
+
+ No slices found
+
+ )}
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/landing/components/dashboard/slice/share-slice-dialog.tsx b/apps/landing/components/dashboard/slice/share-slice-dialog.tsx
new file mode 100644
index 0000000..745d694
--- /dev/null
+++ b/apps/landing/components/dashboard/slice/share-slice-dialog.tsx
@@ -0,0 +1,377 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Button, Input, cn } from "@repo/ui";
+import {
+ updateSliceAccessStatus,
+ grantSliceAccess,
+ revokeSliceAccess,
+ getSliceAccessList,
+ updateSliceAccessRole,
+ type SliceAccessUser,
+} from "../../../services/slice.service";
+import { TbLoader, TbTrash, TbUser } from "react-icons/tb";
+import { motion, AnimatePresence } from "motion/react";
+import type { Slice } from "@repo/types";
+
+interface ShareSliceDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ slice: Slice | null;
+}
+
+export function ShareSliceDialog({
+ isOpen,
+ onClose,
+ slice,
+}: ShareSliceDialogProps) {
+ // State for Access Status
+ const [accessStatus, setAccessStatus] = useState<
+ "private" | "public" | "specific"
+ >("private");
+ const [updatingStatus, setUpdatingStatus] = useState(false);
+
+ // State for Invite
+ const [inviteEmail, setInviteEmail] = useState("");
+ const [inviteRole, setInviteRole] = useState<"viewer" | "editor">("viewer");
+ const [inviting, setInviting] = useState(false);
+
+ // State for User List
+ const [accessList, setAccessList] = useState([]);
+ const [loadingList, setLoadingList] = useState(false);
+ const [revokingUser, setRevokingUser] = useState(null);
+
+ const [error, setError] = useState(null);
+
+ // Initialize values when dialog opens
+ useEffect(() => {
+ if (isOpen && slice) {
+ setAccessStatus(slice.accessStatus as "private" | "public" | "specific");
+ setError(null);
+ if (slice.accessStatus === "specific") {
+ fetchAccessList(slice.id);
+ }
+ }
+ }, [isOpen, slice]);
+
+ const fetchAccessList = async (sliceId: string) => {
+ setLoadingList(true);
+ try {
+ const res = await getSliceAccessList(sliceId);
+ setAccessList(res.accessList || []);
+ } catch (err) {
+ console.error("Failed to fetch access list", err);
+ } finally {
+ setLoadingList(false);
+ }
+ };
+
+ const handleStatusChange = async (
+ status: "private" | "public" | "specific",
+ ) => {
+ if (!slice) return;
+ setUpdatingStatus(true);
+ try {
+ await updateSliceAccessStatus(slice.id, { accessStatus: status });
+ setAccessStatus(status);
+ if (status === "specific") {
+ fetchAccessList(slice.id);
+ }
+ } catch (err) {
+ console.error("Failed to update status", err);
+ setError("Failed to update access status");
+ } finally {
+ setUpdatingStatus(false);
+ }
+ };
+
+ const handleInvite = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!slice || !inviteEmail) return;
+
+ setInviting(true);
+ setError(null);
+ try {
+ await grantSliceAccess(slice.id, {
+ email: inviteEmail,
+ role: inviteRole,
+ });
+ setInviteEmail("");
+ // Refresh list
+ fetchAccessList(slice.id);
+ } catch (err: unknown) {
+ console.error("Failed to invite user", err);
+ // Try to extract backend error message if available
+ const msg =
+ (err as { response?: { data?: { message?: string } } })?.response?.data
+ ?.message || "Failed to invite user";
+ setError(msg);
+ } finally {
+ setInviting(false);
+ }
+ };
+
+ const handleRevoke = async (userId: string) => {
+ if (!slice) return;
+ setRevokingUser(userId);
+ try {
+ await revokeSliceAccess(slice.id, userId);
+ setAccessList((prev) => prev.filter((u) => u.userId !== userId));
+ } catch (err) {
+ console.error("Failed to revoke access", err);
+ setError("Failed to revoke access");
+ } finally {
+ setRevokingUser(null);
+ }
+ };
+
+ const handleRoleChange = async (
+ userId: string,
+ newRole: "viewer" | "editor",
+ ) => {
+ if (!slice) return;
+ // Optimistic update
+ setAccessList((prev) =>
+ prev.map((u) => (u.userId === userId ? { ...u, role: newRole } : u)),
+ );
+
+ try {
+ await updateSliceAccessRole(slice.id, userId, { role: newRole });
+ } catch (err) {
+ console.error("Failed to update role", err);
+ setError("Failed to update role");
+ // Revert on failure involves refetching, for simplicity
+ fetchAccessList(slice.id);
+ }
+ };
+
+ if (!isOpen || !slice) return null;
+
+ return (
+
+ {isOpen && (
+ <>
+
+
+
+
+
+ Share "{slice.name}"
+
+
+ Manage who can see and edit this slice.
+
+
+
+ {/* Access Status Header */}
+
+ {(["private", "public"] as const).map((status) => (
+ handleStatusChange(status)}
+ disabled={updatingStatus}
+ className={cn(
+ "flex-1 py-1.5 text-sm font-medium rounded-md transition-all capitalize",
+ accessStatus === status
+ ? "bg-background shadow-sm text-foreground"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {status}
+
+ ))}
+
+
+ {/* Specific Access - Invite & List */}
+ {accessStatus === "specific" && (
+
+ {/* Invite Form */}
+
+
+ setInviteEmail(e.target.value)}
+ required
+ containerClassName="mb-0"
+ className="bg-background"
+ />
+
+
+ Role
+
+ setInviteRole(e.target.value as "viewer" | "editor")
+ }
+ className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
+ >
+ Viewer
+ Editor
+
+
+
+ {inviting ? (
+
+ ) : (
+ "Invite"
+ )}
+
+
+
+ {/* Access List */}
+
+
+ People with access
+
+
+ {loadingList ? (
+
+
+
+ ) : (accessList?.length || 0) === 0 ? (
+
+ No one else has access yet.
+
+ ) : (
+
+ {accessList.map((access) => (
+
+
+
+ {access.user.avatarUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ )}
+
+
+
+ {access.user.fullName || access.user.email}
+
+
+ {access.user.email}
+
+
+
+
+
+ handleRoleChange(
+ access.userId,
+ e.target.value as "viewer" | "editor",
+ )
+ }
+ className="h-8 rounded-md border border-input bg-transparent px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
+ >
+ Viewer
+ Editor
+
+ handleRevoke(access.userId)}
+ disabled={revokingUser === access.userId}
+ className="p-1.5 text-muted-foreground hover:text-red-500 transition-colors rounded-md hover:bg-red-50"
+ title="Revoke Access"
+ >
+ {revokingUser === access.userId ? (
+
+ ) : (
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+
+ )}
+
+ {accessStatus === "public" && (
+
+
+
Public Access
+
+ Anyone with the link can view this slice. Only you can
+ edit it.
+
+
+
+
+
+ Share Link
+
+
+
+ {
+ const url = `${window.location.origin}/dashboard/${btoa(slice.id)}`;
+ navigator.clipboard.writeText(url);
+ // Optional: Add a toast or visual feedback here if we had a toast system ready
+ // For now, reliance on button state or just action is fine, or I can add a temporary text change
+ }}
+ className="whitespace-nowrap"
+ >
+ Copy Link
+
+
+
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ Done
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/landing/components/dashboard/slice/ui/brain-card.tsx b/apps/landing/components/dashboard/slice/ui/brain-card.tsx
new file mode 100644
index 0000000..d1e9e32
--- /dev/null
+++ b/apps/landing/components/dashboard/slice/ui/brain-card.tsx
@@ -0,0 +1,185 @@
+"use client";
+
+import React, { useState } from "react";
+import {
+ TbBrain,
+ TbDotsVertical,
+ TbPencil,
+ TbTrash,
+ TbCopy,
+ TbCheck,
+} from "react-icons/tb";
+import { motion, AnimatePresence } from "motion/react";
+import type { Memo } from "@repo/types";
+import Link from "next/link";
+
+interface BrainCardProps {
+ brain: Memo;
+ onEdit: (brain: Memo) => void;
+ onDelete: (brain: Memo) => void;
+ activeMenuId: string | null;
+ onMenuToggle: (id: string | null) => void;
+}
+
+export const BrainCard = ({
+ brain,
+ onEdit,
+ onDelete,
+ activeMenuId,
+ onMenuToggle,
+}: BrainCardProps) => {
+ const isMenuOpen = activeMenuId === brain.id;
+ const [isCopied, setIsCopied] = useState(false);
+
+ const handleCopy = (text: string) => {
+ navigator.clipboard.writeText(text);
+ setIsCopied(true);
+ setTimeout(() => setIsCopied(false), 2000);
+ };
+
+ return (
+ isMenuOpen && onMenuToggle(null)}
+ >
+
+
+
+
+
+
+
+ {brain.type}
+
+
+
{
+ e.stopPropagation();
+ onMenuToggle(isMenuOpen ? null : brain.id);
+ }}
+ className="p-1 hover:bg-muted rounded-full text-muted-foreground"
+ >
+
+
+
+ {isMenuOpen && (
+
+ {
+ e.stopPropagation();
+ onEdit(brain);
+ }}
+ className="flex items-center gap-2 w-full px-3 py-2 text-xs hover:bg-muted text-left"
+ >
+ Edit
+
+ {
+ e.stopPropagation();
+ onDelete(brain);
+ }}
+ className="flex items-center gap-2 w-full px-3 py-2 text-xs hover:bg-muted text-left text-danger"
+ >
+ Delete
+
+
+ )}
+
+
+
+
+
+
+ {brain.title || "Untitled Brain"}
+
+
+ {/* Preview Content */}
+
{
+ // Prevent editing when clicking the copy button or iframe
+ if (
+ (e.target as HTMLElement).closest("button") ||
+ (e.target as HTMLElement).closest("iframe")
+ ) {
+ e.stopPropagation();
+ return;
+ }
+ onEdit(brain);
+ }}
+ >
+ {brain.type === "TEXT" && brain.content.text}
+ {brain.type === "LINK" && (
+
+
+ Note: {brain.content.note}
+
+ e.stopPropagation()}
+ >
+ {brain.content.url}
+
+
+ )}
+ {brain.type === "TODO" && (
+
+ {brain.content.items.slice(0, 3).map((item) => (
+
+ {item.text}
+
+ ))}
+ {brain.content.items.length > 3 && ... }
+
+ )}
+ {brain.type === "QA" && (
+
+ Q: {brain.content.question}
+
+ )}
+ {brain.type === "CODE" && (
+
+
+ {brain.content.code}
+
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ handleCopy(brain.content.code);
+ }}
+ className="absolute top-2 right-2 p-1.5 bg-background/80 hover:bg-background rounded-md shadow-sm border opacity-0 group-hover:opacity-100 transition-opacity"
+ title="Copy code"
+ >
+ {isCopied ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+
+
+ created {new Date(brain.createdAt).toLocaleDateString()}
+ {brain.pinned && (
+
+ Pinned
+
+ )}
+
+
+ );
+};
diff --git a/apps/landing/components/dashboard/slice/ui/brain-grid.tsx b/apps/landing/components/dashboard/slice/ui/brain-grid.tsx
new file mode 100644
index 0000000..e71fff2
--- /dev/null
+++ b/apps/landing/components/dashboard/slice/ui/brain-grid.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import React, { useState } from "react";
+import { Button } from "@repo/ui";
+import { TbBrain, TbPlus } from "react-icons/tb";
+import type { Memo } from "@repo/types";
+import { BrainCard } from "./brain-card";
+
+interface BrainGridProps {
+ brains: Memo[];
+ onNewBrain: () => void;
+ onEditBrain: (brain: Memo) => void;
+ onDeleteBrain: (brain: Memo) => void;
+}
+
+export const BrainGrid = ({
+ brains,
+ onNewBrain,
+ onEditBrain,
+ onDeleteBrain,
+}: BrainGridProps) => {
+ const [openBrainMenuId, setOpenBrainMenuId] = useState(null);
+
+ if (brains.length === 0) {
+ return (
+
+
+
+
+
+
No brains yet
+
+ This slice is empty. Create your first brain (memo) to start
+ organizing your thoughts.
+
+
+
+
+ Create New Brain
+
+
+ );
+ }
+
+ return (
+
+ {brains.map((brain) => (
+
+ ))}
+
+ );
+};
diff --git a/apps/landing/components/dashboard/slice/ui/slice-header.tsx b/apps/landing/components/dashboard/slice/ui/slice-header.tsx
new file mode 100644
index 0000000..89c4369
--- /dev/null
+++ b/apps/landing/components/dashboard/slice/ui/slice-header.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+import React, { useState } from "react";
+import { Button, IconButton } from "@repo/ui";
+import {
+ TbPlus,
+ TbDotsVertical,
+ TbPencil,
+ TbShare,
+ TbTrash,
+} from "react-icons/tb";
+import { motion, AnimatePresence } from "motion/react";
+import { SidebarTrigger } from "../../sidebar/sidebar-trigger";
+import type { Slice } from "@repo/types";
+import ThemeToggler from "../../../theme/theme-toggler";
+
+interface SliceHeaderProps {
+ slice: Slice;
+ onNewBrain: () => void;
+ onEditSlice: () => void;
+ onShareSlice: () => void;
+ onDeleteSlice: () => void;
+}
+
+export const SliceHeader = ({
+ slice,
+ onNewBrain,
+ onEditSlice,
+ onShareSlice,
+ onDeleteSlice,
+}: SliceHeaderProps) => {
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+
+ return (
+
+ );
+};
diff --git a/apps/landing/components/dashboard/slice/update-slice-dialog.tsx b/apps/landing/components/dashboard/slice/update-slice-dialog.tsx
new file mode 100644
index 0000000..e3367b9
--- /dev/null
+++ b/apps/landing/components/dashboard/slice/update-slice-dialog.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Button, Input } from "@repo/ui";
+import { updateSlice } from "../../../services/slice.service";
+import { TbLoader } from "react-icons/tb";
+import { motion, AnimatePresence } from "motion/react";
+import type { Slice } from "@repo/types";
+
+interface UpdateSliceDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: () => void;
+ slice: Slice | null;
+}
+
+export function UpdateSliceDialog({
+ isOpen,
+ onClose,
+ onSuccess,
+ slice,
+}: UpdateSliceDialogProps) {
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (slice) {
+ setName(slice.name);
+ setDescription(slice.description || "");
+ }
+ }, [slice]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!slice) return;
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ await updateSlice(slice.id, { name, description });
+ onSuccess();
+ onClose();
+ } catch (err) {
+ console.error("Failed to update slice", err);
+ setError("Failed to update slice. Please try again.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (!isOpen || !slice) return null;
+
+ return (
+
+ {isOpen && (
+ <>
+ {/* Backdrop */}
+
+ {/* Dialog */}
+
+
+
Edit Slice
+
+ setName(e.target.value)}
+ placeholder="e.g. Marketing Q1"
+ required
+ className="bg-background"
+ />
+
+
+ Description
+
+ setDescription(e.target.value)}
+ placeholder="Optional description..."
+ className="flex min-h-20 w-full rounded-2xl border-[1.5px] border-input bg-background px-4 py-2 text-body-medium shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
+ />
+
+
+ {error && {error}
}
+
+
+
+ Cancel
+
+
+ {loading ? : "Save"}
+
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/landing/components/landing/faq/faq.tsx b/apps/landing/components/landing/faq/faq.tsx
index 3915697..da872f7 100644
--- a/apps/landing/components/landing/faq/faq.tsx
+++ b/apps/landing/components/landing/faq/faq.tsx
@@ -1,6 +1,7 @@
-import { Capsule } from "@repo/ui";
-import { Accordion } from "./accordion";
import content from "../../../content/landing.json";
+import { Accordion } from "./ui/accordion";
+import { FAQBackground } from "./ui/faq-background";
+import { SectionHeader } from "./ui/section-header";
export const FAQ = () => {
return (
@@ -8,19 +9,14 @@ export const FAQ = () => {
id="faq"
className="py-24 px-6 sm:py-32 lg:px-8 bg-background relative overflow-hidden"
>
- {/* Subtle background decoration */}
-
+
-
-
-
- {content.faq.title}
-
-
- {content.faq.description}
-
-
+
diff --git a/apps/landing/components/landing/faq/accordion.tsx b/apps/landing/components/landing/faq/ui/accordion.tsx
similarity index 100%
rename from apps/landing/components/landing/faq/accordion.tsx
rename to apps/landing/components/landing/faq/ui/accordion.tsx
diff --git a/apps/landing/components/landing/faq/ui/faq-background.tsx b/apps/landing/components/landing/faq/ui/faq-background.tsx
new file mode 100644
index 0000000..8142422
--- /dev/null
+++ b/apps/landing/components/landing/faq/ui/faq-background.tsx
@@ -0,0 +1,8 @@
+export const FAQBackground = () => {
+ return (
+ <>
+ {/* Subtle background decoration */}
+
+ >
+ );
+};
diff --git a/apps/landing/components/landing/faq/ui/section-header.tsx b/apps/landing/components/landing/faq/ui/section-header.tsx
new file mode 100644
index 0000000..69eb9d3
--- /dev/null
+++ b/apps/landing/components/landing/faq/ui/section-header.tsx
@@ -0,0 +1,28 @@
+import { Capsule } from "@repo/ui";
+import { cn } from "@repo/ui";
+
+interface SectionHeaderProps {
+ capsuleText: string;
+ title: string;
+ description: string;
+ className?: string;
+}
+
+export const SectionHeader = ({
+ capsuleText,
+ title,
+ description,
+ className,
+}: SectionHeaderProps) => {
+ return (
+
+
+
{title}
+
+ {description}
+
+
+ );
+};
diff --git a/apps/landing/components/landing/features/features.tsx b/apps/landing/components/landing/features/features.tsx
index 70665dc..2e6d963 100644
--- a/apps/landing/components/landing/features/features.tsx
+++ b/apps/landing/components/landing/features/features.tsx
@@ -1,47 +1,33 @@
-import { Capsule } from "@repo/ui";
-import { cn } from "@repo/ui";
import content from "../../../content/landing.json";
+import { FeatureCard } from "./ui/feature-card";
+import { SectionHeader } from "./ui/section-header";
export const Features = () => {
return (
-
-
-
-
-
- {content.features.main_title}
-
-
- {content.features.description}
-
-
+
{content.features.list.map((feature, idx) => (
-
-
-
- {feature.name}
-
-
- {feature.description}
-
-
-
+ : "lg:col-span-2"
+ }
+ />
))}
diff --git a/apps/landing/components/landing/features/ui/feature-card.tsx b/apps/landing/components/landing/features/ui/feature-card.tsx
new file mode 100644
index 0000000..c5bea83
--- /dev/null
+++ b/apps/landing/components/landing/features/ui/feature-card.tsx
@@ -0,0 +1,31 @@
+import { cn } from "@repo/ui";
+
+interface FeatureCardProps {
+ name: string;
+ description: string;
+ className?: string;
+}
+
+export const FeatureCard = ({
+ name,
+ description,
+ className,
+}: FeatureCardProps) => {
+ return (
+
+
+
+ {name}
+
+
+ {description}
+
+
+
+ );
+};
diff --git a/apps/landing/components/landing/features/ui/section-header.tsx b/apps/landing/components/landing/features/ui/section-header.tsx
new file mode 100644
index 0000000..226f11f
--- /dev/null
+++ b/apps/landing/components/landing/features/ui/section-header.tsx
@@ -0,0 +1,25 @@
+import { Capsule } from "@repo/ui";
+
+interface SectionHeaderProps {
+ capsuleText: string;
+ title: string;
+ description: string;
+}
+
+export const SectionHeader = ({
+ capsuleText,
+ title,
+ description,
+}: SectionHeaderProps) => {
+ return (
+
+
+
+
+
{title}
+
+ {description}
+
+
+ );
+};
diff --git a/apps/landing/components/landing/footer/footer.tsx b/apps/landing/components/landing/footer/footer.tsx
index e746ae3..b3539ed 100644
--- a/apps/landing/components/landing/footer/footer.tsx
+++ b/apps/landing/components/landing/footer/footer.tsx
@@ -1,7 +1,9 @@
import Link from "next/link";
-import Image from "next/image";
import { Button } from "@repo/ui";
import content from "../../../content/landing.json";
+import { SocialLinks } from "./ui/social-links";
+import { FooterCopyright } from "./ui/footer-copyright";
+import { FooterLogo } from "./ui/footer-logo";
export const Footer = () => {
return (
@@ -10,22 +12,11 @@ export const Footer = () => {
{/* Logo & Info */}
-
-
-
- CoBrain
-
-
+
{content.footer.description}
-
+
Get Started
@@ -34,35 +25,14 @@ export const Footer = () => {
{/* Socials Column */}
-
+
{/* Bottom Bar */}
-
-
- {content.footer.all_rights_reserved} ©{" "}
- {new Date().getFullYear()} CoBrain
-
-
- {content.footer.copyright}
-
-
+
);
diff --git a/apps/landing/components/landing/footer/ui/footer-copyright.tsx b/apps/landing/components/landing/footer/ui/footer-copyright.tsx
new file mode 100644
index 0000000..58c74e8
--- /dev/null
+++ b/apps/landing/components/landing/footer/ui/footer-copyright.tsx
@@ -0,0 +1,20 @@
+interface FooterCopyrightProps {
+ allRightsReservedText: string;
+ copyrightText: string;
+}
+
+export const FooterCopyright = ({
+ allRightsReservedText,
+ copyrightText,
+}: FooterCopyrightProps) => {
+ return (
+
+
+ {allRightsReservedText} © {new Date().getFullYear()} CoBrain
+
+
+ {copyrightText}
+
+
+ );
+};
diff --git a/apps/landing/components/landing/footer/ui/footer-logo.tsx b/apps/landing/components/landing/footer/ui/footer-logo.tsx
new file mode 100644
index 0000000..7419db5
--- /dev/null
+++ b/apps/landing/components/landing/footer/ui/footer-logo.tsx
@@ -0,0 +1,18 @@
+import Image from "next/image";
+
+export const FooterLogo = () => {
+ return (
+
+
+
+ CoBrain
+
+
+ );
+};
diff --git a/apps/landing/components/landing/footer/ui/social-links.tsx b/apps/landing/components/landing/footer/ui/social-links.tsx
new file mode 100644
index 0000000..88c59ed
--- /dev/null
+++ b/apps/landing/components/landing/footer/ui/social-links.tsx
@@ -0,0 +1,30 @@
+interface SocialLink {
+ label: string;
+ href: string;
+}
+
+interface SocialLinksProps {
+ links: SocialLink[];
+}
+
+export const SocialLinks = ({ links }: SocialLinksProps) => {
+ return (
+
+ );
+};
diff --git a/apps/landing/components/landing/header/header.tsx b/apps/landing/components/landing/header/header.tsx
index ff3688d..1f0580c 100644
--- a/apps/landing/components/landing/header/header.tsx
+++ b/apps/landing/components/landing/header/header.tsx
@@ -2,13 +2,13 @@
import { useState } from "react";
import Link from "next/link";
-import Image from "next/image";
import { useScroll, useMotionValueEvent, motion } from "motion/react";
import { Button } from "@repo/ui";
import { Menu, X } from "lucide-react";
import ThemeToggler from "../../theme/theme-toggler";
-import { NavDesktop } from "./nav-desktop";
-import { NavMobile } from "./nav-mobile";
+import { NavDesktop } from "./ui/nav-desktop";
+import { NavMobile } from "./ui/nav-mobile";
+import { Logo } from "./ui/logo";
import { cn } from "@repo/ui";
const navItems = [
@@ -37,18 +37,7 @@ export const Header = () => {
>
{/* Logo */}
-
-
-
- CoBrain
-
-
+
{/* Desktop Nav */}
@@ -58,7 +47,7 @@ export const Header = () => {
{/* Right Side (Desktop) */}
-
+
Get Started
diff --git a/apps/landing/components/landing/header/nav-desktop.tsx b/apps/landing/components/landing/header/nav-desktop.tsx
deleted file mode 100644
index 31cbde7..0000000
--- a/apps/landing/components/landing/header/nav-desktop.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { cn } from "@repo/ui";
-
-interface NavItem {
- name: string;
- href: string;
-}
-
-interface NavDesktopProps {
- navItems: NavItem[];
-}
-
-export const NavDesktop = ({ navItems }: NavDesktopProps) => {
- const pathname = usePathname();
-
- return (
-
- {navItems.map((item) => (
-
- {item.name}
-
- ))}
-
- );
-};
diff --git a/apps/landing/components/landing/header/nav-mobile.tsx b/apps/landing/components/landing/header/nav-mobile.tsx
deleted file mode 100644
index f1eec1b..0000000
--- a/apps/landing/components/landing/header/nav-mobile.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { motion, AnimatePresence } from "motion/react";
-import Link from "next/link";
-import { Button } from "@repo/ui";
-import { cn } from "@repo/ui";
-
-interface NavItem {
- name: string;
- href: string;
-}
-
-interface NavMobileProps {
- navItems: NavItem[];
- isOpen: boolean;
- onClose: () => void;
-}
-
-export const NavMobile = ({ navItems, isOpen, onClose }: NavMobileProps) => {
- return (
-
- {isOpen && (
-
-
- {navItems.map((item) => (
-
- {item.name}
-
- ))}
-
-
-
- Get Started
-
-
-
-
-
- )}
-
- );
-};
diff --git a/apps/landing/components/landing/header/ui/logo.tsx b/apps/landing/components/landing/header/ui/logo.tsx
new file mode 100644
index 0000000..3884d26
--- /dev/null
+++ b/apps/landing/components/landing/header/ui/logo.tsx
@@ -0,0 +1,19 @@
+import Link from "next/link";
+import Image from "next/image";
+
+export const Logo = () => {
+ return (
+
+
+
+ CoBrain
+
+
+ );
+};
diff --git a/apps/landing/components/landing/header/ui/nav-desktop.tsx b/apps/landing/components/landing/header/ui/nav-desktop.tsx
new file mode 100644
index 0000000..e655194
--- /dev/null
+++ b/apps/landing/components/landing/header/ui/nav-desktop.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import Link from "next/link";
+
+interface NavItem {
+ name: string;
+ href: string;
+}
+
+interface NavDesktopProps {
+ navItems: NavItem[];
+}
+
+export const NavDesktop = ({ navItems }: NavDesktopProps) => {
+ return (
+
+ {navItems.map((item) => (
+
+ {item.name}
+
+ ))}
+
+ );
+};
diff --git a/apps/landing/components/landing/header/ui/nav-mobile.tsx b/apps/landing/components/landing/header/ui/nav-mobile.tsx
new file mode 100644
index 0000000..3e6dc38
--- /dev/null
+++ b/apps/landing/components/landing/header/ui/nav-mobile.tsx
@@ -0,0 +1,88 @@
+import { useRef, useEffect } from "react";
+import Link from "next/link";
+import { motion, AnimatePresence } from "motion/react";
+import { X } from "lucide-react";
+
+interface NavItem {
+ name: string;
+ href: string;
+}
+
+interface NavMobileProps {
+ navItems: NavItem[];
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export const NavMobile = ({ navItems, isOpen, onClose }: NavMobileProps) => {
+ const menuRef = useRef
(null);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+ onClose();
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener("mousedown", handleClickOutside);
+ }
+
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, [isOpen, onClose]);
+
+ return (
+
+ {isOpen && (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Menu */}
+
+
+
+
+
+
+
+
+ {navItems.map((item) => (
+
+ {item.name}
+
+ ))}
+
+
+ >
+ )}
+
+ );
+};
diff --git a/apps/landing/components/landing/home/home.tsx b/apps/landing/components/landing/home/home.tsx
index 7b434fc..2a88b0b 100644
--- a/apps/landing/components/landing/home/home.tsx
+++ b/apps/landing/components/landing/home/home.tsx
@@ -1,6 +1,5 @@
-import { Button } from "@repo/ui";
-import Link from "next/link";
-import content from "../../../content/landing.json";
+import { HomeBackground } from "./ui/home-background";
+import { HomeHero } from "./ui/home-hero";
export const Home = () => {
return (
@@ -8,46 +7,8 @@ export const Home = () => {
id="home"
className="relative flex min-h-screen flex-col items-center justify-center py-24 text-center px-4 overflow-hidden"
>
-
- {/* Linear Gradient */}
-
-
-
-
-
-
- {content.home.title}
-
-
- {content.home.description}
-
-
-
-
- {content.home.buttons.primary}
-
-
-
-
- {content.home.buttons.secondary} →
-
-
-
-
+
+
);
};
diff --git a/apps/landing/components/landing/home/ui/home-background.tsx b/apps/landing/components/landing/home/ui/home-background.tsx
new file mode 100644
index 0000000..099e4f8
--- /dev/null
+++ b/apps/landing/components/landing/home/ui/home-background.tsx
@@ -0,0 +1,23 @@
+export const HomeBackground = () => {
+ return (
+
+ {/* Linear Gradient */}
+
+
+
+ );
+};
diff --git a/apps/landing/components/landing/home/ui/home-hero.tsx b/apps/landing/components/landing/home/ui/home-hero.tsx
new file mode 100644
index 0000000..f54e065
--- /dev/null
+++ b/apps/landing/components/landing/home/ui/home-hero.tsx
@@ -0,0 +1,28 @@
+import Link from "next/link";
+import { Button } from "@repo/ui";
+import content from "../../../../content/landing.json";
+
+export const HomeHero = () => {
+ return (
+
+
+ {content.home.title}
+
+
+ {content.home.description}
+
+
+
+
+ {content.home.buttons.primary}
+
+
+
+
+ {content.home.buttons.secondary} →
+
+
+
+
+ );
+};
diff --git a/apps/landing/hooks/use-debounce.ts b/apps/landing/hooks/use-debounce.ts
new file mode 100644
index 0000000..94dd80f
--- /dev/null
+++ b/apps/landing/hooks/use-debounce.ts
@@ -0,0 +1,23 @@
+import { useEffect, useState } from "react";
+
+/**
+ * Custom hook to debounce a value.
+ * @param value The value to debounce.
+ * @param delay The delay in milliseconds.
+ * @returns The debounced value.
+ */
+export function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/apps/landing/hooks/use-slice-data.ts b/apps/landing/hooks/use-slice-data.ts
new file mode 100644
index 0000000..1f08539
--- /dev/null
+++ b/apps/landing/hooks/use-slice-data.ts
@@ -0,0 +1,57 @@
+import { useState, useEffect, useCallback } from "react";
+import { useParams } from "next/navigation";
+import type { Slice, Memo } from "@repo/types";
+import { getSlice, getSliceBrains } from "../services/slice.service";
+
+export const useSliceData = () => {
+ const params = useParams();
+ const [slice, setSlice] = useState(null);
+ const [brains, setBrains] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchData = useCallback(async () => {
+ if (!params.id) return;
+
+ const idString = params.id;
+ const hash = Array.isArray(idString) ? idString[0] : idString;
+
+ if (!hash) return;
+
+ let sliceId;
+ try {
+ sliceId = atob(hash);
+ } catch {
+ setError("Invalid slice ID");
+ setLoading(false);
+ return;
+ }
+
+ try {
+ const [sliceRes, brainsRes] = await Promise.all([
+ getSlice(sliceId),
+ getSliceBrains(sliceId),
+ ]);
+
+ if (!sliceRes.slice) {
+ setError("Slice not found");
+ } else {
+ setSlice(sliceRes.slice);
+ setBrains(brainsRes.brains);
+ }
+ } catch (err) {
+ console.error(err);
+ setError("Failed to load slice data");
+ } finally {
+ setLoading(false);
+ }
+ }, [params.id]);
+
+ useEffect(() => {
+ if (params.id) {
+ fetchData();
+ }
+ }, [fetchData, params.id]);
+
+ return { slice, brains, loading, error, refresh: fetchData };
+};
diff --git a/apps/landing/lib/axios.ts b/apps/landing/lib/axios.ts
new file mode 100644
index 0000000..3a2096f
--- /dev/null
+++ b/apps/landing/lib/axios.ts
@@ -0,0 +1,64 @@
+import axios from "axios";
+
+const apiClient = axios.create({
+ baseURL: process.env.NEXT_PUBLIC_API_URL,
+ withCredentials: true,
+ headers: {
+ "Content-Type": "application/json",
+ },
+});
+
+let isRefreshing = false;
+let refreshPromise: Promise | null = null;
+
+apiClient.interceptors.response.use(
+ (response) => response,
+ async (error) => {
+ const originalRequest = error.config;
+
+ // If error is 401 and it's NOT a refresh token request
+ if (
+ error.response?.status === 401 &&
+ !originalRequest._retry &&
+ !originalRequest.url?.includes("/auth/refresh")
+ ) {
+ originalRequest._retry = true;
+
+ // If already refreshing, wait for the existing promise
+ if (isRefreshing && refreshPromise) {
+ try {
+ await refreshPromise;
+ return apiClient(originalRequest);
+ } catch (err) {
+ return Promise.reject(err);
+ }
+ }
+
+ // Start new refresh process
+ isRefreshing = true;
+ refreshPromise = apiClient.post("/auth/refresh", {});
+
+ try {
+ await refreshPromise;
+ isRefreshing = false;
+ refreshPromise = null;
+
+ // Retry original request
+ return apiClient(originalRequest);
+ } catch (refreshError) {
+ isRefreshing = false;
+ refreshPromise = null;
+
+ // If refresh fails, redirect to login
+ if (typeof window !== "undefined") {
+ window.location.replace("/signin");
+ }
+ return Promise.reject(refreshError);
+ }
+ }
+
+ return Promise.reject(error);
+ },
+);
+
+export default apiClient;
diff --git a/apps/landing/middleware.ts b/apps/landing/middleware.ts
new file mode 100644
index 0000000..7bb6b3c
--- /dev/null
+++ b/apps/landing/middleware.ts
@@ -0,0 +1,28 @@
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+
+export function middleware(req: NextRequest) {
+ // 1. Check for access token OR refresh token
+ const cookieName = process.env.ACCESS_TOKEN_COOKIE_NAME || "access_token";
+ const refreshCookieName =
+ process.env.REFRESH_TOKEN_COOKIE_NAME || "refresh_token";
+
+ const hasAccessToken = req.cookies.has(cookieName);
+ const hasRefreshToken = req.cookies.has(refreshCookieName);
+
+ // 2. If neither exists, redirect to sign-in
+ if (!hasAccessToken && !hasRefreshToken) {
+ const url = req.nextUrl.clone();
+ url.pathname = "/signin"; // Matches existing route: `apps/landing/app/(auth)/signin`
+ return NextResponse.redirect(url);
+ }
+
+ // 3. If either exists, allow the request
+ // (We do NOT validate JWTs here - that is for the backend / API layers)
+ return NextResponse.next();
+}
+
+// 4. Protect specific routes
+export const config = {
+ matcher: ["/dashboard/:path*"],
+};
diff --git a/apps/landing/package.json b/apps/landing/package.json
index d9faf47..bd66fca 100644
--- a/apps/landing/package.json
+++ b/apps/landing/package.json
@@ -12,22 +12,26 @@
},
"dependencies": {
"@repo/ui": "workspace:*",
+ "axios": "^1.13.4",
"lucide-react": "^0.563.0",
"motion": "^12.23.25",
"next": "^15.5.7",
"next-themes": "^0.4.6",
"react": "^19.1.0",
- "react-dom": "^19.1.0"
+ "react-dom": "^19.1.0",
+ "react-icons": "^5.5.0"
},
"devDependencies": {
"@repo/eslint-config": "*",
- "@repo/typescript-config": "*",
+ "@repo/types": "workspace:*",
+ "@repo/fonts": "workspace:*",
"@repo/tailwind-config": "workspace:*",
+ "@repo/typescript-config": "*",
+ "@types/axios": "^0.14.4",
"@types/node": "^22.15.3",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.1",
"eslint": "^9.34.0",
- "typescript": "5.9.2",
- "@repo/fonts": "workspace:*"
+ "typescript": "5.9.2"
}
}
diff --git a/apps/landing/services/auth.service.ts b/apps/landing/services/auth.service.ts
new file mode 100644
index 0000000..1453d1d
--- /dev/null
+++ b/apps/landing/services/auth.service.ts
@@ -0,0 +1,21 @@
+import apiClient from "../lib/axios";
+import type { SignUpRequest, SignInRequest, AuthResponse } from "@repo/types";
+
+export const signup = async (data: SignUpRequest): Promise => {
+ const response = await apiClient.post("/auth/signup", data);
+ return response.data;
+};
+
+export const signin = async (data: SignInRequest): Promise => {
+ const response = await apiClient.post("/auth/signin", data);
+ return response.data;
+};
+
+export const signout = async (): Promise => {
+ await apiClient.post("/auth/signout");
+};
+
+export const refresh = async (): Promise => {
+ const response = await apiClient.post("/auth/refresh");
+ return response.data;
+};
diff --git a/apps/landing/services/memo.service.ts b/apps/landing/services/memo.service.ts
new file mode 100644
index 0000000..144501d
--- /dev/null
+++ b/apps/landing/services/memo.service.ts
@@ -0,0 +1,51 @@
+import apiClient from "../lib/axios";
+import type { Memo } from "@repo/types";
+
+// Types corresponding to backend responses
+export interface CreateMemoRequest {
+ type: "TEXT" | "TODO" | "LINK" | "QA" | "CODE";
+ title?: string;
+ content: any; // Will be validated based on type
+ sliceId: string;
+ pinned?: boolean;
+}
+
+export interface UpdateMemoRequest {
+ type?: "TEXT" | "TODO" | "LINK" | "QA" | "CODE";
+ title?: string;
+ content?: any;
+ pinned?: boolean;
+}
+
+export interface MemoResponse {
+ memo: Memo;
+}
+
+export interface DeleteMemoResponse {
+ message: string;
+}
+
+export const createMemo = async (
+ data: CreateMemoRequest,
+): Promise => {
+ const response = await apiClient.post("/memo", data);
+ return response.data;
+};
+
+export const getMemo = async (id: string): Promise => {
+ const response = await apiClient.get(`/memo/${id}`);
+ return response.data;
+};
+
+export const updateMemo = async (
+ id: string,
+ data: UpdateMemoRequest,
+): Promise => {
+ const response = await apiClient.patch(`/memo/${id}`, data);
+ return response.data;
+};
+
+export const deleteMemo = async (id: string): Promise => {
+ const response = await apiClient.delete(`/memo/${id}`);
+ return response.data;
+};
diff --git a/apps/landing/services/slice.service.ts b/apps/landing/services/slice.service.ts
new file mode 100644
index 0000000..2343036
--- /dev/null
+++ b/apps/landing/services/slice.service.ts
@@ -0,0 +1,138 @@
+import apiClient from "../lib/axios";
+import type { Slice, Memo } from "@repo/types";
+
+// Types corresponding to backend responses
+export interface CreateSliceRequest {
+ name: string;
+ description: string;
+}
+
+export interface UpdateSliceRequest {
+ name?: string;
+ description?: string;
+}
+
+export interface SliceResponse {
+ slice: Slice;
+}
+
+export interface SlicesResponse {
+ slices: Slice[];
+}
+
+export interface SliceBrainsResponse {
+ sliceId: string;
+ brains: Memo[];
+}
+
+export const createSlice = async (
+ data: CreateSliceRequest,
+): Promise => {
+ const response = await apiClient.post("/slice", data);
+ return response.data;
+};
+
+export const updateSlice = async (
+ sliceId: string,
+ data: UpdateSliceRequest,
+): Promise => {
+ const response = await apiClient.patch(
+ `/slice/${sliceId}`,
+ data,
+ );
+ return response.data;
+};
+
+export const deleteSlice = async (sliceId: string): Promise => {
+ await apiClient.delete(`/slice/${sliceId}`);
+};
+
+export const getSlices = async (title?: string): Promise => {
+ const params = title ? { title } : {};
+ const response = await apiClient.get("/slice", { params });
+ return response.data;
+};
+
+export const getSlice = async (sliceId: string): Promise => {
+ const response = await apiClient.get(`/slice/${sliceId}`);
+ return response.data;
+};
+
+export const getSliceBrains = async (
+ sliceId: string,
+): Promise => {
+ const response = await apiClient.get(
+ `/slice/${sliceId}/brains`,
+ );
+ return response.data;
+};
+
+// Access Management
+
+export interface UpdateSliceAccessStatusRequest {
+ accessStatus: "private" | "public" | "specific";
+}
+
+export const updateSliceAccessStatus = async (
+ sliceId: string,
+ data: UpdateSliceAccessStatusRequest,
+): Promise => {
+ await apiClient.patch(`/slice/${sliceId}/access/status`, data);
+};
+
+export interface GrantSliceAccessRequest {
+ email?: string;
+ userId?: string;
+ role: "viewer" | "editor";
+}
+
+export const grantSliceAccess = async (
+ sliceId: string,
+ data: GrantSliceAccessRequest,
+): Promise => {
+ await apiClient.post(`/slice/${sliceId}/access/users`, data);
+};
+
+export const revokeSliceAccess = async (
+ sliceId: string,
+ userId: string,
+): Promise => {
+ await apiClient.delete(`/slice/${sliceId}/access/users/${userId}`);
+};
+
+export interface SliceAccessUser {
+ id: string; // slice_access id
+ userId: string;
+ role: "viewer" | "editor";
+ user: {
+ id: string;
+ email: string;
+ fullName?: string;
+ avatarUrl?: string;
+ };
+}
+
+export interface SliceAccessListResponse {
+ accessList: SliceAccessUser[];
+}
+
+export const getSliceAccessList = async (
+ sliceId: string,
+): Promise => {
+ const response = await apiClient.get(
+ `/slice/${sliceId}/access/users`,
+ );
+ return response.data;
+};
+
+export interface UpdateSliceAccessRoleRequest {
+ role: "viewer" | "editor";
+}
+
+export const updateSliceAccessRole = async (
+ sliceId: string,
+ userId: string,
+ data: UpdateSliceAccessRoleRequest,
+): Promise => {
+ await apiClient.patch(`/slice/${sliceId}/access/users/${userId}`, data);
+};
diff --git a/apps/server/README.md b/apps/server/README.md
index d482238..c694141 100644
--- a/apps/server/README.md
+++ b/apps/server/README.md
@@ -1,15 +1,90 @@
-# .
+# CoBrain Server
-To install dependencies:
+The backend API for CoBrain, built with **Express.js** and **TypeScript**. It handles user authentication, data persistence, and business logic.
+
+## 🛠 Tech Stack
+
+- **Runtime**: Node.js / Bun
+- **Framework**: [Express.js](https://expressjs.com/)
+- **Auth**: [Passport.js](https://www.passportjs.org/) (Google Strategy, JWT Strategy)
+- **Security**: Argon2 (Password hashing), Cookie Parser (Secure HttpOnly cookies)
+- **Database**: PostgreSQL (via `@repo/db` package)
+
+## 🚀 Getting Started
+
+### 1. Environment Variables
+
+Create a `.env` file in `apps/server` based on `.env.example`:
```bash
-bun install
+cp .env.example .env
```
-To run:
+**Configuration Reference:**
+
+```env
+PORT=3001
+CLIENT_APP_URL=http://localhost:3000
+
+# Authentication (Google OAuth)
+GOOGLE_CLIENT_ID=your_client_id
+GOOGLE_CLIENT_SECRET=your_client_secret
+GOOGLE_REDIRECT_URI=http://localhost:3001/auth/google/callback
+
+# JWT Config
+ACCESS_JWT_SECRET=complex_secret_string
+REFRESH_JWT_SECRET=another_complex_secret_string
+ACCESS_TOKEN_EXPIRE=15m
+REFRESH_TOKEN_EXPIRE=7d
+
+# Cookies
+ACCESS_TOKEN_COOKIE_NAME=access_token
+REFRESH_TOKEN_COOKIE_NAME=refresh_token
+```
+
+### 2. Run Server
+
+To run the server in development mode:
```bash
-bun run src/index.ts
+bun dev
```
-This project was created using `bun init` in bun v1.1.42. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
+The server will start at `http://localhost:3001` (or your configured PORT).
+
+## 📡 API Overview
+
+The API is structured in RESTful routes located in `src/routes`:
+
+### Authentication (`/auth`)
+
+- `GET /auth/google`: Initiates Google OAuth flow.
+- `GET /auth/google/callback`: Callback handler, sets cookies and redirects to Client URL.
+- `POST /auth/refresh`: Refreshes access token using the refresh token cookie.
+
+### User (`/user`)
+
+- `GET /user/profile`: Get current user details.
+
+### Memos / Brains (`/memo`)
+
+- CRUD operations for Memos (Brains).
+- `GET /memo`: List all memos.
+- `POST /memo`: Create new memo.
+
+### Slices (`/slice`)
+
+- CRUD operations for Slices (Categories/Tags).
+
+## 📦 Project Structure
+
+```
+apps/server/src/
+├── config/ # Configuration (DB, Passport)
+├── helpers/ # Utility helpers
+├── middlewares/ # Express middlewares (Auth guard, Error handling)
+├── routes/ # Route definitions
+├── services/ # Business logic layer
+├── types/ # Type definitions (augmenting Express Request etc.)
+└── index.ts # Entry point
+```
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index e351a49..d7d2e44 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -37,3 +37,5 @@ app.get("/", (req: Request, res: Response) => {
app.listen(config.server.port, () => {
console.log(`Server is running on port ${config.server.port}`);
});
+
+export default app;
\ No newline at end of file
diff --git a/apps/server/src/services/auth.services.ts b/apps/server/src/services/auth.services.ts
index ec6d9e5..894b1b2 100644
--- a/apps/server/src/services/auth.services.ts
+++ b/apps/server/src/services/auth.services.ts
@@ -73,8 +73,16 @@ export const handleSignUp = async (
}
// Generate tokens
- const accessToken = createToken(newUser, config.jwt.accessSecret);
- const refreshToken = createToken(newUser, config.jwt.refreshSecret);
+ const accessToken = createToken(
+ newUser,
+ config.jwt.accessSecret,
+ config.cookies.accessTokenExpire,
+ );
+ const refreshToken = createToken(
+ newUser,
+ config.jwt.refreshSecret,
+ config.cookies.refreshTokenExpire,
+ );
// Store refresh token
const tokenHash = crypto
@@ -155,8 +163,16 @@ export const handleSignIn = async (
}
// Generate tokens
- const accessToken = createToken(user, config.jwt.accessSecret);
- const refreshToken = createToken(user, config.jwt.refreshSecret);
+ const accessToken = createToken(
+ user,
+ config.jwt.accessSecret,
+ config.cookies.accessTokenExpire,
+ );
+ const refreshToken = createToken(
+ user,
+ config.jwt.refreshSecret,
+ config.cookies.refreshTokenExpire,
+ );
// Store refresh token
const tokenHash = crypto
@@ -271,8 +287,16 @@ export const handleGoogleCallback = (
}
// Generate tokens
- const accessToken = createToken(user, config.jwt.accessSecret);
- const refreshToken = createToken(user, config.jwt.refreshSecret);
+ const accessToken = createToken(
+ user,
+ config.jwt.accessSecret,
+ config.cookies.accessTokenExpire,
+ );
+ const refreshToken = createToken(
+ user,
+ config.jwt.refreshSecret,
+ config.cookies.refreshTokenExpire,
+ );
// Store refresh token
const tokenHash = crypto
@@ -300,15 +324,8 @@ export const handleGoogleCallback = (
maxAge: 7 * 24 * 60 * 60 * 1000,
});
- res.json({
- user: {
- id: user.id,
- name: user.name,
- email: user.email,
- },
- accessToken,
- refreshToken,
- });
+ // Redirect to client dashboard
+ res.redirect(`${config.client.appUrl}/dashboard`);
} catch (error) {
next(error);
}
@@ -384,8 +401,16 @@ export const handleRefresh = async (
.where(eq(refreshTokens.id, storedToken.id));
// Generate new tokens
- const newAccessToken = createToken(user, config.jwt.accessSecret);
- const newRefreshToken = createToken(user, config.jwt.refreshSecret);
+ const newAccessToken = createToken(
+ user,
+ config.jwt.accessSecret,
+ config.cookies.accessTokenExpire,
+ );
+ const newRefreshToken = createToken(
+ user,
+ config.jwt.refreshSecret,
+ config.cookies.refreshTokenExpire,
+ );
// Store new refresh token
const newTokenHash = crypto
diff --git a/apps/server/src/services/memo.services.ts b/apps/server/src/services/memo.services.ts
index 347a618..60f5229 100644
--- a/apps/server/src/services/memo.services.ts
+++ b/apps/server/src/services/memo.services.ts
@@ -69,7 +69,7 @@ export const handleCreateMemo = async (
}
res.status(HttpStatus.CREATED).json({
- memo: newMemo,
+ memo: newMemo as unknown as import("@repo/types").Memo,
});
} catch (error) {
console.error("Create memo error:", error);
@@ -126,7 +126,7 @@ export const handleGetMemo = async (
}
res.json({
- memo,
+ memo: memo as unknown as import("@repo/types").Memo,
});
} catch (error) {
console.error("Get memo error:", error);
@@ -220,7 +220,7 @@ export const handleUpdateMemo = async (
}
res.json({
- memo: updatedMemo,
+ memo: updatedMemo as unknown as import("@repo/types").Memo,
});
} catch (error) {
console.error("Update memo error:", error);
diff --git a/apps/server/src/services/slice.services.ts b/apps/server/src/services/slice.services.ts
index acf23f0..31eb2c4 100644
--- a/apps/server/src/services/slice.services.ts
+++ b/apps/server/src/services/slice.services.ts
@@ -46,7 +46,7 @@ export const handleCreateSlice = async (
.insert(slices)
.values({
name,
- description,
+ description: description || "",
ownerId: userId,
})
.returning();
@@ -286,6 +286,14 @@ export const handleDeleteSlice = async (
} as any);
}
+ // Delete all memos in this slice first
+ await db.delete(memos).where(eq(memos.sliceId, sliceId));
+
+ // Delete all access grants for this slice
+ const { sliceAccess } = await import("@repo/db");
+ await db.delete(sliceAccess).where(eq(sliceAccess.sliceId, sliceId));
+
+ // Finally delete the slice
await db.delete(slices).where(eq(slices.id, sliceId));
res.json({
@@ -340,7 +348,7 @@ export const handleGetSliceBrains = async (
res.json({
sliceId,
- brains: sliceMemos,
+ brains: sliceMemos as unknown as import("@repo/types").Memo[],
});
} catch (error) {
res
diff --git a/apps/server/src/utils/auth.helper.ts b/apps/server/src/utils/auth.helper.ts
index 597a19c..751ade6 100644
--- a/apps/server/src/utils/auth.helper.ts
+++ b/apps/server/src/utils/auth.helper.ts
@@ -3,31 +3,39 @@ import argon2 from "argon2";
import type { User } from "@repo/types";
import config from "../config";
-// Create Access JWT token
-export const createToken = (user: User, secret: string) => {
- const options: SignOptions = {
- expiresIn: config.cookies.accessTokenExpire,
- };
-
- return jwt.sign({
- id: user.id,
- name: user.name,
- email: user.email,
- }, secret, options);
+// Create JWT token
+export const createToken = (
+ user: User,
+ secret: string,
+ expiresIn: SignOptions["expiresIn"] = config.cookies.accessTokenExpire,
+) => {
+ const options: SignOptions = {
+ expiresIn,
+ };
+
+ return jwt.sign(
+ {
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ },
+ secret,
+ options,
+ );
};
// Verify Access JWT token
export const verifyToken = (token: string, secret: string) => {
- return jwt.verify(token, secret);
+ return jwt.verify(token, secret);
};
export const hashString = async (str: string) => {
- return await argon2.hash(str, {
- type: argon2.argon2id,
- timeCost: 2,
- });
+ return await argon2.hash(str, {
+ type: argon2.argon2id,
+ timeCost: 2,
+ });
};
export const verifyString = async (str: string, hash: string) => {
- return await argon2.verify(hash, str);
+ return await argon2.verify(hash, str);
};
diff --git a/apps/server/vercel.json b/apps/server/vercel.json
new file mode 100644
index 0000000..bc45e2d
--- /dev/null
+++ b/apps/server/vercel.json
@@ -0,0 +1,15 @@
+{
+ "version": 2,
+ "builds": [
+ {
+ "src": "dist/index.js",
+ "use": "@vercel/node"
+ }
+ ],
+ "routes": [
+ {
+ "src": "/(.*)",
+ "dest": "/dist/index.js"
+ }
+ ]
+}
diff --git a/bun.lockb b/bun.lockb
index a36fd0b..aa258ba 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 46ea232..25bbc13 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"packages/*"
],
"dependencies": {
+ "jose": "^6.1.3",
"zod": "^4.3.6"
}
}
diff --git a/packages/db/README.md b/packages/db/README.md
index 72e76c3..0b4c372 100644
--- a/packages/db/README.md
+++ b/packages/db/README.md
@@ -1,15 +1,52 @@
-# db
+# CoBrain Database
-To install dependencies:
+This package manages the database schema and connection for CoBrain, using **PostgreSQL** and **Drizzle ORM**.
-```bash
-bun install
+## 🛠 Tech Stack
+
+- **Database**: PostgreSQL
+- **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
+- **Migration Tool**: Drizzle Kit
+
+## 🚀 Getting Started
+
+### 1. Environment Variables
+
+Create a `.env` file in `packages/db`:
+
+```env
+DATABASE_URL=postgresql://postgres:postgres@localhost:5432/memobrain
```
-To run:
+### 2. Local Database (Docker)
+
+This directory includes a `docker-compose.yml` to easily start a local Postgres instance.
```bash
-bun run index.ts
+# Start Postgres
+docker-compose up -d
+
+# Stop Postgres
+docker-compose down
```
-This project was created using `bun init` in bun v1.1.42. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
+### 3. Managing Schema
+
+We use **Drizzle Kit** to manage migrations and schema updates.
+
+**Common Commands:**
+
+| Command | Description |
+| -------------------------- | ------------------------------------------------------------------ |
+| `bun run drizzle:generate` | Generate SQL migrations based on schema changes in `src/schema.ts` |
+| `bun run drizzle:migrate` | Apply generated migrations to the database |
+| `bun run drizzle:push` | Push schema changes directly to DB (useful for prototyping) |
+| `bun run drizzle:studio` | Open Drizzle Studio to view and edit data in the browser |
+
+## 📂 Structure
+
+- **`drizzle/`**: Contains generated SQL migration files.
+- **`src/`**:
+ - `schema.ts`: Defines the database schema (tables, relationships).
+ - `index.ts`: Exports the DB client connection.
+- **`drizzle.config.ts`**: Configuration for Drizzle Kit.
diff --git a/packages/db/drizzle/relations.ts b/packages/db/drizzle/relations.ts
new file mode 100644
index 0000000..80768e2
--- /dev/null
+++ b/packages/db/drizzle/relations.ts
@@ -0,0 +1,3 @@
+import { relations } from "drizzle-orm/relations";
+import { } from "./schema";
+
diff --git a/packages/db/drizzle/schema.ts b/packages/db/drizzle/schema.ts
new file mode 100644
index 0000000..9b0dfc8
--- /dev/null
+++ b/packages/db/drizzle/schema.ts
@@ -0,0 +1,6 @@
+import { pgTable } from "drizzle-orm/pg-core"
+import { sql } from "drizzle-orm"
+
+
+
+
diff --git a/packages/types/src/auth.types.ts b/packages/types/src/auth.types.ts
new file mode 100644
index 0000000..492e0b5
--- /dev/null
+++ b/packages/types/src/auth.types.ts
@@ -0,0 +1,33 @@
+export interface SignUpRequest {
+ name: string;
+ email: string;
+ password: string;
+}
+
+export interface SignInRequest {
+ email: string;
+ password: string;
+}
+
+export interface SignOutRequest {
+ refreshToken?: string;
+}
+
+export interface RefreshTokenRequest {
+ refreshToken: string;
+}
+
+export interface AuthResponse {
+ user: {
+ id: string;
+ name: string;
+ email: string;
+ };
+ accessToken: string;
+ refreshToken: string;
+}
+
+export interface GoogleCallbackQuery {
+ code: string;
+ state?: string;
+}
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 0a843de..d77f0ce 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -14,9 +14,37 @@ export type SliceAccess = InferSelectModel;
export type NewSliceAccess = InferInsertModel;
// Memo types
-export type Memo = InferSelectModel;
+import type {
+ TextContent,
+ TodoContent,
+ LinkContent,
+ QAContent,
+ CodeContent,
+} from "./memo.type";
+
+// Memo types
+export type DbMemo = InferSelectModel;
export type NewMemo = InferInsertModel;
+export type Memo =
+ | (Omit & {
+ type: "TEXT";
+ content: TextContent;
+ })
+ | (Omit & {
+ type: "TODO";
+ content: TodoContent;
+ })
+ | (Omit & {
+ type: "LINK";
+ content: LinkContent;
+ })
+ | (Omit & { type: "QA"; content: QAContent })
+ | (Omit & {
+ type: "CODE";
+ content: CodeContent;
+ });
+
// RefreshToken types
export type RefreshToken = InferSelectModel;
export type NewRefreshToken = InferInsertModel;
@@ -34,3 +62,4 @@ export * from "./http-status";
export * from "./validation";
export * from "./memo.type";
+export * from "./auth.types";
diff --git a/packages/types/src/validation/slice.schema.ts b/packages/types/src/validation/slice.schema.ts
index 9d666fd..554099b 100644
--- a/packages/types/src/validation/slice.schema.ts
+++ b/packages/types/src/validation/slice.schema.ts
@@ -3,7 +3,7 @@ import { z } from "zod";
// Create Slice
export const createSliceSchema = z.object({
name: z.string().min(1, "Name is required").max(255),
- description: z.string().min(1, "Description is required").max(1000),
+ description: z.string().max(1000).optional(),
});
export type CreateSliceInput = z.infer;