A real-time collaborative document editing platform with a hierarchical document structure and rich media support.
-
Real-time Collaboration
- Live updates using Convex
- Multi-user editing
- Presence indicators
- Conflict resolution
-
Document Structure
- Infinite nested documents
- Hierarchical navigation
- Document icons
- Cover images
-
Document Recovery
- Trash can functionality
- Soft delete
- File recovery
- Version history
- Notion-style Editor
- Block-based editing
- Rich text formatting
- Code blocks
- Tables
- Lists
- Media embeds
-
Responsive Design
- Mobile-first approach
- Adaptive layouts
- Touch-friendly interactions
- Collapsible sidebar
-
Appearance
- Light/Dark mode
- Custom document icons
- Cover image support
- Responsive images
// convex/schema.ts
import { defineSchema, defineTable } from 'convex/schema';
import { v } from 'convex/values';
export default defineSchema({
documents: defineTable({
title: v.string(),
content: v.optional(v.string()),
coverImage: v.optional(v.string()),
icon: v.optional(v.string()),
userId: v.string(),
parentDocument: v.optional(v.id('documents')),
isArchived: v.boolean(),
isPublished: v.boolean(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_user', ['userId'])
.index('by_parent', ['parentDocument'])
.index('by_user_parent', ['userId', 'parentDocument']),
files: defineTable({
documentId: v.id('documents'),
userId: v.string(),
url: v.string(),
type: v.string(),
size: v.number(),
createdAt: v.number(),
})
.index('by_document', ['documentId'])
.index('by_user', ['userId']),
});
// components/Editor.tsx
import { BlockNoteEditor } from '@blocknote/react';
import { useConvex } from 'convex/react';
export function Editor({
documentId,
initialContent,
}: {
documentId: string;
initialContent?: string;
}) {
const convex = useConvex();
const [editor] = useState(() =>
new BlockNoteEditor({
initialContent: initialContent
? JSON.parse(initialContent)
: undefined,
})
);
const debouncedUpdate = useMemo(
() => debounce(async (content: string) => {
await convex.mutation('documents:update', {
id: documentId,
content,
updatedAt: Date.now(),
});
}, 500),
[documentId]
);
return (
<BlockNoteView
editor={editor}
onChange={content => {
debouncedUpdate(JSON.stringify(content));
}}
/>
);
}
// hooks/useDocuments.ts
export function useDocuments(parentId?: string) {
const documents = useQuery('documents:list', {
parentDocument: parentId,
isArchived: false,
});
const documentsWithChildren = useMemo(() => {
return documents?.map(doc => ({
...doc,
children: documents.filter(
child => child.parentDocument === doc._id
),
}));
}, [documents]);
return documentsWithChildren;
}
// components/DocumentTree.tsx
export function DocumentTree({
documents,
level = 0
}: {
documents: Document[];
level?: number;
}) {
return (
<div style={{ paddingLeft: level * 12 }}>
{documents.map(document => (
<div key={document._id}>
<DocumentItem document={document} />
{document.children?.length > 0 && (
<DocumentTree
documents={document.children}
level={level + 1}
/>
)}
</div>
))}
</div>
);
}
// hooks/useTrash.ts
export function useTrash() {
const convex = useConvex();
const moveToTrash = async (documentId: string) => {
await convex.mutation('documents:archive', {
id: documentId,
isArchived: true,
archivedAt: Date.now(),
});
};
const restore = async (documentId: string) => {
await convex.mutation('documents:archive', {
id: documentId,
isArchived: false,
archivedAt: null,
});
};
const deletePermanently = async (documentId: string) => {
await convex.mutation('documents:delete', {
id: documentId,
});
};
return {
moveToTrash,
restore,
deletePermanently,
};
}
// hooks/usePublish.ts
export function usePublish() {
const convex = useConvex();
const publishDocument = async (documentId: string) => {
await convex.mutation('documents:publish', {
id: documentId,
isPublished: true,
publishedAt: Date.now(),
});
};
const unpublishDocument = async (documentId: string) => {
await convex.mutation('documents:publish', {
id: documentId,
isPublished: false,
publishedAt: null,
});
};
return {
publishDocument,
unpublishDocument,
};
}
// hooks/useTheme.ts
export function useTheme() {
const [theme, setTheme] = useState<'light' | 'dark'>(
() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light'
);
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
document.documentElement.classList.toggle('dark');
};
useEffect(() => {
document.documentElement.classList.toggle(
'dark',
theme === 'dark'
);
}, []);
return {
theme,
toggleTheme,
};
}
- Node.js 18+
- Convex account
- Authentication provider account
- Clone the repository
git clone https://github.com/yourusername/notion-clone.git
cd notion-clone
- Install dependencies
pnpm install
- Set up environment variables
# .env.local
CONVEX_DEPLOYMENT=
NEXT_PUBLIC_CONVEX_URL=
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
- Initialize Convex
npx convex dev
- Start development server
pnpm dev
- Real-time updates batching
- Image optimization
- Code splitting
- Tree shaking
- Lazy loading
- Authentication
- Document permissions
- API route protection
- Input validation
- File upload restrictions
- Deploy Convex
npx convex deploy
- Configure Vercel
vercel env pull
- Deploy
vercel deploy
- Fork the repository
- Create your feature branch
- Commit your changes
- Push to the branch
- Create a Pull Request
This project is licensed under the MIT License.
Built with π by Awais Raza