diff --git a/app/settings/page.tsx b/app/settings/page.tsx index d07296b..ebb8646 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -10,6 +10,7 @@ import { SortSettings } from '@/components/settings/SortSettings'; import { DataSettings } from '@/components/settings/DataSettings'; import { SettingsHeader } from '@/components/settings/SettingsHeader'; import { AccountSettings } from '@/components/settings/AccountSettings'; +import { SyncSettings } from '@/components/settings/SyncSettings'; import { useSettingsPage } from './hooks/useSettingsPage'; export default function SettingsPage() { @@ -49,6 +50,9 @@ export default function SettingsPage() { + {/* Google Drive Sync */} + + {/* Source Management */} +
+
+ 正在初始化同步服务... +
+ + ); + } + + return ( +
+
+

+ + 云端同步 (Google Drive) +

+ {user && ( +
+ + {user.email} + + +
+ )} +
+ + {error && ( +
+ {error} +
+ )} + + {!user ? ( +
+

+ 登录 Google 账号以在设备间同步您的设置和历史记录。 + 数据将存储在您 Google Drive 的专用应用文件夹中。 +

+ +
+ ) : ( +
+
+
+

自动同步

+

+ 更改设置或观看视频时自动同步 +

+
+ +
+ +
+ + +
+ + {lastSynced && ( +

+ 上次同步: {lastSynced.toLocaleString()} +

+ )} +
+ )} +
+ ); +} diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx index 2ea7875..6d537ea 100644 --- a/components/ui/Button.tsx +++ b/components/ui/Button.tsx @@ -1,7 +1,7 @@ import React, { forwardRef } from 'react'; interface ButtonProps extends React.ButtonHTMLAttributes { - variant?: 'primary' | 'secondary'; + variant?: 'primary' | 'secondary' | 'ghost'; children: React.ReactNode; } @@ -35,10 +35,15 @@ export const Button = forwardRef(({ rounded-[var(--radius-2xl)] text-[var(--text-color)] shadow-[0_2px_8px_color-mix(in_srgb,var(--shadow-color)_50%,transparent)] - hover:brightness-110 hover:shadow-[0_4px_12px_color-mix(in_srgb,var(--shadow-color)_70%,transparent)] active:scale-[0.98] `, + ghost: ` + bg-transparent + text-[var(--text-color)] + hover:bg-[var(--glass-border)] + active:scale-[0.98] + `, }; return ( diff --git a/components/ui/icons/utility-icons.tsx b/components/ui/icons/utility-icons.tsx index e1253f9..960030f 100644 --- a/components/ui/icons/utility-icons.tsx +++ b/components/ui/icons/utility-icons.tsx @@ -127,4 +127,16 @@ export const UtilityIcons = { ), + + Cloud: ({ className = "", size = 24 }: IconProps) => ( + + + + ), + + Google: ({ className = "", size = 24 }: IconProps) => ( + + + + ), }; diff --git a/lib/hooks/useGoogleDrive.ts b/lib/hooks/useGoogleDrive.ts new file mode 100644 index 0000000..434d113 --- /dev/null +++ b/lib/hooks/useGoogleDrive.ts @@ -0,0 +1,186 @@ +import { useState, useEffect, useCallback } from 'react'; +import { gapi } from 'gapi-script'; + +const DISCOVERY_DOCS = ['https://www.googleapis.com/discovery/v1/apis/drive/v3/rest']; +const SCOPES = 'https://www.googleapis.com/auth/drive.appdata'; +const APP_DATA_FILENAME = 'kvideo_data.json'; + +export interface GoogleUser { + name: string; + email: string; + imageUrl: string; +} + +export interface DriveFile { + id: string; + name: string; + modifiedTime?: string; +} + +export function useGoogleDrive() { + const [user, setUser] = useState(null); + const [isInitialized, setIsInitialized] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const initClient = async () => { + try { + await new Promise((resolve, reject) => { + gapi.load('client:auth2', () => { + resolve(); + }); + }); + + await gapi.client.init({ + clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '', + discoveryDocs: DISCOVERY_DOCS, + scope: SCOPES, + }); + + // Listen for sign-in state changes. + gapi.auth2.getAuthInstance().isSignedIn.listen(updateSigninStatus); + + // Handle the initial sign-in state. + updateSigninStatus(gapi.auth2.getAuthInstance().isSignedIn.get()); + setIsInitialized(true); + } catch (err: any) { + console.error('Error initializing Google API client', err); + setError(err.message || 'Failed to initialize Google API'); + } + }; + + if (process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID) { + initClient(); + } else { + setError('Missing Google Client ID'); + } + }, []); + + const updateSigninStatus = (isSignedIn: boolean) => { + if (isSignedIn) { + const profile = gapi.auth2.getAuthInstance().currentUser.get().getBasicProfile(); + setUser({ + name: profile.getName(), + email: profile.getEmail(), + imageUrl: profile.getImageUrl(), + }); + } else { + setUser(null); + } + }; + + const signIn = async () => { + try { + await gapi.auth2.getAuthInstance().signIn(); + } catch (err: any) { + console.error('Error signing in', err); + setError(err.message || 'Failed to sign in'); + } + }; + + const signOut = async () => { + try { + await gapi.auth2.getAuthInstance().signOut(); + } catch (err: any) { + console.error('Error signing out', err); + setError(err.message || 'Failed to sign out'); + } + }; + + const findAppDataFile = useCallback(async (): Promise => { + try { + const response = await gapi.client.drive.files.list({ + spaces: 'appDataFolder', + fields: 'nextPageToken, files(id, name, modifiedTime)', + q: `name = '${APP_DATA_FILENAME}'`, + pageSize: 1, + }); + const files = response.result.files; + if (files && files.length > 0) { + return files[0] as DriveFile; + } + return null; + } catch (err: any) { + console.error('Error finding app data file', err); + throw err; + } + }, []); + + const uploadData = useCallback(async (data: string) => { + setIsLoading(true); + try { + const file = await findAppDataFile(); + const boundary = '-------314159265358979323846'; + const delimiter = "\r\n--" + boundary + "\r\n"; + const close_delim = "\r\n--" + boundary + "--"; + + const contentType = 'application/json'; + const metadata = { + 'name': APP_DATA_FILENAME, + 'mimeType': contentType, + 'parents': !file ? ['appDataFolder'] : undefined // Only set parent on create + }; + + const multipartRequestBody = + delimiter + + 'Content-Type: application/json\r\n\r\n' + + JSON.stringify(metadata) + + delimiter + + 'Content-Type: ' + contentType + '\r\n\r\n' + + data + + close_delim; + + const request = gapi.client.request({ + 'path': file ? `/upload/drive/v3/files/${file.id}` : '/upload/drive/v3/files', + 'method': file ? 'PATCH' : 'POST', + 'params': { 'uploadType': 'multipart' }, + 'headers': { + 'Content-Type': 'multipart/related; boundary="' + boundary + '"' + }, + 'body': multipartRequestBody + }); + + await request; + console.log('Data uploaded successfully'); + } catch (err: any) { + console.error('Error uploading data', err); + setError(err.message || 'Failed to upload data'); + } finally { + setIsLoading(false); + } + }, [findAppDataFile]); + + const downloadData = useCallback(async (): Promise => { + setIsLoading(true); + try { + const file = await findAppDataFile(); + if (!file) return null; + + const response = await gapi.client.drive.files.get({ + fileId: file.id, + alt: 'media', + }); + return JSON.stringify(response.result); // gapi returns the parsed JSON in result for 'media' alt? + // Actually gapi.client.drive.files.get with alt='media' returns the body in `body` or `result` depending on content type. + // For JSON it should be in result. + } catch (err: any) { + console.error('Error downloading data', err); + setError(err.message || 'Failed to download data'); + return null; + } finally { + setIsLoading(false); + } + }, [findAppDataFile]); + + return { + user, + isInitialized, + isLoading, + error, + signIn, + signOut, + uploadData, + downloadData, + }; +} diff --git a/lib/hooks/useSync.ts b/lib/hooks/useSync.ts new file mode 100644 index 0000000..4f78474 --- /dev/null +++ b/lib/hooks/useSync.ts @@ -0,0 +1,138 @@ +import { useEffect, useRef, useState } from 'react'; +import { useGoogleDrive } from './useGoogleDrive'; +import { settingsStore } from '@/lib/store/settings-store'; +import { useHistoryStore } from '@/lib/store/history-store'; +import { useSearchHistory } from './useSearchHistory'; + +export function useSync() { + const { user, isInitialized, isLoading: isDriveLoading, error: driveError, signIn, signOut, uploadData, downloadData } = useGoogleDrive(); + const [lastSynced, setLastSynced] = useState(null); + const [isSyncing, setIsSyncing] = useState(false); + const [syncError, setSyncError] = useState(null); + const [autoSync, setAutoSync] = useState(false); + + // Load auto-sync preference + useEffect(() => { + const stored = localStorage.getItem('kvideo-autosync'); + if (stored) { + setAutoSync(JSON.parse(stored)); + } + }, []); + + const toggleAutoSync = (enabled: boolean) => { + setAutoSync(enabled); + localStorage.setItem('kvideo-autosync', JSON.stringify(enabled)); + }; + + const exportAllData = () => { + const settings = settingsStore.getSettings(); + const searchHistory = localStorage.getItem('kvideo-search-history'); + const watchHistory = localStorage.getItem('kvideo-watch-history'); + + return JSON.stringify({ + settings, + searchHistory: searchHistory ? JSON.parse(searchHistory) : [], + watchHistory: watchHistory ? JSON.parse(watchHistory) : [], + timestamp: Date.now(), + }); + }; + + const importAllData = (jsonString: string) => { + try { + const data = JSON.parse(jsonString); + + if (data.settings) { + settingsStore.saveSettings(data.settings); + } + if (data.searchHistory) { + localStorage.setItem('kvideo-search-history', JSON.stringify(data.searchHistory)); + // Trigger reload or store update if necessary + } + if (data.watchHistory) { + localStorage.setItem('kvideo-watch-history', JSON.stringify(data.watchHistory)); + // Trigger reload or store update if necessary + useHistoryStore.getState().importHistory(data.watchHistory); + } + + setLastSynced(new Date(data.timestamp || Date.now())); + return true; + } catch (e) { + console.error('Failed to import data', e); + return false; + } + }; + + const handleSync = async () => { + if (!user) return; + setIsSyncing(true); + setSyncError(null); + try { + const data = exportAllData(); + await uploadData(data); + setLastSynced(new Date()); + } catch (e: any) { + setSyncError(e.message || 'Sync failed'); + } finally { + setIsSyncing(false); + } + }; + + const handleRestore = async () => { + if (!user) return; + setIsSyncing(true); + setSyncError(null); + try { + const data = await downloadData(); + if (data) { + // The data might be wrapped in a result object depending on how gapi returns it + // If downloadData returns the raw string body: + importAllData(typeof data === 'string' ? data : JSON.stringify(data)); + } + } catch (e: any) { + setSyncError(e.message || 'Restore failed'); + } finally { + setIsSyncing(false); + } + }; + + // Auto-sync logic (simple debounce) + useEffect(() => { + if (!autoSync || !user || !isInitialized) return; + + const timeout = setTimeout(() => { + handleSync(); + }, 30000); // Sync every 30s if changes detected? + // Real implementation would subscribe to stores. + // For now, let's just sync on mount/login if auto-sync is on, and maybe periodically? + // Better: subscribe to store changes. + + return () => clearTimeout(timeout); + }, [autoSync, user, isInitialized]); + + // Subscribe to settings changes for auto-sync + useEffect(() => { + if (!autoSync || !user) return; + + const unsubscribe = settingsStore.subscribe(() => { + // Debounce sync + const timeout = setTimeout(handleSync, 5000); + return () => clearTimeout(timeout); + }); + + return unsubscribe; + }, [autoSync, user]); + + return { + user, + isInitialized, + isLoading: isDriveLoading || isSyncing, + error: driveError || syncError, + lastSynced, + autoSync, + toggleAutoSync, + signIn, + signOut, + syncNow: handleSync, + restoreNow: handleRestore, + }; +} diff --git a/lib/store/history-store.ts b/lib/store/history-store.ts index 230e7a6..7b485bb 100644 --- a/lib/store/history-store.ts +++ b/lib/store/history-store.ts @@ -28,6 +28,7 @@ interface HistoryStore { removeFromHistory: (videoId: string | number, source: string) => void; clearHistory: () => void; + importHistory: (history: VideoHistoryItem[]) => void; } /** @@ -135,6 +136,10 @@ export const useHistoryStore = create()( clearAllCache(); set({ viewingHistory: [] }); }, + + importHistory: (history) => { + set({ viewingHistory: history }); + }, }), { name: 'kvideo-history-store', diff --git a/package-lock.json b/package-lock.json index c297c06..9a58b10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,10 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@types/gapi": "^0.0.47", + "@types/gapi.client.drive": "^3.0.15", "@vercel/analytics": "^1.5.0", + "gapi-script": "^1.2.0", "hls.js": "^1.6.15", "next": "16.0.5", "react": "19.2.0", @@ -1081,6 +1084,26 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@maxim_mazurok/gapi.client.discovery-v1": { + "version": "0.4.20200806", + "resolved": "https://registry.npmjs.org/@maxim_mazurok/gapi.client.discovery-v1/-/gapi.client.discovery-v1-0.4.20200806.tgz", + "integrity": "sha512-Jeo/KZqK39DI6ExXHcJ4lqnn1O/wEqboQ6eQ8WnNpu5eJ7wUnX/C5KazOgs1aRhnIB/dVzDe8wm62nmtkMIoaw==", + "license": "MIT", + "dependencies": { + "@types/gapi.client": "*", + "@types/gapi.client.discovery-v1": "*" + } + }, + "node_modules/@maxim_mazurok/gapi.client.drive-v3": { + "version": "0.1.20251122", + "resolved": "https://registry.npmjs.org/@maxim_mazurok/gapi.client.drive-v3/-/gapi.client.drive-v3-0.1.20251122.tgz", + "integrity": "sha512-hi8A2jW6a/JZ5Hvweo1Ww0YIb004LqGOYv4/ifa7NCPBc51PHuVEeUDfQvf+INpN+JhC5OudENvzmAbFD/DoLQ==", + "license": "MIT", + "dependencies": { + "@types/gapi.client": "*", + "@types/gapi.client.discovery-v1": "*" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1591,6 +1614,37 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/gapi": { + "version": "0.0.47", + "resolved": "https://registry.npmjs.org/@types/gapi/-/gapi-0.0.47.tgz", + "integrity": "sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==", + "license": "MIT" + }, + "node_modules/@types/gapi.client": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/gapi.client/-/gapi.client-1.0.8.tgz", + "integrity": "sha512-qJQUmmumbYym3Amax0S8CVzuSngcXsC1fJdwRS2zeW5lM63zXkw4wJFP+bG0jzgi0R6EsJKoHnGNVTDbOyG1ng==", + "license": "MIT" + }, + "node_modules/@types/gapi.client.discovery-v1": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@types/gapi.client.discovery-v1/-/gapi.client.discovery-v1-0.0.4.tgz", + "integrity": "sha512-uevhRumNE65F5mf2gABLaReOmbFSXONuzFZjNR3dYv6BmkHg+wciubHrfBAsp3554zNo3Dcg6dUAlwMqQfpwjQ==", + "license": "MIT", + "dependencies": { + "@maxim_mazurok/gapi.client.discovery-v1": "latest" + } + }, + "node_modules/@types/gapi.client.drive": { + "version": "3.0.15", + "resolved": "https://registry.npmjs.org/@types/gapi.client.drive/-/gapi.client.drive-3.0.15.tgz", + "integrity": "sha512-qEfI0LxUBadOLmym4FkaNGpI4ibBCBPJHiUFWKIv0GIp7yKT2d+wztJYKr9giIRecErUCF+jGSDw1fzTZ6hPVQ==", + "deprecated": "use @types/gapi.client.drive-v3 instead; see https://github.com/Maxim-Mazurok/google-api-typings-generator/issues/652 for details", + "license": "MIT", + "dependencies": { + "@maxim_mazurok/gapi.client.drive-v3": "latest" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3726,6 +3780,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gapi-script": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gapi-script/-/gapi-script-1.2.0.tgz", + "integrity": "sha512-NKTVKiIwFdkO1j1EzcrWu/Pz7gsl1GmBmgh+qhuV2Ytls04W/Eg5aiBL91SCiBM9lU0PMu7p1hTVxhh1rPT5Lw==", + "license": "MIT" + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", diff --git a/package.json b/package.json index a617e88..07d9412 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@types/gapi": "^0.0.47", + "@types/gapi.client.drive": "^3.0.15", "@vercel/analytics": "^1.5.0", + "gapi-script": "^1.2.0", "hls.js": "^1.6.15", "next": "16.0.5", "react": "19.2.0", @@ -30,4 +33,4 @@ "tailwindcss": "^4", "typescript": "^5" } -} \ No newline at end of file +}