diff --git a/CLAUDE.md b/CLAUDE.md index 76f1e67..e76d5e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,8 +130,7 @@ interface Block { ``` Key files: -- `components/editor/SnapDocsEditor.tsx` - Main editor with drag-and-drop -- `components/editor/blocks/` - Individual block implementations +- `components/editor/BlockNoteEditor.tsx` - Main editor using BlockNote - `lib/services/page-content.ts` - MongoDB content service ### Real-Time Collaboration @@ -175,11 +174,12 @@ Required in `.env.local`: ## Development Tips -### Adding New Block Types -1. Add type to `BlockType` union in `types/index.ts` -2. Create block component in `components/editor/blocks/` -3. Update `BlockV2.tsx` to handle rendering -4. Add to slash menu in `SlashMenu.tsx` +### Editor +The application uses BlockNote as the main editor, which provides: +- Rich text editing with slash commands +- Built-in block types (headings, lists, tables, etc.) +- File uploads and media embedding +- Real-time collaboration support ### Database Operations ```typescript diff --git a/app/(protected)/ClientLayout.tsx b/app/(protected)/ClientLayout.tsx index a9849fd..16f29e0 100644 --- a/app/(protected)/ClientLayout.tsx +++ b/app/(protected)/ClientLayout.tsx @@ -1,11 +1,9 @@ 'use client' -import { SocketProvider } from '@/lib/socket/client' - export function ClientLayout({ children }: { children: React.ReactNode }) { return ( - + <> {children} - + ) } \ No newline at end of file diff --git a/app/(protected)/workspace/[workspaceId]/database/create/CreateDatabasePage.tsx b/app/(protected)/workspace/[workspaceId]/database/create/CreateDatabasePage.tsx new file mode 100644 index 0000000..fa4ed95 --- /dev/null +++ b/app/(protected)/workspace/[workspaceId]/database/create/CreateDatabasePage.tsx @@ -0,0 +1,69 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { CreateDatabaseDialog } from '@/components/database' +import { Button } from '@/components/ui/button' +import { ArrowLeft, Database } from 'lucide-react' +import Link from 'next/link' +import toast from 'react-hot-toast' + +interface CreateDatabasePageProps { + workspaceId: string + workspace: { + id: string + name: string + slug: string + } + user: { + id: string + email: string + name?: string | null + } +} + +export default function CreateDatabasePage({ workspaceId, workspace, user }: CreateDatabasePageProps) { + const router = useRouter() + const [showDialog, setShowDialog] = useState(true) + + const handleCreateDatabase = async (database: any) => { + toast.success('Database created successfully!') + router.push(`/workspace/${workspaceId}/database/${database.id}`) + } + + const handleCancel = () => { + router.push(`/workspace/${workspaceId}`) + } + + return ( +
+
+
+ + + + +
+ +

Create New Database

+
+

+ Create a structured database to organize and manage your data +

+
+ + { + if (!open) handleCancel() + }} + workspaceId={workspaceId} + onCreateDatabase={handleCreateDatabase} + /> +
+
+ ) +} \ No newline at end of file diff --git a/app/(protected)/workspace/[workspaceId]/database/create/page.tsx b/app/(protected)/workspace/[workspaceId]/database/create/page.tsx new file mode 100644 index 0000000..4dcaaec --- /dev/null +++ b/app/(protected)/workspace/[workspaceId]/database/create/page.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import { redirect } from 'next/navigation' +import { getCurrentUser } from '@/lib/auth' +import { prisma } from '@/lib/db/prisma' +import { generateId } from '@/lib/utils/id' +import CreateDatabasePage from './CreateDatabasePage' + +interface CreateDatabasePageProps { + params: Promise<{ + workspaceId: string + }> +} + +export default async function CreateDatabase({ params }: CreateDatabasePageProps) { + const user = await getCurrentUser() + if (!user) { + redirect('/login') + } + + const resolvedParams = await params + + // Check if user is a member of the workspace + const workspaceMember = await prisma.workspaceMember.findUnique({ + where: { + userId_workspaceId: { + userId: user.id, + workspaceId: resolvedParams.workspaceId + } + } + }) + + if (!workspaceMember) { + redirect(`/workspace/${resolvedParams.workspaceId}`) + } + + const workspace = await prisma.workspace.findUnique({ + where: { id: resolvedParams.workspaceId }, + select: { id: true, name: true, slug: true } + }) + + if (!workspace) { + redirect('/') + } + + return ( + + ) +} \ No newline at end of file diff --git a/app/(protected)/workspace/[workspaceId]/page/[pageId]/PageEditorV2.tsx b/app/(protected)/workspace/[workspaceId]/page/[pageId]/PageEditorV2.tsx index 6c5f5fd..c373504 100644 --- a/app/(protected)/workspace/[workspaceId]/page/[pageId]/PageEditorV2.tsx +++ b/app/(protected)/workspace/[workspaceId]/page/[pageId]/PageEditorV2.tsx @@ -7,7 +7,7 @@ import dynamic from 'next/dynamic' import { SnapDocsPageHeader } from '@/components/page/snapdocs-page-header' import { cn } from '@/lib/utils' import toast from 'react-hot-toast' -import { useSocket } from '@/lib/socket/client' +// import { useSocket } from '@/lib/socket/client' // Removed - using Yjs collaboration now // Dynamically import BlockNoteEditor to avoid SSR hydration issues const BlockNoteEditor = dynamic( @@ -65,7 +65,7 @@ interface PageEditorProps { export default function PageEditorV2({ page, initialContent, user }: PageEditorProps) { const router = useRouter() - const { isConnected, joinPage, leavePage } = useSocket() + // const { isConnected, joinPage, leavePage } = useSocket() // Removed - using Yjs collaboration now const [title, setTitle] = useState(page.title || '') const [icon, setIcon] = useState(page.icon || '') const [coverImage, setCoverImage] = useState(page.coverImage || '') @@ -78,22 +78,8 @@ export default function PageEditorV2({ page, initialContent, user }: PageEditorP const initialBlocks = initialContent?.blocks || [] - // Join the page room for real-time collaboration - useEffect(() => { - if (isConnected && user) { - joinPage(page.id, page.workspaceId, { - id: user.id, - name: user.name || 'Anonymous', - email: user.email || '', - avatarUrl: null - }) - } - - // Clean up when leaving the page - return () => { - leavePage() - } - }, [isConnected, page.id, page.workspaceId, user, joinPage, leavePage]) + // Yjs collaboration is now handled directly in BlockNoteEditor component + // No need for Socket.io room joining // Auto-resize title textarea diff --git a/app/api/databases/[databaseId]/route.ts b/app/api/databases/[databaseId]/route.ts index 069e4b8..bcbf71e 100644 --- a/app/api/databases/[databaseId]/route.ts +++ b/app/api/databases/[databaseId]/route.ts @@ -133,6 +133,71 @@ export async function PUT( } } +// PATCH /api/databases/[databaseId] - Partial update database +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ databaseId: string }> } +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const resolvedParams = await params + const body = await request.json() + + // Check database exists and user has access + const database = await prisma.database.findUnique({ + where: { + id: resolvedParams.databaseId + }, + include: { + workspace: { + include: { + members: { + where: { + userId: session.user.id + } + } + } + } + } + }) + + if (!database) { + return NextResponse.json({ error: 'Database not found' }, { status: 404 }) + } + + if (!database.workspace.members.length) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const updatedDatabase = await prisma.database.update({ + where: { + id: resolvedParams.databaseId + }, + data: body, + include: { + views: true, + rows: { + orderBy: { + order: 'asc' + } + } + } + }) + + return NextResponse.json(updatedDatabase) + } catch (error) { + console.error('Error updating database:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} + // DELETE /api/databases/[databaseId] - Delete database export async function DELETE( request: NextRequest, diff --git a/app/globals.css b/app/globals.css index 45086ac..4821f68 100644 --- a/app/globals.css +++ b/app/globals.css @@ -126,48 +126,6 @@ @apply relative min-h-full; } -/* BlockV2 System - Clean hover area and handle positioning */ -.block-wrapper { - @apply relative; -} - -/* Extend hover area to include the handle space */ -.block-wrapper::before { - content: ''; - position: absolute; - left: -3rem; /* Mobile: 48px for handles */ - right: 0; - top: 0; - bottom: 0; - pointer-events: none; - z-index: 1; -} - -@media (min-width: 768px) { - .block-wrapper::before { - left: -4rem; /* Desktop: 64px for handles */ - } -} - -/* Block content area - properly centered */ -.block-content { - @apply relative; - /* Ensure content doesn't shift when handles appear */ - margin-left: 0; -} - -/* Handle positioning with negative margins - doesn't affect content flow */ -.block-wrapper .absolute.-left-12 { - /* Mobile handle position */ - left: -3rem; -} - -@media (min-width: 768px) { - .block-wrapper .absolute.-left-16 { - /* Desktop handle position */ - left: -4rem; - } -} /* Responsive typography */ @layer utilities { diff --git a/components/collaboration/ActiveUsers.tsx b/components/collaboration/ActiveUsers.tsx index a47ccf6..b20972d 100644 --- a/components/collaboration/ActiveUsers.tsx +++ b/components/collaboration/ActiveUsers.tsx @@ -1,60 +1,14 @@ 'use client' import React from 'react' -import { useSocket } from '@/lib/socket/client' -import { cn } from '@/lib/utils' -export function ActiveUsers() { - const { currentUsers, isConnected, currentPageId } = useSocket() - - // Don't show if not connected or not on a page - if (!isConnected || !currentPageId) { - return null - } +// Note: ActiveUsers component is temporarily disabled while migrating to Yjs collaboration +// The BlockNote editor now handles collaboration cursors and user presence automatically +// You can re-implement this using Yjs awareness API if needed - // Current users already contains only OTHER users (not including current user) - // So total = other users + 1 (current user) - const totalUsers = currentUsers.size + 1 - - return ( -
- {currentUsers.size > 0 && ( -
- {Array.from(currentUsers.entries()).slice(0, 5).map(([userId, user], index) => ( -
-
- {(user.name || user.email)?.[0]?.toUpperCase() || '?'} -
- - {/* Tooltip */} -
- {user.name || user.email} -
-
- ))} - - {currentUsers.size > 5 && ( -
- +{currentUsers.size - 5} -
- )} -
- )} - - - {totalUsers === 1 ? 'Just you' : `${totalUsers} ${totalUsers === 2 ? 'user' : 'users'} on this page`} - -
- ) +export function ActiveUsers() { + // Temporarily return null while we migrate to Yjs + // TODO: Implement using Yjs awareness API to show active users + // The BlockNote editor with Yjs collaboration handles cursors and presence automatically + return null } \ No newline at end of file diff --git a/components/database/ColumnMenu.tsx b/components/database/ColumnMenu.tsx new file mode 100644 index 0000000..731d6dc --- /dev/null +++ b/components/database/ColumnMenu.tsx @@ -0,0 +1,154 @@ +"use client" + +import { useState, useRef, useEffect } from "react" +import { createPortal } from "react-dom" +import { DatabaseProperty } from "@/types" +import { cn } from "@/lib/utils" +import { + ArrowUpDown, + ArrowUp, + ArrowDown, + EyeOff, + Copy, + Trash2 +} from "lucide-react" + +interface ColumnMenuProps { + property: DatabaseProperty + anchorEl: HTMLElement | null + onClose: () => void + onSort?: (direction: 'asc' | 'desc' | null) => void + onHide?: () => void + onDuplicate?: () => void + onDelete?: () => void + currentSort?: 'asc' | 'desc' | null +} + + +export function ColumnMenu({ + property, + anchorEl, + onClose, + onSort, + onHide, + onDuplicate, + onDelete, + currentSort +}: ColumnMenuProps) { + const [position, setPosition] = useState({ top: 0, left: 0 }) + const menuRef = useRef(null) + + useEffect(() => { + if (anchorEl) { + const rect = anchorEl.getBoundingClientRect() + const menuWidth = 200 + const menuHeight = 300 // Approximate height + + let left = rect.left + let top = rect.bottom + 4 + + // Adjust if menu would go off screen + if (left + menuWidth > window.innerWidth) { + left = window.innerWidth - menuWidth - 10 + } + + if (top + menuHeight > window.innerHeight) { + top = rect.top - menuHeight - 4 + } + + setPosition({ top, left }) + } + }, [anchorEl]) + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node) && + anchorEl && !anchorEl.contains(event.target as Node)) { + onClose() + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [anchorEl, onClose]) + + const handleSort = (direction: 'asc' | 'desc' | null) => { + onSort?.(direction) + onClose() + } + + if (!anchorEl) return null + + return createPortal( +
+ {/* Sort options - only for text properties */} + {(property.type === 'text' || property.type === 'email' || property.type === 'url' || property.type === 'phone') && ( +
+ + + {currentSort && ( + + )} +
+ )} + + + {/* Column actions */} +
+ + + +
+
, + document.body + ) +} \ No newline at end of file diff --git a/components/database/CreateDatabaseDialog.tsx b/components/database/CreateDatabaseDialog.tsx new file mode 100644 index 0000000..100c661 --- /dev/null +++ b/components/database/CreateDatabaseDialog.tsx @@ -0,0 +1,204 @@ +"use client" + +import { useState } from 'react' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { DatabaseViewType, DatabaseProperty } from '@/types' +import { Table, LayoutGrid, List, Calendar, Image, Clock } from 'lucide-react' +import { generateId } from '@/lib/utils/id' + +interface CreateDatabaseDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + workspaceId: string + pageId?: string + onCreateDatabase?: (database: any) => void +} + +const viewTypes = [ + { value: 'TABLE', label: 'Table', icon: Table, description: 'Traditional spreadsheet view' }, + { value: 'BOARD', label: 'Board', icon: LayoutGrid, description: 'Kanban-style board view' }, + { value: 'LIST', label: 'List', icon: List, description: 'Simple list view' }, + { value: 'CALENDAR', label: 'Calendar', icon: Calendar, description: 'Calendar view for dates' }, + { value: 'GALLERY', label: 'Gallery', icon: Image, description: 'Card-based gallery view' } +] + +export function CreateDatabaseDialog({ + open, + onOpenChange, + workspaceId, + pageId, + onCreateDatabase +}: CreateDatabaseDialogProps) { + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [viewType, setViewType] = useState('TABLE' as DatabaseViewType) + const [isCreating, setIsCreating] = useState(false) + + const handleCreate = async () => { + if (!name.trim()) return + + setIsCreating(true) + try { + // Default properties for new database + const defaultProperties: DatabaseProperty[] = [ + { + id: generateId(), + name: 'Name', + type: 'text', + options: {} + }, + { + id: generateId(), + name: 'Status', + type: 'select', + options: { + options: [ + { id: generateId(), name: 'Not Started', color: 'gray' }, + { id: generateId(), name: 'In Progress', color: 'blue' }, + { id: generateId(), name: 'Done', color: 'green' } + ] + } + }, + { + id: generateId(), + name: 'Priority', + type: 'select', + options: { + options: [ + { id: generateId(), name: 'Low', color: 'green' }, + { id: generateId(), name: 'Medium', color: 'yellow' }, + { id: generateId(), name: 'High', color: 'red' } + ] + } + }, + { + id: generateId(), + name: 'Due Date', + type: 'date', + options: { + dateFormat: 'MMM DD, YYYY', + includeTime: false + } + }, + { + id: generateId(), + name: 'Tags', + type: 'multiSelect', + options: { + options: [] + } + } + ] + + const response = await fetch('/api/databases', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name, + description, + workspaceId, + pageId, + viewType, + properties: defaultProperties + }) + }) + + if (!response.ok) { + throw new Error('Failed to create database') + } + + const database = await response.json() + + // Call the callback if provided + onCreateDatabase?.(database) + + // Reset form + setName('') + setDescription('') + setViewType('TABLE') + onOpenChange(false) + } catch (error) { + console.error('Error creating database:', error) + } finally { + setIsCreating(false) + } + } + + return ( + + + + Create New Database + + Create a structured database to organize your information + + + +
+
+ + setName(e.target.value)} + placeholder="e.g., Project Tracker, Task List..." + autoFocus + /> +
+ +
+ +