mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
Merge pull request #90 from vvwn/main
利用 vercel 的Upstash Redis增加收藏和播放同步功能。
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { Redis } from '@upstash/redis';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// 确保这行代码在整个文件中只出现一次
|
||||
export const runtime = 'edge';
|
||||
|
||||
const redis = Redis.fromEnv();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const profileId = request.headers.get('x-profile-id');
|
||||
|
||||
if (!profileId) {
|
||||
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await redis.get(`user:sync:${profileId}`);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: data || { history: [], favorites: [] }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Redis Get Error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch sync data' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const profileId = request.headers.get('x-profile-id');
|
||||
|
||||
if (!profileId) {
|
||||
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { history, favorites } = body;
|
||||
|
||||
await redis.set(`user:sync:${profileId}`, { history, favorites });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Redis Set Error:', error);
|
||||
return NextResponse.json({ error: 'Failed to save sync data' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import { AutoSync } from '@/components/AutoSync'; // <-- 引入了自动同步组件
|
||||
import { TVProvider } from "@/lib/contexts/TVContext";
|
||||
import { TVNavigationInitializer } from "@/components/TVNavigationInitializer";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
@@ -15,6 +16,7 @@ import { ScrollPositionManager } from "@/components/ScrollPositionManager";
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
|
||||
// Server Component specifically for reading env/file (async for best practices)
|
||||
async function AdKeywordsWrapper() {
|
||||
let keywords: string[] = [];
|
||||
@@ -97,6 +99,9 @@ export default function RootLayout({
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<ThemeProvider>
|
||||
{/* 加入自动同步组件,它会在后台默默工作,我们放在 ThemeProvider 内部的最前面 */}
|
||||
<AutoSync />
|
||||
|
||||
<TVProvider>
|
||||
<TVNavigationInitializer />
|
||||
<PasswordGate hasAuth={!!(process.env.ADMIN_PASSWORD || process.env.ACCOUNTS || process.env.ACCESS_PASSWORD)}>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useHistoryStore } from '@/lib/store/history-store';
|
||||
import { useFavoritesStore } from '@/lib/store/favorites-store';
|
||||
import { useCloudSync } from '@/lib/hooks/useCloudSync';
|
||||
import { getSession } from '@/lib/store/auth-store';
|
||||
|
||||
// 防抖函数,防止频繁请求
|
||||
function debounce(fn: Function, delay: number) {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
return (...args: any[]) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
export function AutoSync() {
|
||||
const { pushToCloud, pullFromCloud } = useCloudSync();
|
||||
|
||||
useEffect(() => {
|
||||
const session = getSession();
|
||||
if (!session) return; // 未登录不进行同步
|
||||
|
||||
// 1. 刚打开网页时,主动从云端拉取一次最新数据
|
||||
pullFromCloud();
|
||||
|
||||
// 2. 监听本地数据的变化,如果数据变了,延迟 5 秒后推送到云端
|
||||
const debouncedPush = debounce(pushToCloud, 5000);
|
||||
|
||||
// 修改点在这里:Zustand v4/v5 默认 subscribe 只接受一个参数
|
||||
const unsubHistory = useHistoryStore.subscribe(() => {
|
||||
debouncedPush();
|
||||
});
|
||||
|
||||
const unsubFavorites = useFavoritesStore.subscribe(() => {
|
||||
debouncedPush();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubHistory();
|
||||
unsubFavorites();
|
||||
};
|
||||
}, [pushToCloud, pullFromCloud]);
|
||||
|
||||
return null; // 这是一个静默组件,不需要渲染任何UI
|
||||
}
|
||||
@@ -77,7 +77,13 @@ export const MovieCard = memo(function MovieCard({ movie, onMovieClick }: MovieC
|
||||
)}
|
||||
{movie.rate && parseFloat(movie.rate) > 0 && (
|
||||
<div
|
||||
className="absolute top-2 right-2 bg-black/80 px-2.5 py-1.5 flex items-center gap-1.5 rounded-[var(--radius-full)]"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // 阻止事件冒泡,防止触发外层卡片的搜索点击
|
||||
window.open(movie.url, '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
title="在豆瓣中查看"
|
||||
className="absolute top-2 right-2 bg-black/80 hover:bg-black/90 px-2.5 py-1.5 flex items-center gap-1.5 rounded-[var(--radius-full)] z-20 hover:scale-105 transition-all shadow-md"
|
||||
>
|
||||
<Icons.Star size={12} className="text-yellow-400 fill-yellow-400" />
|
||||
<span className="text-xs font-bold text-white">
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useHistoryStore, usePremiumHistoryStore } from '@/lib/store/history-store';
|
||||
import { useFavoritesStore, usePremiumFavoritesStore } from '@/lib/store/favorites-store';
|
||||
import { getProfileId } from '@/lib/store/auth-store';
|
||||
|
||||
export function useCloudSync(isPremium = false) {
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
const historyStore = isPremium ? usePremiumHistoryStore : useHistoryStore;
|
||||
const favoritesStore = isPremium ? usePremiumFavoritesStore : useFavoritesStore;
|
||||
|
||||
const pullFromCloud = useCallback(async () => {
|
||||
const profileId = getProfileId();
|
||||
if (!profileId) return;
|
||||
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
const response = await fetch('/api/user/sync', {
|
||||
headers: { 'x-profile-id': profileId }
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
if (result.data.history?.length > 0) {
|
||||
historyStore.getState().importHistory(result.data.history);
|
||||
}
|
||||
if (result.data.favorites?.length > 0) {
|
||||
favoritesStore.getState().importFavorites(result.data.favorites);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pull from cloud:', error);
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}, [historyStore, favoritesStore]);
|
||||
|
||||
const pushToCloud = useCallback(async () => {
|
||||
const profileId = getProfileId();
|
||||
if (!profileId) return;
|
||||
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
const currentHistory = historyStore.getState().viewingHistory;
|
||||
const currentFavorites = favoritesStore.getState().favorites;
|
||||
|
||||
await fetch('/api/user/sync', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-profile-id': profileId,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
history: currentHistory,
|
||||
favorites: currentFavorites
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to push to cloud:', error);
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}, [historyStore, favoritesStore]);
|
||||
|
||||
return { pushToCloud, pullFromCloud, isSyncing };
|
||||
}
|
||||
Generated
+6068
-1244
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@upstash/redis": "^1.34.4",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucide-react": "^0.575.0",
|
||||
|
||||
Reference in New Issue
Block a user