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.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.

+ +
+ + setVersionLabel(e.target.value)} + placeholder="e.g., Before major changes" + maxLength={100} + /> + +
+ + +
+
+
+
+ )} +
+
+ ); +} + +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 */} +
+ + + + + + + +
+
+ +

DocNest

+
+ +

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 */} +
+ + + +
+

Ready to collaborate?

+

Join thousands of teams creating together.

+ +
+ +
+ +

Built with Yjs, React, and Go

+
+
+
+ ); +} + +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;