From 0ec58ca86671c38d0109b3f82b250de87f2678ed Mon Sep 17 00:00:00 2001
From: M1ngdaXie <156019134+M1ngdaXie@users.noreply.github.com>
Date: Mon, 19 Jan 2026 16:14:56 -0800
Subject: [PATCH] feat: Add landing page and version history functionality
- Implemented ConditionalHome component to show LandingPage for guests and Home for authenticated users.
- Created LandingPage with login options for Google and GitHub.
- Added VersionHistoryPanel component for managing document versions.
- Integrated version history functionality into EditorPage.
- Updated API client to handle FormData correctly.
- Added styles for LandingPage and VersionHistoryPanel.
- Created version management API methods for creating, listing, restoring, and fetching document versions.
---
backend/internal/handlers/version.go | 258 ++++++++++++
backend/internal/store/version.go | 2 +-
frontend/src/App.tsx | 27 +-
frontend/src/api/client.ts | 10 +-
frontend/src/api/document.ts | 83 ++++
.../VersionHistory/VersionHistoryPanel.css | 374 +++++++++++++++++
.../VersionHistory/VersionHistoryPanel.tsx | 298 ++++++++++++++
frontend/src/pages/EditorPage.tsx | 16 +
frontend/src/pages/LandingPage.css | 375 ++++++++++++++++++
frontend/src/pages/LandingPage.tsx | 147 +++++++
10 files changed, 1577 insertions(+), 13 deletions(-)
create mode 100644 backend/internal/handlers/version.go
create mode 100644 frontend/src/components/VersionHistory/VersionHistoryPanel.css
create mode 100644 frontend/src/components/VersionHistory/VersionHistoryPanel.tsx
create mode 100644 frontend/src/pages/LandingPage.css
create mode 100644 frontend/src/pages/LandingPage.tsx
diff --git a/backend/internal/handlers/version.go b/backend/internal/handlers/version.go
new file mode 100644
index 0000000..98cc2d0
--- /dev/null
+++ b/backend/internal/handlers/version.go
@@ -0,0 +1,258 @@
+package handlers
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+
+ "github.com/M1ngdaXie/realtime-collab/internal/auth"
+ "github.com/M1ngdaXie/realtime-collab/internal/models"
+ "github.com/M1ngdaXie/realtime-collab/internal/store"
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+)
+
+type VersionHandler struct {
+ store *store.PostgresStore
+}
+
+func NewVersionHandler(s *store.PostgresStore) *VersionHandler {
+ return &VersionHandler{store: s}
+}
+
+// CreateVersion creates a manual snapshot (requires edit permission)
+func (h *VersionHandler) CreateVersion(c *gin.Context) {
+ userID := auth.GetUserFromContext(c)
+ if userID == nil {
+ respondUnauthorized(c, "Authentication required")
+ return
+ }
+
+ documentID, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ respondBadRequest(c, "Invalid document ID")
+ return
+ }
+
+ // Check edit permission (only editors can create versions)
+ canEdit, err := h.store.CanEditDocument(c.Request.Context(), documentID, *userID)
+ if err != nil {
+ respondInternalError(c, "Failed to check permissions", err)
+ return
+ }
+ if !canEdit {
+ respondForbidden(c, "Edit permission required to create versions")
+ return
+ }
+
+ // Parse multipart form data
+ if err := c.Request.ParseMultipartForm(10 << 20); err != nil { // 10MB limit
+ respondBadRequest(c, "Invalid multipart form")
+ return
+ }
+
+ // Get version label (optional)
+ versionLabel := c.PostForm("version_label")
+ var labelPtr *string
+ if versionLabel != "" {
+ labelPtr = &versionLabel
+ }
+
+ // Get text preview (required)
+ textPreview := c.PostForm("text_preview")
+ if textPreview == "" {
+ respondBadRequest(c, "text_preview is required")
+ return
+ }
+
+ // Get Yjs snapshot binary (required)
+ file, _, err := c.Request.FormFile("yjs_snapshot")
+ if err != nil {
+ respondBadRequest(c, "yjs_snapshot file is required")
+ return
+ }
+ defer file.Close()
+
+ snapshotData, err := io.ReadAll(file)
+ if err != nil || len(snapshotData) == 0 {
+ respondBadRequest(c, "Failed to read snapshot data")
+ return
+ }
+
+ // Validate snapshot size (max 10MB)
+ if len(snapshotData) > 10*1024*1024 {
+ respondBadRequest(c, "Snapshot too large (max 10MB)")
+ return
+ }
+
+ // Create version (manual snapshot)
+ version, err := h.store.CreateDocumentVersion(
+ c.Request.Context(),
+ documentID,
+ *userID,
+ snapshotData,
+ &textPreview,
+ labelPtr,
+ false, // is_auto_generated = false
+ )
+ if err != nil {
+ respondInternalError(c, "Failed to create version", err)
+ return
+ }
+
+ c.JSON(http.StatusCreated, version)
+}
+
+// ListVersions returns paginated version history (requires view permission)
+func (h *VersionHandler) ListVersions(c *gin.Context) {
+ userID := auth.GetUserFromContext(c)
+ if userID == nil {
+ respondUnauthorized(c, "Authentication required")
+ return
+ }
+
+ documentID, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ respondBadRequest(c, "Invalid document ID")
+ return
+ }
+
+ // Check view permission
+ canView, err := h.store.CanViewDocument(c.Request.Context(), documentID, *userID)
+ if err != nil {
+ respondInternalError(c, "Failed to check permissions", err)
+ return
+ }
+ if !canView {
+ respondForbidden(c, "View permission required")
+ return
+ }
+
+ // Parse pagination params
+ limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
+ offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
+
+ if limit > 100 {
+ limit = 100 // Max limit
+ }
+
+ versions, total, err := h.store.ListDocumentVersions(c.Request.Context(), documentID, limit, offset)
+ if err != nil {
+ respondInternalError(c, "Failed to list versions", err)
+ return
+ }
+
+ c.JSON(http.StatusOK, models.VersionListResponse{
+ Versions: versions,
+ Total: total,
+ })
+}
+
+// GetVersionSnapshot returns the Yjs binary snapshot for a specific version
+func (h *VersionHandler) GetVersionSnapshot(c *gin.Context) {
+ userID := auth.GetUserFromContext(c)
+ if userID == nil {
+ respondUnauthorized(c, "Authentication required")
+ return
+ }
+
+ versionID, err := uuid.Parse(c.Param("versionId"))
+ if err != nil {
+ respondBadRequest(c, "Invalid version ID")
+ return
+ }
+
+ // Get version
+ version, err := h.store.GetDocumentVersion(c.Request.Context(), versionID)
+ if err != nil {
+ respondNotFound(c, "version")
+ return
+ }
+
+ // Check permission on parent document
+ canView, err := h.store.CanViewDocument(c.Request.Context(), version.DocumentID, *userID)
+ if err != nil {
+ respondInternalError(c, "Failed to check permissions", err)
+ return
+ }
+ if !canView {
+ respondForbidden(c, "View permission required")
+ return
+ }
+
+ // Return binary snapshot
+ c.Data(http.StatusOK, "application/octet-stream", version.YjsSnapshot)
+}
+
+// RestoreVersion creates a new version from an old snapshot (non-destructive)
+func (h *VersionHandler) RestoreVersion(c *gin.Context) {
+ userID := auth.GetUserFromContext(c)
+ if userID == nil {
+ respondUnauthorized(c, "Authentication required")
+ return
+ }
+
+ documentID, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ respondBadRequest(c, "Invalid document ID")
+ return
+ }
+
+ // Check edit permission
+ canEdit, err := h.store.CanEditDocument(c.Request.Context(), documentID, *userID)
+ if err != nil {
+ respondInternalError(c, "Failed to check permissions", err)
+ return
+ }
+ if !canEdit {
+ respondForbidden(c, "Edit permission required to restore versions")
+ return
+ }
+
+ var req models.RestoreVersionRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ respondWithValidationError(c, err)
+ return
+ }
+
+ // Get the version to restore
+ oldVersion, err := h.store.GetDocumentVersion(c.Request.Context(), req.VersionID)
+ if err != nil {
+ respondNotFound(c, "version")
+ return
+ }
+
+ // Verify version belongs to this document
+ if oldVersion.DocumentID != documentID {
+ respondBadRequest(c, "Version does not belong to this document")
+ return
+ }
+
+ // Update current document state with old snapshot
+ if err := h.store.UpdateDocumentState(documentID, oldVersion.YjsSnapshot); err != nil {
+ respondInternalError(c, "Failed to restore document state", err)
+ return
+ }
+
+ // Create new version entry marking it as a restore
+ restoreLabel := fmt.Sprintf("Restored from version %d", oldVersion.VersionNumber)
+ newVersion, err := h.store.CreateDocumentVersion(
+ c.Request.Context(),
+ documentID,
+ *userID,
+ oldVersion.YjsSnapshot,
+ oldVersion.TextPreview,
+ &restoreLabel,
+ false, // Manual restore
+ )
+ if err != nil {
+ respondInternalError(c, "Failed to create restore version", err)
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "message": "Version restored successfully",
+ "new_version": newVersion,
+ })
+}
diff --git a/backend/internal/store/version.go b/backend/internal/store/version.go
index 8f446ec..60f1e9b 100644
--- a/backend/internal/store/version.go
+++ b/backend/internal/store/version.go
@@ -142,7 +142,7 @@ func (s *PostgresStore) ListDocumentVersions(
// GetDocumentVersion retrieves a specific version with full snapshot
func (s *PostgresStore) GetDocumentVersion(ctx context.Context, versionID uuid.UUID) (*models.DocumentVersion, error) {
query := `
- SELECT id, document_id, yjs_snapshot, text_preview, ve rsion_number,
+ SELECT id, document_id, yjs_snapshot, text_preview, version_number,
created_by, version_label, is_auto_generated, created_at
FROM document_versions
WHERE id = $1
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 28edb6e..edc5be9 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,11 +1,27 @@
import { BrowserRouter, Route, Routes } from "react-router-dom";
-import { AuthProvider } from "./contexts/AuthContext";
+import { AuthProvider, useAuth } from "./contexts/AuthContext";
import ProtectedRoute from "./components/ProtectedRoute";
import LoginPage from "./pages/LoginPage";
import AuthCallback from "./pages/AuthCallback";
import EditorPage from "./pages/EditorPage.tsx";
import Home from "./pages/Home.tsx";
import KanbanPage from "./pages/KanbanPage.tsx";
+import LandingPage from "./pages/LandingPage.tsx";
+
+// Conditional component that shows LandingPage for guests, Home for authenticated users
+function ConditionalHome() {
+ const { user, loading } = useAuth();
+
+ if (loading) {
+ return
Loading...
;
+ }
+
+ if (!user) {
+ return ;
+ }
+
+ return ;
+}
function App() {
return (
@@ -14,14 +30,7 @@ function App() {
} />
} />
-
-
-
- }
- />
+ } />
{
const token = localStorage.getItem('auth_token');
- const headers: Record = {
- 'Content-Type': 'application/json',
- };
+ const headers: Record = {};
+
+ // Only set Content-Type for non-FormData requests
+ // FormData needs browser to auto-set multipart/form-data with boundary
+ if (!(options?.body instanceof FormData)) {
+ headers['Content-Type'] = 'application/json';
+ }
// Merge existing headers if provided
if (options?.headers) {
diff --git a/frontend/src/api/document.ts b/frontend/src/api/document.ts
index 6851ce1..3714bc6 100644
--- a/frontend/src/api/document.ts
+++ b/frontend/src/api/document.ts
@@ -83,4 +83,87 @@ export const documentsApi = {
if (!response.ok) throw new Error("Failed to fetch document permission");
return response.json();
},
+};
+
+// Version History Types
+export type DocumentVersion = {
+ id: string;
+ document_id: string;
+ text_preview: string | null;
+ version_number: number;
+ created_by: string | null;
+ version_label: string | null;
+ is_auto_generated: boolean;
+ created_at: string;
+ author?: {
+ id: string;
+ email: string;
+ name: string;
+ avatar_url?: string;
+ };
+};
+
+export type VersionListResponse = {
+ versions: DocumentVersion[];
+ total: number;
+};
+
+// Version History API
+export const versionsApi = {
+ // Create manual snapshot
+ create: async (
+ documentId: string,
+ yjsSnapshot: Uint8Array,
+ textPreview: string,
+ versionLabel?: string
+ ): Promise => {
+ const formData = new FormData();
+ // Create a copy of the buffer to ensure compatibility
+ const buffer = new ArrayBuffer(yjsSnapshot.byteLength);
+ new Uint8Array(buffer).set(yjsSnapshot);
+ formData.append('yjs_snapshot', new Blob([buffer]));
+ formData.append('text_preview', textPreview);
+ if (versionLabel) {
+ formData.append('version_label', versionLabel);
+ }
+ const response = await authFetch(`${API_BASE_URL}/documents/${documentId}/versions`, {
+ method: 'POST',
+ body: formData,
+ });
+ if (!response.ok) throw new Error('Failed to create version');
+ return response.json();
+ },
+
+ // List versions (paginated)
+ list: async (
+ documentId: string,
+ limit: number = 50,
+ offset: number = 0
+ ): Promise => {
+ const response = await authFetch(
+ `${API_BASE_URL}/documents/${documentId}/versions?limit=${limit}&offset=${offset}`
+ );
+ if (!response.ok) throw new Error('Failed to fetch versions');
+ return response.json();
+ },
+
+ // Get version snapshot (binary)
+ getSnapshot: async (documentId: string, versionId: string): Promise => {
+ const response = await authFetch(
+ `${API_BASE_URL}/documents/${documentId}/versions/${versionId}/snapshot`
+ );
+ if (!response.ok) throw new Error('Failed to fetch snapshot');
+ return new Uint8Array(await response.arrayBuffer());
+ },
+
+ // Restore version
+ restore: async (documentId: string, versionId: string): Promise<{ message: string }> => {
+ const response = await authFetch(`${API_BASE_URL}/documents/${documentId}/restore`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ version_id: versionId }),
+ });
+ if (!response.ok) throw new Error('Failed to restore version');
+ return response.json();
+ },
};
\ No newline at end of file
diff --git a/frontend/src/components/VersionHistory/VersionHistoryPanel.css b/frontend/src/components/VersionHistory/VersionHistoryPanel.css
new file mode 100644
index 0000000..ccd7aa6
--- /dev/null
+++ b/frontend/src/components/VersionHistory/VersionHistoryPanel.css
@@ -0,0 +1,374 @@
+.version-panel-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.3);
+ z-index: 1000;
+ animation: fadeIn 0.2s ease;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes slideIn {
+ from {
+ transform: translateX(100%);
+ }
+ to {
+ transform: translateX(0);
+ }
+}
+
+.version-panel {
+ position: fixed;
+ right: 0;
+ top: 0;
+ width: 400px;
+ height: 100vh;
+ background: white;
+ box-shadow: -4px 0 20px rgba(0, 0, 0, 0.15);
+ display: flex;
+ flex-direction: column;
+ animation: slideIn 0.2s ease;
+}
+
+.version-panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 20px 24px;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.version-panel-header h2 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ color: #1a202c;
+}
+
+.close-button {
+ background: none;
+ border: none;
+ font-size: 28px;
+ color: #718096;
+ cursor: pointer;
+ padding: 0;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 6px;
+ transition: all 0.2s ease;
+}
+
+.close-button:hover {
+ background: #f7fafc;
+ color: #2d3748;
+}
+
+.version-panel-actions {
+ padding: 16px 24px;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.create-version-btn {
+ width: 100%;
+ padding: 10px 16px;
+ background: #667eea;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.create-version-btn:hover {
+ background: #5568d3;
+}
+
+.version-panel-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px 24px;
+}
+
+.loading-state,
+.empty-state {
+ text-align: center;
+ padding: 40px 20px;
+ color: #718096;
+}
+
+.empty-state p {
+ margin: 0 0 8px 0;
+ font-size: 15px;
+}
+
+.empty-state small {
+ font-size: 13px;
+ color: #a0aec0;
+}
+
+.message {
+ padding: 12px 16px;
+ border-radius: 6px;
+ margin-bottom: 16px;
+ font-size: 14px;
+}
+
+.message.error {
+ background: #fff5f5;
+ color: #c53030;
+ border: 1px solid #feb2b2;
+}
+
+.version-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.version-item {
+ background: #f7fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ padding: 14px;
+ transition: all 0.2s ease;
+}
+
+.version-item:hover {
+ border-color: #cbd5e0;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+}
+
+.version-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+ flex-wrap: wrap;
+}
+
+.version-number {
+ font-weight: 600;
+ font-size: 14px;
+ color: #2d3748;
+}
+
+.auto-badge {
+ background: #e8f4fd;
+ color: #3182ce;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.version-label {
+ color: #667eea;
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.version-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 13px;
+ color: #718096;
+ margin-bottom: 8px;
+}
+
+.version-author {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.author-avatar {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.version-time {
+ color: #a0aec0;
+}
+
+.version-preview {
+ font-size: 13px;
+ color: #4a5568;
+ line-height: 1.5;
+ margin-bottom: 12px;
+ padding: 8px;
+ background: white;
+ border-radius: 4px;
+ border: 1px solid #edf2f7;
+ max-height: 60px;
+ overflow: hidden;
+}
+
+.restore-btn {
+ width: 100%;
+ padding: 8px 12px;
+ background: white;
+ color: #667eea;
+ border: 1px solid #667eea;
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.restore-btn:hover:not(:disabled) {
+ background: #667eea;
+ color: white;
+}
+
+.restore-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.pagination {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 0;
+ margin-top: 16px;
+ border-top: 1px solid #e2e8f0;
+}
+
+.pagination button {
+ padding: 8px 16px;
+ background: #f7fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ font-size: 13px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.pagination button:hover:not(:disabled) {
+ background: #edf2f7;
+}
+
+.pagination button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.pagination span {
+ font-size: 13px;
+ color: #718096;
+}
+
+/* Create Version Modal */
+.create-modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 2000;
+}
+
+.create-modal {
+ background: white;
+ border-radius: 12px;
+ padding: 24px;
+ width: 90%;
+ max-width: 400px;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+}
+
+.create-modal h3 {
+ margin: 0 0 8px 0;
+ font-size: 18px;
+ color: #1a202c;
+}
+
+.create-modal p {
+ margin: 0 0 20px 0;
+ font-size: 14px;
+ color: #718096;
+}
+
+.create-modal label {
+ display: block;
+ font-size: 14px;
+ font-weight: 500;
+ color: #2d3748;
+ margin-bottom: 8px;
+}
+
+.create-modal input {
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ font-size: 14px;
+ margin-bottom: 20px;
+ box-sizing: border-box;
+}
+
+.create-modal input:focus {
+ outline: none;
+ border-color: #667eea;
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+.modal-buttons {
+ display: flex;
+ gap: 12px;
+ justify-content: flex-end;
+}
+
+.modal-buttons button {
+ padding: 10px 20px;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.modal-buttons button:not(.primary) {
+ background: #f7fafc;
+ color: #4a5568;
+ border: 1px solid #e2e8f0;
+}
+
+.modal-buttons button:not(.primary):hover {
+ background: #edf2f7;
+}
+
+.modal-buttons button.primary {
+ background: #667eea;
+ color: white;
+ border: none;
+}
+
+.modal-buttons button.primary:hover:not(:disabled) {
+ background: #5568d3;
+}
+
+.modal-buttons button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
diff --git a/frontend/src/components/VersionHistory/VersionHistoryPanel.tsx b/frontend/src/components/VersionHistory/VersionHistoryPanel.tsx
new file mode 100644
index 0000000..433ba5b
--- /dev/null
+++ b/frontend/src/components/VersionHistory/VersionHistoryPanel.tsx
@@ -0,0 +1,298 @@
+import { useState, useEffect } from 'react';
+import { versionsApi, type DocumentVersion } from '../../api/document';
+import * as Y from 'yjs';
+import './VersionHistoryPanel.css';
+
+interface VersionHistoryPanelProps {
+ documentId: string;
+ ydoc: Y.Doc | null;
+ canEdit: boolean;
+ onClose: () => void;
+}
+
+function formatTimeAgo(dateString: string): string {
+ const date = new Date(dateString);
+ const now = new Date();
+ const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
+
+ if (seconds < 60) return 'just now';
+ if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`;
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`;
+ if (seconds < 604800) return `${Math.floor(seconds / 86400)} days ago`;
+
+ return date.toLocaleDateString();
+}
+
+function VersionHistoryPanel({ documentId, ydoc, canEdit, onClose }: VersionHistoryPanelProps) {
+ const [versions, setVersions] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [restoring, setRestoring] = useState(null);
+ const [offset, setOffset] = useState(0);
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [versionLabel, setVersionLabel] = useState('');
+ const [creating, setCreating] = useState(false);
+
+ const LIMIT = 20;
+
+ useEffect(() => {
+ loadVersions();
+ }, [documentId, offset]);
+
+ const loadVersions = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await versionsApi.list(documentId, LIMIT, offset);
+ setVersions(data.versions || []);
+ setTotal(data.total);
+ } catch (err) {
+ console.error('Failed to load versions:', err);
+ setError('Failed to load version history');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleRestore = async (version: DocumentVersion) => {
+ if (!canEdit) {
+ alert('You need edit permission to restore versions');
+ return;
+ }
+
+ if (!confirm(`Restore to version ${version.version_number}? This will create a new version from the restored content.`)) {
+ return;
+ }
+
+ setRestoring(version.id);
+ try {
+ await versionsApi.restore(documentId, version.id);
+
+ // Clear IndexedDB cache so restored state is used on reload
+ // (y-indexeddb uses documentId as the database name)
+ await new Promise((resolve, reject) => {
+ const request = indexedDB.deleteDatabase(documentId);
+ request.onsuccess = () => resolve();
+ request.onerror = () => reject(request.error);
+ });
+
+ alert('Version restored successfully! The page will reload to sync changes.');
+ window.location.reload();
+ } catch (err) {
+ console.error('Failed to restore version:', err);
+ alert('Failed to restore version');
+ } finally {
+ setRestoring(null);
+ }
+ };
+
+ const handleCreateVersion = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!ydoc) {
+ alert('Document not loaded');
+ return;
+ }
+
+ setCreating(true);
+ try {
+ // Get Yjs state
+ const yjsSnapshot = Y.encodeStateAsUpdate(ydoc);
+
+ // Extract text preview from document (use placeholder if empty)
+ const textPreview = extractTextFromYjs(ydoc) || '(empty document)';
+
+ await versionsApi.create(documentId, yjsSnapshot, textPreview, versionLabel || undefined);
+
+ setShowCreateModal(false);
+ setVersionLabel('');
+ await loadVersions();
+ alert('Version created successfully!');
+ } catch (err) {
+ console.error('Failed to create version:', err);
+ alert('Failed to create version');
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ const extractTextFromYjs = (doc: Y.Doc): string => {
+ try {
+ const xmlFragment = doc.getXmlFragment('default');
+ let text = '';
+
+ const extractText = (item: Y.XmlElement | Y.XmlText | Y.Item): void => {
+ if (item instanceof Y.XmlText) {
+ text += item.toString();
+ } else if (item instanceof Y.XmlElement) {
+ // Add newline for block elements
+ if (['paragraph', 'heading', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(item.nodeName)) {
+ if (text.length > 0) text += '\n';
+ }
+ item.toArray().forEach((child) => extractText(child as Y.XmlElement | Y.XmlText));
+ }
+ };
+
+ xmlFragment.toArray().forEach((item) => extractText(item as Y.XmlElement | Y.XmlText));
+ return text.trim();
+ } catch (err) {
+ console.error('Failed to extract text:', err);
+ return '';
+ }
+ };
+
+ const handleNextPage = () => {
+ if (offset + LIMIT < total) {
+ setOffset(offset + LIMIT);
+ }
+ };
+
+ const handlePrevPage = () => {
+ if (offset > 0) {
+ setOffset(Math.max(0, offset - LIMIT));
+ }
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
Version History
+
+
+
+ {canEdit && (
+
+
+
+ )}
+
+
+ {error &&
{error}
}
+
+ {loading ? (
+
Loading versions...
+ ) : versions.length === 0 ? (
+
+
No versions yet
+
Versions are created when you save manually or automatically over time
+
+ ) : (
+
+ {versions.map((version) => (
+
+
+ v{version.version_number}
+ {version.is_auto_generated && (
+ Auto
+ )}
+ {version.version_label && (
+ {version.version_label}
+ )}
+
+
+
+ {version.author ? (
+
+ {version.author.avatar_url && (
+

+ )}
+
{version.author.name || version.author.email}
+
+ ) : (
+
Unknown user
+ )}
+
{formatTimeAgo(version.created_at)}
+
+
+ {version.text_preview && (
+
+ {version.text_preview.length > 150
+ ? version.text_preview.substring(0, 150) + '...'
+ : version.text_preview}
+
+ )}
+
+ {canEdit && (
+
+ )}
+
+ ))}
+
+ )}
+
+ {total > LIMIT && (
+
+
+
+ {offset + 1}-{Math.min(offset + LIMIT, total)} of {total}
+
+
+
+ )}
+
+
+ {/* Create Version Modal */}
+ {showCreateModal && (
+
setShowCreateModal(false)}>
+
e.stopPropagation()}>
+
Save Version
+
Create a snapshot of the current document state.
+
+
+
+
+ )}
+
+
+ );
+}
+
+export default VersionHistoryPanel;
diff --git a/frontend/src/pages/EditorPage.tsx b/frontend/src/pages/EditorPage.tsx
index 54c348b..97b8ee6 100644
--- a/frontend/src/pages/EditorPage.tsx
+++ b/frontend/src/pages/EditorPage.tsx
@@ -4,6 +4,7 @@ import Editor from "../components/Editor/Editor.tsx";
import Navbar from "../components/Navbar.tsx";
import UserList from "../components/Presence/UserList.tsx";
import ShareModal from "../components/Share/ShareModal.tsx";
+import VersionHistoryPanel from "../components/VersionHistory/VersionHistoryPanel.tsx";
import { useYjsDocument } from "../hooks/useYjsDocument.ts";
const EditorPage = () => {
@@ -13,6 +14,7 @@ const EditorPage = () => {
const shareToken = searchParams.get('share') || undefined;
const { providers, synced, permission, role } = useYjsDocument(id!, shareToken);
const [showShareModal, setShowShareModal] = useState(false);
+ const [showVersionHistory, setShowVersionHistory] = useState(false);
if (!providers) {
return Connecting...
;
@@ -38,6 +40,11 @@ const EditorPage = () => {
Share
)}
+ {!shareToken && (
+
+ )}
@@ -58,6 +65,15 @@ const EditorPage = () => {
currentRole={role || undefined}
/>
)}
+
+ {showVersionHistory && (
+ setShowVersionHistory(false)}
+ />
+ )}
);
};
diff --git a/frontend/src/pages/LandingPage.css b/frontend/src/pages/LandingPage.css
new file mode 100644
index 0000000..3e2b489
--- /dev/null
+++ b/frontend/src/pages/LandingPage.css
@@ -0,0 +1,375 @@
+/* Landing Page Styles */
+
+.landing-page {
+ min-height: 100vh;
+ overflow-x: hidden;
+}
+
+/* ========================================
+ Hero Section
+ ======================================== */
+
+.landing-hero {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ overflow: hidden;
+ padding: 2rem;
+
+ /* Animated gradient background */
+ background: linear-gradient(
+ 135deg,
+ var(--pixel-purple-deep) 0%,
+ var(--pixel-purple-bright) 40%,
+ var(--pixel-pink-vibrant) 100%
+ );
+ background-size: 200% 200%;
+ animation: gradient-shift 12s ease infinite;
+}
+
+@keyframes gradient-shift {
+ 0%, 100% {
+ background-position: 0% 50%;
+ }
+ 50% {
+ background-position: 100% 50%;
+ }
+}
+
+.hero-content {
+ text-align: center;
+ z-index: 10;
+ max-width: 800px;
+}
+
+.hero-logo {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+ margin-bottom: 2rem;
+}
+
+.hero-brand {
+ font-size: 3rem;
+ font-weight: 800;
+ color: var(--pixel-white);
+ text-shadow:
+ 4px 4px 0 var(--pixel-shadow-dark),
+ -1px -1px 0 var(--pixel-shadow-dark),
+ 1px -1px 0 var(--pixel-shadow-dark),
+ -1px 1px 0 var(--pixel-shadow-dark);
+ margin: 0;
+ letter-spacing: -1px;
+}
+
+.hero-headline {
+ font-size: 2.5rem;
+ font-weight: 700;
+ color: var(--pixel-white);
+ margin: 0 0 1.5rem 0;
+ text-shadow: 2px 2px 0 var(--pixel-shadow-dark);
+ line-height: 1.2;
+}
+
+.hero-tagline {
+ font-size: 1.25rem;
+ color: var(--pixel-bg-light);
+ margin: 0 0 3rem 0;
+ line-height: 1.6;
+ opacity: 0.95;
+}
+
+.hero-login-buttons {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ align-items: center;
+}
+
+.hero-scroll-hint {
+ position: absolute;
+ bottom: 2rem;
+ left: 50%;
+ transform: translateX(-50%);
+ color: var(--pixel-white);
+ opacity: 0.6;
+ animation: bounce-hint 2s ease-in-out infinite;
+}
+
+@keyframes bounce-hint {
+ 0%, 100% {
+ transform: translateX(-50%) translateY(0);
+ }
+ 50% {
+ transform: translateX(-50%) translateY(10px);
+ }
+}
+
+/* ========================================
+ Login Buttons
+ ======================================== */
+
+.landing-login-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 1rem 2rem;
+ font-size: 1rem;
+ font-weight: 600;
+ border: 3px solid var(--pixel-outline);
+ cursor: pointer;
+ transition: transform 0.05s ease, box-shadow 0.05s ease;
+ min-width: 260px;
+}
+
+.landing-login-button.google {
+ background: var(--pixel-white);
+ color: var(--pixel-text-primary);
+ box-shadow: 4px 4px 0 var(--pixel-shadow-dark);
+}
+
+.landing-login-button.google:hover {
+ transform: translate(-2px, -2px);
+ box-shadow: 6px 6px 0 var(--pixel-shadow-dark);
+ background: var(--pixel-panel);
+}
+
+.landing-login-button.google:active {
+ transform: translate(2px, 2px);
+ box-shadow: 2px 2px 0 var(--pixel-shadow-dark);
+}
+
+.landing-login-button.github {
+ background: var(--pixel-bg-dark);
+ color: var(--pixel-white);
+ box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.5);
+}
+
+.landing-login-button.github:hover {
+ transform: translate(-2px, -2px);
+ box-shadow: 6px 6px 0 rgba(0, 0, 0, 0.5);
+ background: var(--pixel-bg-medium);
+}
+
+.landing-login-button.github:active {
+ transform: translate(2px, 2px);
+ box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.5);
+}
+
+.landing-login-button.large {
+ padding: 1.25rem 2.5rem;
+ font-size: 1.125rem;
+ min-width: 300px;
+}
+
+.oauth-icon {
+ flex-shrink: 0;
+}
+
+/* ========================================
+ Features Section
+ ======================================== */
+
+.landing-features {
+ padding: 6rem 2rem;
+ background: var(--pixel-bg-light);
+ position: relative;
+}
+
+.section-title {
+ text-align: center;
+ font-size: 2.5rem;
+ font-weight: 700;
+ color: var(--pixel-text-primary);
+ margin: 0 0 4rem 0;
+ text-shadow: 2px 2px 0 var(--pixel-white);
+}
+
+.features-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 2rem;
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+.feature-card {
+ background: var(--pixel-white);
+ padding: 2.5rem 2rem;
+ border: 3px solid var(--pixel-outline);
+ box-shadow:
+ 0 0 0 3px var(--pixel-outline),
+ 6px 6px 0 var(--pixel-shadow-dark),
+ 6px 6px 0 3px var(--pixel-outline);
+ text-align: center;
+ transition: transform 0.1s ease, box-shadow 0.1s ease;
+ opacity: 0;
+ animation: fade-in-up 0.6s ease forwards;
+}
+
+.feature-card:nth-child(1) { animation-delay: 0.1s; }
+.feature-card:nth-child(2) { animation-delay: 0.2s; }
+.feature-card:nth-child(3) { animation-delay: 0.3s; }
+.feature-card:nth-child(4) { animation-delay: 0.4s; }
+
+@keyframes fade-in-up {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.feature-card:hover {
+ transform: translate(-3px, -3px);
+ box-shadow:
+ 0 0 0 3px var(--pixel-outline),
+ 9px 9px 0 var(--pixel-shadow-dark),
+ 9px 9px 0 3px var(--pixel-outline);
+}
+
+.feature-icon {
+ margin-bottom: 1.5rem;
+}
+
+.feature-title {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: var(--pixel-text-primary);
+ margin: 0 0 0.75rem 0;
+}
+
+.feature-description {
+ font-size: 1rem;
+ color: var(--pixel-text-secondary);
+ margin: 0;
+ line-height: 1.5;
+}
+
+/* ========================================
+ Footer Section
+ ======================================== */
+
+.landing-footer {
+ padding: 6rem 2rem;
+ background: var(--pixel-bg-dark);
+ position: relative;
+ overflow: hidden;
+}
+
+.footer-content {
+ text-align: center;
+ max-width: 600px;
+ margin: 0 auto;
+ position: relative;
+ z-index: 10;
+}
+
+.footer-headline {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--pixel-white);
+ margin: 0 0 1rem 0;
+ text-shadow: 2px 2px 0 var(--pixel-shadow-dark);
+}
+
+.footer-tagline {
+ font-size: 1.125rem;
+ color: var(--pixel-bg-light);
+ margin: 0 0 2.5rem 0;
+ opacity: 0.9;
+}
+
+.footer-login-buttons {
+ display: flex;
+ justify-content: center;
+ margin-bottom: 3rem;
+}
+
+.footer-tech {
+ font-size: 0.875rem;
+ color: var(--pixel-text-muted);
+ margin: 0;
+}
+
+/* ========================================
+ Responsive Design
+ ======================================== */
+
+/* Tablet (768px+) */
+@media (min-width: 768px) {
+ .hero-brand {
+ font-size: 4rem;
+ }
+
+ .hero-headline {
+ font-size: 3rem;
+ }
+
+ .hero-tagline {
+ font-size: 1.5rem;
+ }
+
+ .hero-login-buttons {
+ flex-direction: row;
+ gap: 1.5rem;
+ }
+
+ .features-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .section-title {
+ font-size: 3rem;
+ }
+}
+
+/* Desktop (1024px+) */
+@media (min-width: 1024px) {
+ .hero-brand {
+ font-size: 4.5rem;
+ }
+
+ .hero-headline {
+ font-size: 3.5rem;
+ }
+
+ .features-grid {
+ grid-template-columns: repeat(4, 1fr);
+ }
+
+ .feature-card {
+ padding: 2rem 1.5rem;
+ }
+}
+
+/* Large Desktop (1280px+) */
+@media (min-width: 1280px) {
+ .hero-headline {
+ font-size: 4rem;
+ }
+}
+
+/* Reduced Motion */
+@media (prefers-reduced-motion: reduce) {
+ .landing-hero {
+ animation: none;
+ }
+
+ .hero-scroll-hint {
+ animation: none;
+ }
+
+ .feature-card {
+ animation: none;
+ opacity: 1;
+ }
+}
diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx
new file mode 100644
index 0000000..8c7f5e5
--- /dev/null
+++ b/frontend/src/pages/LandingPage.tsx
@@ -0,0 +1,147 @@
+import FloatingGem from '../components/PixelSprites/FloatingGem';
+import PixelIcon from '../components/PixelIcon/PixelIcon';
+import './LandingPage.css';
+
+const API_BASE_URL = import.meta.env.VITE_API_URL || "https://docnest-backend-mingda.fly.dev/api";
+
+function LandingPage() {
+ const handleGoogleLogin = () => {
+ window.location.href = `${API_BASE_URL}/auth/google`;
+ };
+
+ const handleGitHubLogin = () => {
+ window.location.href = `${API_BASE_URL}/auth/github`;
+ };
+
+ return (
+
+ {/* Hero Section */}
+
+
+
+
+
+
+
+
+
+
+
+
Create Together. In Real-Time.
+
+ Collaborative documents and Kanban boards that sync instantly.
+
+ Work with your team from anywhere, even offline.
+
+
+
+
+
+
+
+
+
+
+
+ {/* Features Section */}
+
+ Why DocNest?
+
+
+
+
+
+
+
+
+ {/* Footer CTA */}
+
+
+ );
+}
+
+function FeatureCard({ icon, title, description, color }: {
+ icon: string;
+ title: string;
+ description: string;
+ color: string;
+}) {
+ return (
+
+
+
{title}
+
{description}
+
+ );
+}
+
+function GoogleIcon() {
+ return (
+
+ );
+}
+
+function GitHubIcon() {
+ return (
+
+ );
+}
+
+export default LandingPage;