mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-15 00:33:44 +08:00
feat: remove Google Drive integration, associated hooks, components, API routes, and dependencies.
This commit is contained in:
@@ -1,19 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export async function GET() {
|
||||
const CLIENT_ID = process.env.LINUX_DO_CLIENT_ID;
|
||||
const REDIRECT_URI = `${process.env.NEXT_PUBLIC_APP_URL}/api/oauth/callback`;
|
||||
|
||||
if (!CLIENT_ID || !process.env.NEXT_PUBLIC_APP_URL) {
|
||||
return new Response('Missing configuration', { status: 500 });
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
response_type: 'code',
|
||||
scope: 'user', // As per user guide
|
||||
});
|
||||
|
||||
redirect(`https://connect.linux.do/oauth2/authorize?${params.toString()}`);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const code = searchParams.get('code');
|
||||
|
||||
console.log('[OAuth Callback] Received callback with code:', code ? 'exists' : 'missing');
|
||||
|
||||
if (!code) {
|
||||
console.error('[OAuth Callback] No code provided');
|
||||
return NextResponse.redirect(new URL('/settings?error=no_code', request.url));
|
||||
}
|
||||
|
||||
const CLIENT_ID = process.env.LINUX_DO_CLIENT_ID;
|
||||
const CLIENT_SECRET = process.env.LINUX_DO_CLIENT_SECRET;
|
||||
const REDIRECT_URI = `${process.env.NEXT_PUBLIC_APP_URL}/api/oauth/callback`;
|
||||
|
||||
if (!CLIENT_ID || !CLIENT_SECRET || !process.env.NEXT_PUBLIC_APP_URL) {
|
||||
console.error('[OAuth Callback] Missing configuration');
|
||||
return NextResponse.redirect(new URL('/settings?error=config_missing', request.url));
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Exchange code for token
|
||||
console.log('[OAuth Callback] Exchanging code for token...');
|
||||
const tokenResponse = await fetch('https://connect.linux.do/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
grant_type: 'authorization_code',
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
console.log('[OAuth Callback] Token response status:', tokenResponse.status);
|
||||
console.log('[OAuth Callback] Token data:', { hasToken: !!tokenData.access_token });
|
||||
|
||||
if (!tokenData.access_token) {
|
||||
console.error('[OAuth Callback] No access token in response:', tokenData);
|
||||
throw new Error('Failed to get access token');
|
||||
}
|
||||
|
||||
// 2. Get User Info
|
||||
console.log('[OAuth Callback] Fetching user info...');
|
||||
const userResponse = await fetch('https://connect.linux.do/api/user', {
|
||||
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
||||
});
|
||||
|
||||
const userData = await userResponse.json();
|
||||
console.log('[OAuth Callback] User response status:', userResponse.status);
|
||||
console.log('[OAuth Callback] User data:', { id: userData.id, username: userData.username });
|
||||
|
||||
// 3. Redirect with data
|
||||
const redirectUrl = new URL('/settings', request.url);
|
||||
redirectUrl.searchParams.set('auth_success', 'true');
|
||||
redirectUrl.searchParams.set('token', tokenData.access_token);
|
||||
redirectUrl.searchParams.set('user', JSON.stringify(userData));
|
||||
|
||||
console.log('[OAuth Callback] Redirecting to settings with auth data');
|
||||
return NextResponse.redirect(redirectUrl);
|
||||
} catch (error) {
|
||||
console.error('[OAuth Callback] Error:', error);
|
||||
return NextResponse.redirect(new URL('/settings?error=auth_failed', request.url));
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,6 @@ import { SourceSettings } from '@/components/settings/SourceSettings';
|
||||
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() {
|
||||
@@ -45,14 +43,6 @@ export default function SettingsPage() {
|
||||
{/* Header */}
|
||||
<SettingsHeader />
|
||||
|
||||
{/* Account Settings */}
|
||||
<Suspense fallback={<div className="p-6 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] backdrop-blur-xl">加载中...</div>}>
|
||||
<AccountSettings />
|
||||
</Suspense>
|
||||
|
||||
{/* Google Drive Sync */}
|
||||
<SyncSettings />
|
||||
|
||||
{/* Source Management */}
|
||||
<SourceSettings
|
||||
sources={sources}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useAuthStore, type LinuxDoUser } from '@/lib/store/auth-store';
|
||||
import { useHistoryStore } from '@/lib/store/history-store';
|
||||
import { useSearchHistoryStore } from '@/lib/store/search-history-store';
|
||||
|
||||
export function AccountSettings() {
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { user, isAuthenticated, login, logout } = useAuthStore();
|
||||
const { clearHistory } = useHistoryStore();
|
||||
const { clearSearchHistory } = useSearchHistoryStore();
|
||||
|
||||
// Wait for Zustand to hydrate from localStorage
|
||||
useEffect(() => {
|
||||
setIsHydrated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const authSuccess = searchParams.get('auth_success');
|
||||
const token = searchParams.get('token');
|
||||
const userStr = searchParams.get('user');
|
||||
|
||||
console.log('[AccountSettings] Checking URL params:', {
|
||||
authSuccess,
|
||||
hasToken: !!token,
|
||||
hasUser: !!userStr,
|
||||
});
|
||||
|
||||
if (authSuccess && token && userStr) {
|
||||
try {
|
||||
const userData = JSON.parse(userStr) as LinuxDoUser;
|
||||
console.log('[AccountSettings] Parsed user data:', {
|
||||
id: userData.id,
|
||||
username: userData.username,
|
||||
});
|
||||
|
||||
clearHistory();
|
||||
clearSearchHistory();
|
||||
login(userData, token);
|
||||
|
||||
console.log('[AccountSettings] Login complete, redirecting...');
|
||||
router.replace('/settings');
|
||||
} catch (e) {
|
||||
console.error('[AccountSettings] Failed to parse user data:', e);
|
||||
}
|
||||
}
|
||||
}, [searchParams, login, clearHistory, clearSearchHistory, router]);
|
||||
|
||||
const handleLogin = () => {
|
||||
clearHistory();
|
||||
clearSearchHistory();
|
||||
router.push('/api/oauth/authorize');
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
clearHistory();
|
||||
clearSearchHistory();
|
||||
};
|
||||
|
||||
// Don't render until hydrated to prevent flash
|
||||
if (!isHydrated) {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="flex items-center gap-3 pb-2 border-b border-[var(--glass-border)]">
|
||||
<svg className="w-6 h-6 text-[var(--accent-color)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<h2 className="text-xl font-semibold text-[var(--text-color)]">账号</h2>
|
||||
</div>
|
||||
<div className="p-6 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] backdrop-blur-xl">
|
||||
<div className="flex items-center justify-center text-[var(--text-color-secondary)]">加载中...</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="flex items-center gap-3 pb-2 border-b border-[var(--glass-border)]">
|
||||
<svg className="w-6 h-6 text-[var(--accent-color)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<h2 className="text-xl font-semibold text-[var(--text-color)]">账号</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] backdrop-blur-xl shadow-[var(--shadow-md)]">
|
||||
{!isAuthenticated ? (
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<p className="text-[var(--text-color-secondary)]">登录 Linux DO 以同步您的账号信息</p>
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
className="px-6 py-3 rounded-[var(--radius-2xl)] bg-[var(--accent-color)] text-white font-semibold hover:brightness-110 transition-all shadow-[var(--shadow-sm)] hover:shadow-[var(--shadow-md)] active:scale-95"
|
||||
>
|
||||
使用 Linux DO 登录
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={user?.avatar_template.replace('{size}', '100')}
|
||||
alt={user?.name || user?.username}
|
||||
className="w-16 h-16 rounded-[var(--radius-full)] border-2 border-[var(--glass-border)]"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-[var(--text-color)]">{user?.name || user?.username}</h3>
|
||||
<p className="text-[var(--text-color-secondary)]">@{user?.username}</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<span className="px-3 py-1 text-xs font-medium rounded-[var(--radius-full)] bg-[var(--accent-color)] text-white">
|
||||
Trust Level {user?.trust_level}
|
||||
</span>
|
||||
{user?.active && (
|
||||
<span className="px-3 py-1 text-xs font-medium rounded-[var(--radius-full)] bg-green-500/20 text-green-600 border border-green-500/30">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-[var(--glass-border)]">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full px-4 py-2 text-red-500 hover:bg-red-500/10 rounded-[var(--radius-2xl)] transition-colors font-medium"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
'use client';
|
||||
|
||||
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) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-color)] mb-4 flex items-center gap-2">
|
||||
<Icons.Cloud size={24} />
|
||||
<span>云端同步 (Google Drive)</span>
|
||||
</h2>
|
||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-[var(--radius-xl)] text-sm text-yellow-600 dark:text-yellow-400">
|
||||
<p className="font-medium mb-2">配置缺失</p>
|
||||
<p className="opacity-90">请在 .env.local 文件中添加 Google Client ID:</p>
|
||||
<code className="block mt-2 p-2 bg-black/10 dark:bg-white/10 rounded">
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID=你的客户端ID
|
||||
</code>
|
||||
</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 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>
|
||||
);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
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';
|
||||
|
||||
// Type declarations for gapi
|
||||
declare const gapi: any;
|
||||
|
||||
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(() => {
|
||||
// Only run on client side
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const initClient = async () => {
|
||||
try {
|
||||
console.log('[Google Drive] Starting initialization...');
|
||||
console.log('[Google Drive] Client ID:', process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ? 'Present' : 'Missing');
|
||||
|
||||
if (!process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID) {
|
||||
throw new Error('Missing Google Client ID');
|
||||
}
|
||||
|
||||
// Dynamically load gapi script
|
||||
console.log('[Google Drive] Loading gapi script...');
|
||||
if (!window.gapi) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://apis.google.com/js/api.js';
|
||||
script.onload = () => {
|
||||
console.log('[Google Drive] gapi script loaded');
|
||||
resolve();
|
||||
};
|
||||
script.onerror = (e) => {
|
||||
console.error('[Google Drive] Failed to load gapi script', e);
|
||||
reject(new Error('Failed to load Google API script'));
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[Google Drive] Loading client:auth2...');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
gapi.load('client:auth2', {
|
||||
callback: () => {
|
||||
console.log('[Google Drive] client:auth2 loaded');
|
||||
resolve();
|
||||
},
|
||||
onerror: (err: any) => {
|
||||
console.error('[Google Drive] Failed to load client:auth2', err);
|
||||
reject(new Error('Failed to load Google client libraries'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log('[Google Drive] Initializing client...');
|
||||
await gapi.client.init({
|
||||
apiKey: '', // No API key needed for OAuth
|
||||
clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
|
||||
discoveryDocs: DISCOVERY_DOCS,
|
||||
scope: SCOPES,
|
||||
});
|
||||
|
||||
console.log('[Google Drive] Client initialized successfully');
|
||||
|
||||
// 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);
|
||||
console.log('[Google Drive] Initialization complete');
|
||||
} catch (err: any) {
|
||||
console.error('[Google Drive] Error initializing Google API client', err);
|
||||
const errorMessage = err?.message || err?.error || 'Failed to initialize Google API';
|
||||
setError(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
initClient();
|
||||
}, []);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface LinuxDoUser {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
avatar_template: string;
|
||||
active: boolean;
|
||||
trust_level: number;
|
||||
silenced: boolean;
|
||||
external_ids?: Record<string, unknown>;
|
||||
api_key?: string;
|
||||
}
|
||||
|
||||
interface AuthStore {
|
||||
user: LinuxDoUser | null;
|
||||
accessToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (user: LinuxDoUser, accessToken: string) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
isAuthenticated: false,
|
||||
login: (user, accessToken) => set({ user, accessToken, isAuthenticated: true }),
|
||||
logout: () => set({ user: null, accessToken: null, isAuthenticated: false }),
|
||||
}),
|
||||
{
|
||||
name: 'kvideo-auth-storage',
|
||||
}
|
||||
)
|
||||
);
|
||||
Generated
-71
@@ -11,10 +11,7 @@
|
||||
"@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",
|
||||
@@ -23,7 +20,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/gapi.auth2": "^0.0.61",
|
||||
"@types/node": "^24",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -1085,26 +1081,6 @@
|
||||
"@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",
|
||||
@@ -1615,47 +1591,6 @@
|
||||
"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.auth2": {
|
||||
"version": "0.0.61",
|
||||
"resolved": "https://registry.npmjs.org/@types/gapi.auth2/-/gapi.auth2-0.0.61.tgz",
|
||||
"integrity": "sha512-cn+omiRoE/LTxZncnVl1QhcLggOT0sJ8Yz9RXIsw5R2zLyRf+0o6kaZzJ/Gr3Sxz6i7J/+PbXAF8yeZipCaiWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/gapi": "*"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -3791,12 +3726,6 @@
|
||||
"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",
|
||||
|
||||
+1
-5
@@ -12,10 +12,7 @@
|
||||
"@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",
|
||||
@@ -24,7 +21,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/gapi.auth2": "^0.0.61",
|
||||
"@types/node": "^24",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -34,4 +30,4 @@
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user