@@ -13,11 +13,34 @@ const CLOUDINARY_CONFIG = {
1313 uploadPreset : "gogo_app_upload" ,
1414 folder : "gogo_app"
1515} ;
16+ const FIREBASE_CONFIG = {
17+ apiKey : "AIzaSyCkRt_XnSiNTfraQ2OgYInwQq9H65jFdeE" ,
18+ authDomain : "gogo-app-e5311.firebaseapp.com" ,
19+ projectId : "gogo-app-e5311" ,
20+ storageBucket : "gogo-app-e5311.firebasestorage.app" ,
21+ messagingSenderId : "1044106911706" ,
22+ appId : "1:1044106911706:web:27a7c5fc0beadd530aeae3" ,
23+ measurementId : "G-TJN4QMWX77"
24+ } ;
1625const LONG_PRESS_MS = 750 ;
1726const LONG_PRESS_MOVE_TOLERANCE = 12 ;
1827const IMAGE_MAX_SIZE = 900 ;
1928const IMAGE_QUALITY = 0.72 ;
29+ const CLOUD_BACKUP_URL_KEY = `${ STORAGE_KEY } -cloud-backup-url` ;
30+ const CLOUD_BACKUP_TIME_KEY = `${ STORAGE_KEY } -cloud-backup-time` ;
31+ const CLOUD_BACKUP_HASH_KEY = `${ STORAGE_KEY } -cloud-backup-hash` ;
32+ const CLOUD_BACKUP_DELAY_MS = 30000 ;
33+ const CLOUD_BACKUP_MIN_INTERVAL_MS = 10 * 60 * 1000 ;
2034let storageWarningShown = false ;
35+ let cloudBackupTimer = null ;
36+ let cloudBackupInProgress = false ;
37+ let firebaseApp = null ;
38+ let firebaseAuth = null ;
39+ let firebaseDb = null ;
40+ let firebaseUser = null ;
41+ let cloudSyncTimer = null ;
42+ let cloudSyncInProgress = false ;
43+ let applyingRemoteState = false ;
2144
2245const defaultData = {
2346 "settings" : {
@@ -1185,6 +1208,10 @@ const els = {
11851208 restoreDataInput : document . querySelector ( "#restoreDataInput" ) ,
11861209 updateAppBtn : document . querySelector ( "#updateAppBtn" ) ,
11871210 resetDataBtn : document . querySelector ( "#resetDataBtn" ) ,
1211+ cloudBackupStatus : document . querySelector ( "#cloudBackupStatus" ) ,
1212+ copyCloudBackupLinkBtn : document . querySelector ( "#copyCloudBackupLinkBtn" ) ,
1213+ cloudBackupNowBtn : document . querySelector ( "#cloudBackupNowBtn" ) ,
1214+ restoreCloudBackupBtn : document . querySelector ( "#restoreCloudBackupBtn" ) ,
11881215 toolEditModal : document . querySelector ( "#toolEditModal" ) ,
11891216 closeToolEditModal : document . querySelector ( "#closeToolEditModal" ) ,
11901217 editToolName : document . querySelector ( "#editToolName" ) ,
@@ -1671,6 +1698,7 @@ function normalizeGroupItems(group, fallbackId = "sc") {
16711698function saveState ( ) {
16721699 try {
16731700 localStorage . setItem ( STORAGE_KEY , JSON . stringify ( state ) ) ;
1701+ scheduleCloudSync ( ) ;
16741702 } catch ( error ) {
16751703 console . error ( "Save failed" , error ) ;
16761704 if ( ! storageWarningShown ) {
@@ -2602,6 +2630,146 @@ function restoreFullBackup(payload) {
26022630 switchView ( "projects" ) ;
26032631}
26042632
2633+ function cloudDocRef ( ) {
2634+ if ( ! firebaseDb || ! firebaseUser ) return null ;
2635+ return firebaseDb . collection ( "users" ) . doc ( firebaseUser . uid ) . collection ( "workbench" ) . doc ( "state" ) ;
2636+ }
2637+
2638+ function updateCloudSyncStatus ( text ) {
2639+ if ( els . cloudBackupStatus ) els . cloudBackupStatus . textContent = text ;
2640+ }
2641+
2642+ function cloudStatePayload ( ) {
2643+ return {
2644+ type : "gogo-full-backup" ,
2645+ version : 1 ,
2646+ clientUpdatedAt : new Date ( ) . toISOString ( ) ,
2647+ state
2648+ } ;
2649+ }
2650+
2651+ function updateCloudAuthButtons ( ) {
2652+ els . copyCloudBackupLinkBtn ?. classList . toggle ( "hidden" , Boolean ( firebaseUser ) ) ;
2653+ els . restoreCloudBackupBtn ?. classList . toggle ( "hidden" , ! firebaseUser ) ;
2654+ els . cloudBackupNowBtn ?. classList . toggle ( "hidden" , ! firebaseUser ) ;
2655+ }
2656+
2657+ function scheduleCloudSync ( delay = CLOUD_BACKUP_DELAY_MS ) {
2658+ if ( applyingRemoteState || ! firebaseUser || ! firebaseDb ) return ;
2659+ window . clearTimeout ( cloudSyncTimer ) ;
2660+ cloudSyncTimer = window . setTimeout ( ( ) => syncStateToCloud ( false ) , delay ) ;
2661+ }
2662+
2663+ async function syncStateToCloud ( showAlert = true ) {
2664+ if ( ! firebaseUser || ! firebaseDb ) {
2665+ if ( showAlert ) alert ( "請先用 Google 登入。" ) ;
2666+ return ;
2667+ }
2668+ if ( cloudSyncInProgress ) return ;
2669+ cloudSyncInProgress = true ;
2670+ updateCloudSyncStatus ( "同步中..." ) ;
2671+ try {
2672+ await cloudDocRef ( ) . set ( {
2673+ ...cloudStatePayload ( ) ,
2674+ updatedAt : firebase . firestore . FieldValue . serverTimestamp ( )
2675+ } , { merge : true } ) ;
2676+ const syncedAt = new Date ( ) . toLocaleString ( "zh-TW" , { hour12 : false } ) ;
2677+ updateCloudSyncStatus ( `已登入:${ firebaseUser . email || "Google 帳號" } ,上次同步 ${ syncedAt } ` ) ;
2678+ localStorage . setItem ( CLOUD_BACKUP_TIME_KEY , new Date ( ) . toISOString ( ) ) ;
2679+ if ( showAlert ) alert ( "已同步到雲端。" ) ;
2680+ } catch ( error ) {
2681+ console . error ( "Cloud sync failed" , error ) ;
2682+ const message = error . message || "請稍後再試" ;
2683+ updateCloudSyncStatus ( `同步失敗:${ message } ` ) ;
2684+ if ( showAlert ) alert ( `同步失敗:${ message } ` ) ;
2685+ } finally {
2686+ cloudSyncInProgress = false ;
2687+ }
2688+ }
2689+
2690+ async function loadCloudStateAfterSignIn ( ) {
2691+ if ( ! firebaseUser || ! firebaseDb ) return ;
2692+ updateCloudSyncStatus ( `已登入:${ firebaseUser . email || "Google 帳號" } ,檢查雲端資料中...` ) ;
2693+ try {
2694+ const snapshot = await cloudDocRef ( ) . get ( ) ;
2695+ const remote = snapshot . exists ? snapshot . data ( ) : null ;
2696+ if ( remote ?. state ) {
2697+ const useRemote = confirm ( "找到雲端同步資料,要用雲端資料取代這台裝置目前資料嗎?\n\n按「取消」會保留本機資料,並上傳成新的雲端資料。" ) ;
2698+ if ( useRemote ) {
2699+ applyingRemoteState = true ;
2700+ state = normalizeState ( remote . state ) ;
2701+ localStorage . setItem ( STORAGE_KEY , JSON . stringify ( state ) ) ;
2702+ selectedProjectId = state . projects [ 0 ] ?. id || null ;
2703+ selectedPatternId = state . patterns [ 0 ] ?. id || null ;
2704+ selectedYarnId = state . yarns [ 0 ] ?. id || null ;
2705+ selectedPartId = state . patterns [ 0 ] ?. parts ?. [ 0 ] ?. id || null ;
2706+ selectedProjectIds . clear ( ) ;
2707+ selectedPatternIds . clear ( ) ;
2708+ selectedYarnIds . clear ( ) ;
2709+ activeProjectFolder = null ;
2710+ activePatternFolder = null ;
2711+ activeYarnFolder = null ;
2712+ applyingRemoteState = false ;
2713+ updateCloudSyncStatus ( `已載入雲端資料:${ firebaseUser . email || "Google 帳號" } ` ) ;
2714+ switchView ( "projects" ) ;
2715+ return ;
2716+ }
2717+ }
2718+ await syncStateToCloud ( false ) ;
2719+ } catch ( error ) {
2720+ console . error ( "Cloud load failed" , error ) ;
2721+ updateCloudSyncStatus ( `雲端資料讀取失敗:${ error . message || "請稍後再試" } ` ) ;
2722+ } finally {
2723+ applyingRemoteState = false ;
2724+ }
2725+ }
2726+
2727+ function initFirebaseSync ( ) {
2728+ if ( ! window . firebase ) {
2729+ updateCloudSyncStatus ( "Firebase 載入失敗,請確認網路後重新整理。" ) ;
2730+ updateCloudAuthButtons ( ) ;
2731+ return ;
2732+ }
2733+ try {
2734+ firebaseApp = firebase . apps ?. length ? firebase . app ( ) : firebase . initializeApp ( FIREBASE_CONFIG ) ;
2735+ firebaseAuth = firebase . auth ( ) ;
2736+ firebaseDb = firebase . firestore ( ) ;
2737+ updateCloudAuthButtons ( ) ;
2738+ firebaseAuth . onAuthStateChanged ( ( user ) => {
2739+ firebaseUser = user ;
2740+ updateCloudAuthButtons ( ) ;
2741+ window . clearTimeout ( cloudSyncTimer ) ;
2742+ if ( user ) loadCloudStateAfterSignIn ( ) ;
2743+ else updateCloudSyncStatus ( "尚未登入。登入後會自動同步完整資料。" ) ;
2744+ } ) ;
2745+ } catch ( error ) {
2746+ console . error ( "Firebase init failed" , error ) ;
2747+ updateCloudSyncStatus ( `Firebase 初始化失敗:${ error . message || "請稍後再試" } ` ) ;
2748+ }
2749+ }
2750+
2751+ async function signInWithGoogle ( ) {
2752+ if ( ! firebaseAuth ) {
2753+ alert ( "Firebase 尚未準備好,請重新整理後再試。" ) ;
2754+ return ;
2755+ }
2756+ const provider = new firebase . auth . GoogleAuthProvider ( ) ;
2757+ try {
2758+ await firebaseAuth . signInWithPopup ( provider ) ;
2759+ } catch ( error ) {
2760+ if ( error . code === "auth/popup-blocked" || error . code === "auth/operation-not-supported-in-this-environment" ) {
2761+ await firebaseAuth . signInWithRedirect ( provider ) ;
2762+ return ;
2763+ }
2764+ alert ( `登入失敗:${ error . message || "請稍後再試" } ` ) ;
2765+ }
2766+ }
2767+
2768+ async function signOutGoogle ( ) {
2769+ if ( ! firebaseAuth ) return ;
2770+ await firebaseAuth . signOut ( ) ;
2771+ }
2772+
26052773function patternCloudShareUrl ( dataUrl ) {
26062774 const url = new URL ( location . href ) ;
26072775 url . hash = "" ;
@@ -5475,6 +5643,9 @@ els.resetDataBtn.addEventListener("click", () => {
54755643els . backupDataBtn . addEventListener ( "click" , ( ) => downloadFullBackup ( ) ) ;
54765644els . restoreDataBtn . addEventListener ( "click" , ( ) => els . restoreDataInput . click ( ) ) ;
54775645els . updateAppBtn . addEventListener ( "click" , ( ) => forceAppUpdate ( ) ) ;
5646+ els . copyCloudBackupLinkBtn ?. addEventListener ( "click" , signInWithGoogle ) ;
5647+ els . cloudBackupNowBtn ?. addEventListener ( "click" , ( ) => syncStateToCloud ( true ) ) ;
5648+ els . restoreCloudBackupBtn ?. addEventListener ( "click" , signOutGoogle ) ;
54785649els . restoreDataInput . addEventListener ( "change" , async ( ) => {
54795650 const file = els . restoreDataInput . files ?. [ 0 ] ;
54805651 if ( ! file ) return ;
@@ -6077,6 +6248,7 @@ requestAnimationFrame(() => {
60776248 document . querySelector ( ".workspace" ) ?. scrollTo ( { top : 0 } ) ;
60786249} ) ;
60796250render ( ) ;
6251+ initFirebaseSync ( ) ;
60806252checkSharedPatternImportUrl ( ) ;
60816253checkSharedPatternFromUrl ( ) ;
60826254checkSharedStashFromUrl ( ) ;
0 commit comments