| "use client"; |
|
|
| import { useEffect, useRef, useState, useCallback } from "react"; |
| import { useRouter } from "next/navigation"; |
| import * as Y from "yjs"; |
| import { |
| saveDocument, |
| getDocument, |
| addUnsyncedUpdate, |
| getUnsyncedUpdates, |
| clearUnsyncedUpdates, |
| type Document, |
| } from "@/lib/indexeddb"; |
| import { useTabSync } from "@/hooks/use-tab-sync"; |
| import { useWebSocketSync } from "@/hooks/use-websocket-sync"; |
| import { usePresence } from "@/hooks/use-presence"; |
| import { PresenceAvatars, EditingIndicator } from "@/components/presence-avatars"; |
| import { TestPanel } from "@/components/test-panel"; |
| import { |
| Save, |
| FileText, |
| Check, |
| Loader2, |
| Wifi, |
| WifiOff, |
| CloudOff, |
| Circle, |
| RefreshCw, |
| AlertCircle, |
| ArrowLeft, |
| Copy, |
| Share2, |
| } from "lucide-react"; |
| import { Button } from "@/components/ui/button"; |
|
|
| interface DocumentEditorWithSyncProps { |
| documentId: string; |
| } |
|
|
| const AUTO_SAVE_DELAY = 1000; |
|
|
| export function DocumentEditorWithSync({ documentId }: DocumentEditorWithSyncProps) { |
| const router = useRouter(); |
| |
| |
| const ydocRef = useRef<Y.Doc | null>(null); |
| const yTitleRef = useRef<Y.Text | null>(null); |
| const yContentRef = useRef<Y.Text | null>(null); |
| |
| |
| const [title, setTitle] = useState("Untitled Document"); |
| const [content, setContent] = useState(""); |
| const [isLoading, setIsLoading] = useState(true); |
| const [isDocReady, setIsDocReady] = useState(false); |
| const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle"); |
| const [lastSaved, setLastSaved] = useState<Date | null>(null); |
| const [unsyncedCount, setUnsyncedCount] = useState(0); |
| const [isOnline, setIsOnline] = useState(true); |
| const [simulatedOffline, setSimulatedOffline] = useState(false); |
| const [copied, setCopied] = useState(false); |
| const [debugLogs, setDebugLogs] = useState<string[]>([]); |
| const [showDebug, setShowDebug] = useState(false); |
| |
| const addDebugLog = useCallback((msg: string) => { |
| setDebugLogs(prev => [...prev.slice(-20), `${new Date().toISOString().slice(11,19)} ${msg}`]); |
| }, []); |
| |
| const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null); |
| const isInitializedRef = useRef(false); |
| const contentRef = useRef<HTMLTextAreaElement>(null); |
| const titleRef = useRef<HTMLInputElement>(null); |
|
|
| const effectiveOnline = isOnline && !simulatedOffline; |
|
|
| |
| useEffect(() => { |
| setIsOnline(navigator.onLine); |
| const handleOnline = () => setIsOnline(true); |
| const handleOffline = () => setIsOnline(false); |
| window.addEventListener("online", handleOnline); |
| window.addEventListener("offline", handleOffline); |
| return () => { |
| window.removeEventListener("online", handleOnline); |
| window.removeEventListener("offline", handleOffline); |
| }; |
| }, []); |
|
|
| |
| useEffect(() => { |
| const ydoc = new Y.Doc(); |
| ydocRef.current = ydoc; |
| |
| const yTitle = ydoc.getText("title"); |
| const yContent = ydoc.getText("content"); |
| yTitleRef.current = yTitle; |
| yContentRef.current = yContent; |
|
|
| |
| async function loadDocument() { |
| try { |
| const savedDoc = await getDocument(documentId); |
| |
| if (savedDoc?.yjsState) { |
| Y.applyUpdate(ydoc, savedDoc.yjsState, "load"); |
| setLastSaved(new Date(savedDoc.updatedAt)); |
| |
| |
| const unsyncedUpdates = await getUnsyncedUpdates(documentId); |
| for (const update of unsyncedUpdates) { |
| Y.applyUpdate(ydoc, update.update, "load"); |
| } |
| if (unsyncedUpdates.length > 0) { |
| await clearUnsyncedUpdates(documentId); |
| } |
| } else { |
| |
| yTitle.insert(0, "Untitled Document"); |
| } |
| |
| setTitle(yTitle.toString()); |
| setContent(yContent.toString()); |
| isInitializedRef.current = true; |
| setIsDocReady(true); |
| } catch (error) { |
| console.error("Failed to load document:", error); |
| yTitle.insert(0, "Untitled Document"); |
| isInitializedRef.current = true; |
| setIsDocReady(true); |
| } finally { |
| setIsLoading(false); |
| } |
| } |
| |
| loadDocument(); |
|
|
| |
| const titleObserver = () => { |
| const newTitle = yTitle.toString(); |
| console.log("[v0] Y.Title changed:", newTitle); |
| setTitle(newTitle); |
| addDebugLog(`Title: "${newTitle.slice(0,30)}"`); |
| }; |
| const contentObserver = () => { |
| const newContent = yContent.toString(); |
| console.log("[v0] Y.Content changed, length:", newContent.length); |
| setContent(newContent); |
| addDebugLog(`Content: ${newContent.length} chars`); |
| }; |
| |
| yTitle.observe(titleObserver); |
| yContent.observe(contentObserver); |
| addDebugLog("Observers set up"); |
| |
| |
| ydoc.on("update", async (update: Uint8Array, origin: unknown) => { |
| if (origin !== "load" && origin !== "tab-sync" && origin !== "websocket" && isInitializedRef.current) { |
| try { |
| await addUnsyncedUpdate(documentId, update); |
| const updates = await getUnsyncedUpdates(documentId); |
| setUnsyncedCount(updates.length); |
| } catch (error) { |
| console.error("Failed to track update:", error); |
| } |
| } |
| }); |
|
|
| return () => { |
| ydoc.destroy(); |
| if (saveTimeoutRef.current) { |
| clearTimeout(saveTimeoutRef.current); |
| } |
| }; |
| }, [documentId]); |
|
|
| |
| const { |
| isActive: isTabSyncActive, |
| connectedTabs, |
| tabId, |
| syncStatus, |
| requestReconciliation, |
| flushOfflineQueue, |
| } = useTabSync({ |
| ydoc: isDocReady ? ydocRef.current : null, |
| documentId, |
| enabled: true, |
| isOffline: !effectiveOnline, |
| }); |
|
|
| |
| const { |
| status: wsStatus, |
| connectedClients, |
| error: wsError, |
| reconnect: wsReconnect, |
| } = useWebSocketSync({ |
| ydoc: isDocReady ? ydocRef.current : null, |
| documentId, |
| enabled: effectiveOnline, |
| onDebugLog: addDebugLog, |
| }); |
|
|
| |
| const { |
| currentUser, |
| remoteUsers, |
| updateCursor, |
| updateSelection, |
| clearEditing, |
| } = usePresence({ |
| documentId, |
| enabled: isDocReady, |
| }); |
|
|
| |
| const save = useCallback(async () => { |
| if (!ydocRef.current || !yTitleRef.current) return; |
| |
| setSaveStatus("saving"); |
| try { |
| const state = Y.encodeStateAsUpdate(ydocRef.current); |
| const existingDoc = await getDocument(documentId); |
| |
| const doc: Document = { |
| id: documentId, |
| title: yTitleRef.current.toString(), |
| yjsState: state, |
| updatedAt: Date.now(), |
| createdAt: existingDoc?.createdAt || Date.now(), |
| }; |
| |
| await saveDocument(doc); |
| await clearUnsyncedUpdates(documentId); |
| setUnsyncedCount(0); |
| setLastSaved(new Date()); |
| setSaveStatus("saved"); |
| setTimeout(() => setSaveStatus("idle"), 2000); |
| } catch (error) { |
| console.error("Failed to save:", error); |
| setSaveStatus("idle"); |
| } |
| }, [documentId]); |
|
|
| |
| const scheduleSave = useCallback(() => { |
| if (saveTimeoutRef.current) { |
| clearTimeout(saveTimeoutRef.current); |
| } |
| saveTimeoutRef.current = setTimeout(save, AUTO_SAVE_DELAY); |
| }, [save]); |
|
|
| |
| const updateTitle = useCallback((newTitle: string) => { |
| if (!yTitleRef.current || !isInitializedRef.current) return; |
| const yTitle = yTitleRef.current; |
| ydocRef.current?.transact(() => { |
| yTitle.delete(0, yTitle.length); |
| yTitle.insert(0, newTitle); |
| }); |
| scheduleSave(); |
| }, [scheduleSave]); |
|
|
| const updateContent = useCallback((newContent: string, cursorPosition?: number) => { |
| if (!yContentRef.current || !isInitializedRef.current) return; |
| const yContent = yContentRef.current; |
| const currentContent = yContent.toString(); |
|
|
| if (cursorPosition !== undefined) { |
| const lengthDiff = newContent.length - currentContent.length; |
| if (lengthDiff === 1) { |
| const insertPos = cursorPosition - 1; |
| const char = newContent[insertPos]; |
| if (char !== undefined) { |
| yContent.insert(insertPos, char); |
| scheduleSave(); |
| return; |
| } |
| } else if (lengthDiff === -1) { |
| const deletePos = cursorPosition; |
| if (deletePos >= 0 && deletePos < currentContent.length) { |
| yContent.delete(deletePos, 1); |
| scheduleSave(); |
| return; |
| } |
| } |
| } |
|
|
| ydocRef.current?.transact(() => { |
| yContent.delete(0, yContent.length); |
| yContent.insert(0, newContent); |
| }); |
| scheduleSave(); |
| }, [scheduleSave]); |
|
|
| |
| const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
| updateTitle(e.target.value); |
| }; |
|
|
| const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { |
| updateContent(e.target.value, e.target.selectionStart); |
| }; |
|
|
| const handleTitleFocus = () => { |
| if (titleRef.current) updateCursor(titleRef.current.selectionStart, "title"); |
| }; |
|
|
| const handleTitleSelect = () => { |
| if (titleRef.current) { |
| updateSelection(titleRef.current.selectionStart, titleRef.current.selectionEnd, "title"); |
| } |
| }; |
|
|
| const handleContentFocus = () => { |
| if (contentRef.current) updateCursor(contentRef.current.selectionStart, "content"); |
| }; |
|
|
| const handleContentSelect = () => { |
| if (contentRef.current) { |
| updateSelection(contentRef.current.selectionStart, contentRef.current.selectionEnd, "content"); |
| } |
| }; |
|
|
| const handleBlur = () => clearEditing(); |
|
|
| const manualSave = () => { |
| if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); |
| save(); |
| }; |
|
|
| const copyShareLink = async () => { |
| const url = window.location.href; |
| await navigator.clipboard.writeText(url); |
| setCopied(true); |
| setTimeout(() => setCopied(false), 2000); |
| }; |
|
|
| |
| useEffect(() => { |
| const handleKeyDown = (e: KeyboardEvent) => { |
| if ((e.metaKey || e.ctrlKey) && e.key === "s") { |
| e.preventDefault(); |
| manualSave(); |
| } |
| }; |
| window.addEventListener("keydown", handleKeyDown); |
| return () => window.removeEventListener("keydown", handleKeyDown); |
| }, []); |
|
|
| |
| const handleSimulateOffline = useCallback(() => setSimulatedOffline(true), []); |
| const handleSimulateOnline = useCallback(() => { |
| setSimulatedOffline(false); |
| setTimeout(() => { |
| requestReconciliation(); |
| flushOfflineQueue(); |
| }, 100); |
| }, [requestReconciliation, flushOfflineQueue]); |
|
|
| const handleForceSync = useCallback(() => { |
| requestReconciliation(); |
| flushOfflineQueue(); |
| wsReconnect(); |
| }, [requestReconciliation, flushOfflineQueue, wsReconnect]); |
|
|
| const handleClearLocalData = useCallback(async () => { |
| const dbs = await indexedDB.databases(); |
| for (const db of dbs) { |
| if (db.name) indexedDB.deleteDatabase(db.name); |
| } |
| localStorage.clear(); |
| window.location.reload(); |
| }, []); |
|
|
| const handleDuplicateUpdate = useCallback(() => { |
| if (ydocRef.current) { |
| const state = Y.encodeStateAsUpdate(ydocRef.current); |
| Y.applyUpdate(ydocRef.current, state, "test-duplicate"); |
| } |
| }, []); |
|
|
| const getStateVector = useCallback(() => { |
| return ydocRef.current ? Y.encodeStateVector(ydocRef.current) : null; |
| }, []); |
|
|
| if (isLoading) { |
| return ( |
| <div className="flex items-center justify-center min-h-screen bg-background"> |
| <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| |
| const otherParticipants = |
| wsStatus === "connected" |
| ? Math.max(0, connectedClients - 1) |
| : connectedTabs; |
|
|
| const getSyncBadge = () => { |
| if (simulatedOffline) { |
| return ( |
| <div className="flex items-center gap-1.5 px-2 py-1 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded text-xs"> |
| <WifiOff className="h-3 w-3" /> |
| <span>Simulated Offline</span> |
| </div> |
| ); |
| } |
|
|
| if (wsStatus === "connected" || connectedTabs > 0) { |
| return ( |
| <div className="flex items-center gap-1.5 px-2 py-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 rounded text-xs"> |
| <Circle className="h-2 w-2 bg-green-500 rounded-full animate-pulse" /> |
| <span>Live</span> |
| </div> |
| ); |
| } |
|
|
| if (wsStatus === "connecting" || syncStatus.state === "syncing") { |
| return ( |
| <div className="flex items-center gap-1.5 px-2 py-1 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded text-xs"> |
| <RefreshCw className="h-3 w-3 animate-spin" /> |
| <span>Connecting...</span> |
| </div> |
| ); |
| } |
|
|
| if (wsStatus === "error") { |
| return ( |
| <div className="flex items-center gap-1.5 px-2 py-1 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 rounded text-xs"> |
| <AlertCircle className="h-3 w-3" /> |
| <span>Error</span> |
| </div> |
| ); |
| } |
|
|
| return null; |
| }; |
|
|
| return ( |
| <div className="min-h-screen bg-background flex flex-col"> |
| {/* Header */} |
| <header className="border-b border-border bg-card sticky top-0 z-10"> |
| <div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between gap-4"> |
| <div className="flex items-center gap-3 flex-1 min-w-0"> |
| <Button |
| variant="ghost" |
| size="sm" |
| className="shrink-0" |
| onClick={() => router.push("/")} |
| > |
| <ArrowLeft className="h-4 w-4" /> |
| </Button> |
| <FileText className="h-5 w-5 text-muted-foreground shrink-0" /> |
| <div className="relative flex-1 min-w-0"> |
| <input |
| ref={titleRef} |
| type="text" |
| value={title} |
| onChange={handleTitleChange} |
| onFocus={handleTitleFocus} |
| onSelect={handleTitleSelect} |
| onBlur={handleBlur} |
| className="w-full bg-transparent text-lg font-medium text-foreground border-none outline-none focus:ring-0" |
| placeholder="Document title..." |
| /> |
| <EditingIndicator remoteUsers={remoteUsers} field="title" /> |
| </div> |
| </div> |
| |
| <div className="flex items-center gap-2 shrink-0"> |
| <PresenceAvatars currentUser={currentUser} remoteUsers={remoteUsers} /> |
| |
| <div className="hidden sm:block">{getSyncBadge()}</div> |
| |
| {otherParticipants > 0 && ( |
| <div className="hidden sm:flex items-center gap-1.5 px-2 py-1 bg-muted rounded text-xs text-muted-foreground"> |
| <Users className="h-3 w-3" /> |
| <span>{otherParticipants + 1}</span> |
| </div> |
| )} |
| |
| {(unsyncedCount > 0 || syncStatus.pendingUpdates > 0) && ( |
| <div className="hidden sm:flex items-center gap-1.5 px-2 py-1 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded text-xs"> |
| <CloudOff className="h-3 w-3" /> |
| <span>{unsyncedCount || syncStatus.pendingUpdates} pending</span> |
| </div> |
| )} |
| |
| <div className="flex items-center gap-2 text-sm text-muted-foreground"> |
| {saveStatus === "saving" && <Loader2 className="h-4 w-4 animate-spin" />} |
| {saveStatus === "saved" && <Check className="h-4 w-4 text-green-500" />} |
| </div> |
| |
| <Button variant="outline" size="sm" onClick={copyShareLink} className="gap-1.5"> |
| {copied ? <Check className="h-3 w-3" /> : <Share2 className="h-3 w-3" />} |
| <span className="hidden sm:inline">{copied ? "Copied!" : "Share"}</span> |
| </Button> |
| |
| <Button size="sm" onClick={manualSave} className="gap-1.5"> |
| <Save className="h-3 w-3" /> |
| <span className="hidden sm:inline">Save</span> |
| </Button> |
| </div> |
| </div> |
| </header> |
| |
| {/* Sync Banner */} |
| {effectiveOnline && otherParticipants > 0 && ( |
| <div className="bg-green-50 dark:bg-green-900/20 border-b border-green-200 dark:border-green-800"> |
| <div className="max-w-4xl mx-auto px-4 py-2 flex items-center justify-center gap-2 text-sm text-green-700 dark:text-green-400"> |
| <Circle className="h-2 w-2 bg-green-500 rounded-full animate-pulse" /> |
| <span> |
| Real-time sync with {otherParticipants + 1} users |
| </span> |
| </div> |
| </div> |
| )} |
| |
| {/* Offline Banner */} |
| {!effectiveOnline && ( |
| <div className="bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800"> |
| <div className="max-w-4xl mx-auto px-4 py-2 flex items-center justify-center gap-2 text-sm text-amber-700 dark:text-amber-400"> |
| <WifiOff className="h-4 w-4" /> |
| <span>Offline mode. Changes saved locally.</span> |
| </div> |
| </div> |
| )} |
| |
| {/* Editor */} |
| <main className="flex-1 max-w-4xl mx-auto w-full px-4 py-8 relative"> |
| <EditingIndicator remoteUsers={remoteUsers} field="content" /> |
| <textarea |
| ref={contentRef} |
| value={content} |
| onChange={handleContentChange} |
| onFocus={handleContentFocus} |
| onSelect={handleContentSelect} |
| onBlur={handleBlur} |
| className="w-full h-full min-h-[calc(100vh-250px)] bg-transparent text-foreground text-base leading-relaxed resize-none border-none outline-none focus:ring-0" |
| placeholder="Start writing your document..." |
| spellCheck |
| /> |
| </main> |
| |
| {/* Footer */} |
| <footer className="border-t border-border bg-card"> |
| <div className="max-w-4xl mx-auto px-4 py-2 flex items-center justify-between text-sm text-muted-foreground"> |
| <div className="flex items-center gap-4"> |
| <span>{content.length} characters</span> |
| <span>{content.split(/\s+/).filter(Boolean).length} words</span> |
| </div> |
| <div className="flex items-center gap-4"> |
| <span className="text-xs font-mono truncate max-w-[200px]" title={documentId}> |
| {documentId} |
| </span> |
| <span className="text-xs">User: {currentUser.name}</span> |
| </div> |
| </div> |
| </footer> |
| |
| {/* Debug Panel */} |
| <div className="fixed bottom-4 left-4 z-50"> |
| <Button size="sm" variant="outline" onClick={() => setShowDebug(!showDebug)}> |
| Debug |
| </Button> |
| {showDebug && ( |
| <div className="absolute bottom-10 left-0 w-80 max-h-60 overflow-auto bg-black text-green-400 text-xs font-mono p-2 rounded shadow-lg"> |
| <div>WS: {wsStatus} | Clients: {connectedClients}</div> |
| <div>Doc ready: {isDocReady ? "yes" : "no"}</div> |
| <div>Title: {title.slice(0,30)}</div> |
| <div>Content len: {content.length}</div> |
| <hr className="my-1 border-green-800" /> |
| {debugLogs.map((log, i) => <div key={i}>{log}</div>)} |
| </div> |
| )} |
| </div> |
| |
| {/* Test Panel */} |
| <TestPanel |
| documentId={documentId} |
| isOnline={effectiveOnline} |
| connectedTabs={otherParticipants} |
| unsyncedCount={unsyncedCount} |
| syncStatus={syncStatus} |
| onSimulateOffline={handleSimulateOffline} |
| onSimulateOnline={handleSimulateOnline} |
| onForceSync={handleForceSync} |
| onClearLocalData={handleClearLocalData} |
| onDuplicateUpdate={handleDuplicateUpdate} |
| getStateVector={getStateVector} |
| /> |
| </div> |
| ); |
| } |
|
|
| |
| function Users(props: React.SVGProps<SVGSVGElement>) { |
| return ( |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| width="24" |
| height="24" |
| viewBox="0 0 24 24" |
| fill="none" |
| stroke="currentColor" |
| strokeWidth="2" |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| {...props} |
| > |
| <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /> |
| <circle cx="9" cy="7" r="4" /> |
| <path d="M22 21v-2a4 4 0 0 0-3-3.87" /> |
| <path d="M16 3.13a4 4 0 0 1 0 7.75" /> |
| </svg> |
| ); |
| } |
|
|