feat: Add Google Drive sync functionality for app data and history, including new settings UI and a 'ghost' button variant.

This commit is contained in:
kuekhaoyang
2025-11-29 18:16:44 +08:00
parent 3679ba0f52
commit 421cde41e1
9 changed files with 535 additions and 3 deletions
+4
View File
@@ -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() {
<AccountSettings />
</Suspense>
{/* Google Drive Sync */}
<SyncSettings />
{/* Source Management */}
<SourceSettings
sources={sources}
+119
View File
@@ -0,0 +1,119 @@
import { useSync } from '@/lib/hooks/useSync';
import { Icons } from '@/components/ui/Icon';
import { Button } from '@/components/ui/Button';
export function SyncSettings() {
const {
user,
isInitialized,
isLoading,
error,
lastSynced,
autoSync,
toggleAutoSync,
signIn,
signOut,
syncNow,
restoreNow
} = useSync();
if (!isInitialized) {
return (
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-5 w-5 border-2 border-[var(--accent-color)] border-t-transparent"></div>
<span className="text-[var(--text-color-secondary)]">...</span>
</div>
</div>
);
}
return (
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-[var(--text-color)] flex items-center gap-2">
<Icons.Cloud size={24} />
<span> (Google Drive)</span>
</h2>
{user && (
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--text-color-secondary)]">
{user.email}
</span>
<Button
variant="ghost"
onClick={signOut}
className="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
>
退
</Button>
</div>
)}
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-[var(--radius-xl)] text-sm text-red-600 dark:text-red-400">
{error}
</div>
)}
{!user ? (
<div className="text-center py-6">
<p className="text-[var(--text-color-secondary)] mb-4">
Google
Google Drive
</p>
<Button onClick={signIn} variant="primary" className="w-full sm:w-auto">
<Icons.Google className="mr-2" size={20} />
Google Drive
</Button>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-xl)]">
<div>
<p className="font-medium text-[var(--text-color)]"></p>
<p className="text-sm text-[var(--text-color-secondary)]">
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={autoSync}
onChange={(e) => toggleAutoSync(e.target.checked)}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-[var(--accent-color)]"></div>
</label>
</div>
<div className="flex gap-3">
<Button
onClick={syncNow}
disabled={isLoading}
className="flex-1"
variant="secondary"
>
{isLoading ? '同步中...' : '立即同步 (上传)'}
</Button>
<Button
onClick={restoreNow}
disabled={isLoading}
className="flex-1"
variant="secondary"
>
{isLoading ? '同步中...' : '立即恢复 (下载)'}
</Button>
</div>
{lastSynced && (
<p className="text-xs text-center text-[var(--text-color-secondary)]">
: {lastSynced.toLocaleString()}
</p>
)}
</div>
)}
</div>
);
}
+7 -2
View File
@@ -1,7 +1,7 @@
import React, { forwardRef } from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary';
variant?: 'primary' | 'secondary' | 'ghost';
children: React.ReactNode;
}
@@ -35,10 +35,15 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
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 (
+12
View File
@@ -127,4 +127,16 @@ export const UtilityIcons = {
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" />
</svg>
),
Cloud: ({ className = "", size = 24 }: IconProps) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" />
</svg>
),
Google: ({ className = "", size = 24 }: IconProps) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .533 5.333.533 12S5.867 24 12.48 24c3.44 0 6.013-1.133 8.053-3.24 2.08-2.08 2.72-5.013 2.72-7.427 0-.733-.053-1.44-.16-2.133h-10.613z" />
</svg>
),
};
+186
View File
@@ -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<GoogleUser | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const initClient = async () => {
try {
await new Promise<void>((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<DriveFile | null> => {
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<string | null> => {
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,
};
}
+138
View File
@@ -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<Date | null>(null);
const [isSyncing, setIsSyncing] = useState(false);
const [syncError, setSyncError] = useState<string | null>(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,
};
}
+5
View File
@@ -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<HistoryStore>()(
clearAllCache();
set({ viewingHistory: [] });
},
importHistory: (history) => {
set({ viewingHistory: history });
},
}),
{
name: 'kvideo-history-store',
+60
View File
@@ -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",
+4 -1
View File
@@ -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"
}
}
}