chore: update package versions and add user config sync API

- Bump version to 4.8.0 and update dependencies in package.json
- Implement user config sync API for cross-device settings persistence
- Add resolution badge auto-hide hook for improved user experience
- Create useConfigSync hook to manage user settings synchronization with the server
This commit is contained in:
kuekhaoyang
2026-03-18 12:44:05 +08:00
parent 54c1d353d7
commit 3564ce1ede
27 changed files with 721 additions and 215 deletions
+3
View File
@@ -42,3 +42,6 @@ next-env.d.ts
# unused/generated files
contrast-test-results.json
# user data (server-side config sync)
/.data/
+14 -1
View File
@@ -61,10 +61,13 @@
- **搜索结果显示**:支持默认显示和合并同名源两种模式
- **实时延迟监测**:可选实时显示各源的网络延迟
- **清晰度标签**:自动解析并显示视频清晰度(4K/蓝光/1080P/720P/HD 等),方便快速分辨源质量
- **实际分辨率检测**:播放视频时自动检测并显示实际视频分辨率(如 1920x1080),不依赖源标签,显示真实清晰度
- **实际分辨率检测**:播放视频时自动检测并显示实际视频分辨率(如 1920x1080),不依赖源标签,显示真实清晰度。分辨率标签 5 秒后自动隐藏,鼠标移动时重新显示
- **繁体中文搜索**:自动将繁体中文转换为简体中文进行搜索,确保繁体输入也能搜到结果
- **源过滤**:支持按源和类型筛选搜索结果,源标签支持按类型分组显示,智能合并同名分类标签,展开/折叠状态持久化记忆
- **多级标签**:搜索结果和播放器中显示源名称和内容类型双重标签
- **搜索取消**:搜索进行中可随时点击"取消"按钮终止搜索,释放资源
- **内容类目过滤**:在设置中添加屏蔽关键词(如"伦理"),匹配类目的视频将自动从搜索结果中过滤
- **搜索性能优化**:服务端支持客户端断开检测(AbortSignal),每源超时保护,总结果数上限,防止内存溢出
### 多线路折叠
@@ -86,6 +89,7 @@
- **多线路折叠**:频道多线路默认显示前 3 条,可点击展开查看全部
- **自动切源**:当视频源不可用时,自动选择延迟最低的可用源
- **自定义请求头**:自动解析 M3U 中的 `http-user-agent``http-referrer` 属性,通过代理传递
- **User-Agent 智能代理**:当频道指定自定义 User-Agent 时,自动走代理路径避免浏览器限制,解决 CCTV 等频道仅有声音无画面的问题
- **流媒体代理**:内置 HLS 流代理,自动处理 CORS 问题和 M3U8 URL 重写
- **智能内容检测**:当 content-type 不明确时,检查响应体内容自动识别 M3U8 格式,同时保持二进制流数据完整性
- **重定向跟随**:自动跟随 HTTP 3xx 重定向,提升兼容性
@@ -167,6 +171,15 @@
- **可安装应用**:支持将 KVideo 安装为独立应用
- **Service Worker**:离线缓存和资源预加载
- **全屏体验**:独立应用模式下的沉浸式体验
- **配置同步**:iOS Safari 添加到主屏幕后,视频源和设置自动从服务端同步,无需重新配置
### 跨设备配置同步
- **服务端存储**:视频源、订阅、显示设置等自动同步到服务端(文件存储,无需 Redis)
- **自动拉取**:打开应用时自动从服务端拉取最新配置
- **自动推送**:设置变更后自动延迟推送到服务端(防抖 3 秒)
- **多设备支持**:电脑端配置的源和设置,手机端打开即可使用
- **PWA 兼容**iOS PWA 模式下配置不再丢失
### 无障碍设计
+97 -84
View File
@@ -1,84 +1,94 @@
/**
* Parallel Streaming Search API Route
* Searches all sources in parallel and streams results immediately as they arrive
* No waiting - results flow in real-time
* Searches all sources in parallel and streams results immediately as they arrive.
* Supports abort via request.signal when clients disconnect.
* Caps results per source and total to prevent OOM.
*/
import { NextRequest } from 'next/server';
import { searchVideos } from '@/lib/api/client';
import { getSourceById } from '@/lib/api/video-sources';
import { getSourceName } from '@/lib/utils/source-names';
import { traditionalToSimplified } from '@/lib/utils/chinese-convert';
export const runtime = 'edge';
const MAX_TOTAL_VIDEOS = 2000;
const MAX_PAGES_PER_SOURCE = 3;
const PER_SOURCE_TIMEOUT_MS = 20000;
export async function POST(request: NextRequest) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// Use the request signal for abort detection
const signal = request.signal;
const safeSend = (data: object) => {
if (signal.aborted) return;
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch {
// Controller may be closed
}
};
try {
const body = await request.json();
const { query, sources: sourceConfigs, page = 1 } = body;
const { query, sources: sourceConfigs } = body;
// Validate input
if (!query || typeof query !== 'string' || query.trim().length === 0) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'error',
message: 'Invalid query'
})}\n\n`));
safeSend({ type: 'error', message: 'Invalid query' });
controller.close();
return;
}
// Convert Traditional Chinese to Simplified Chinese for broader search compatibility
const normalizedQuery = traditionalToSimplified(query.trim());
// Use provided sources or fallback to empty (client should provide them)
const sources = Array.isArray(sourceConfigs) && sourceConfigs.length > 0
? sourceConfigs
: [];
if (sources.length === 0) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'error',
message: 'No valid sources provided'
})}\n\n`));
safeSend({ type: 'error', message: 'No valid sources provided' });
controller.close();
return;
}
// Send initial status
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'start',
totalSources: sources.length
})}\n\n`));
safeSend({ type: 'start', totalSources: sources.length });
// Track progress
let completedSources = 0;
let totalVideosFound = 0;
let maxPageCount = 1;
// Search all sources in PARALLEL - don't wait for all to finish
const searchPromises = sources.map(async (source: any) => {
const startTime = performance.now(); // Track start time
try {
if (signal.aborted) return;
// Search page 1 for this source
const result = await searchVideos(normalizedQuery, [source], 1);
const endTime = performance.now(); // Track end time
const latency = Math.round(endTime - startTime); // Calculate latency in ms
const startTime = performance.now();
// Per-source timeout via AbortController
const sourceController = new AbortController();
const sourceTimeout = setTimeout(
() => sourceController.abort(),
PER_SOURCE_TIMEOUT_MS
);
// Cascade request abort to source controller
const onRequestAbort = () => sourceController.abort();
signal.addEventListener('abort', onRequestAbort, { once: true });
try {
const result = await searchVideos(
normalizedQuery, [source], 1, sourceController.signal
);
const endTime = performance.now();
const latency = Math.round(endTime - startTime);
const videos = result[0]?.results || [];
const pagecount = result[0]?.pagecount ?? 1;
completedSources++;
totalVideosFound += videos.length;
// Stream page 1 videos immediately
if (videos.length > 0) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
if (videos.length > 0 && !signal.aborted) {
safeSend({
type: 'videos',
videos: videos.map((video: any) => ({
...video,
@@ -89,29 +99,35 @@ export async function POST(request: NextRequest) {
completedSources,
totalSources: sources.length,
latency,
})}\n\n`));
});
}
// Send progress update for page 1
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
safeSend({
type: 'progress',
completedSources,
totalSources: sources.length,
totalVideosFound
})}\n\n`));
totalVideosFound,
});
// Auto-fetch remaining pages (capped)
if (pagecount > 1 && totalVideosFound < MAX_TOTAL_VIDEOS && !signal.aborted) {
const maxPages = Math.min(pagecount, MAX_PAGES_PER_SOURCE);
const remainingPages = Array.from(
{ length: maxPages - 1 }, (_, i) => i + 2
);
for (const pg of remainingPages) {
if (signal.aborted || totalVideosFound >= MAX_TOTAL_VIDEOS) break;
// Auto-fetch remaining pages if pagecount > 1
if (pagecount > 1) {
const remainingPages = Array.from({ length: pagecount - 1 }, (_, i) => i + 2);
const pagePromises = remainingPages.map(async (pg) => {
try {
const pageResult = await searchVideos(normalizedQuery, [source], pg);
const pageResult = await searchVideos(
normalizedQuery, [source], pg, sourceController.signal
);
const pageVideos = pageResult[0]?.results || [];
totalVideosFound += pageVideos.length;
if (pageVideos.length > 0) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
if (pageVideos.length > 0 && !signal.aborted) {
safeSend({
type: 'videos',
videos: pageVideos.map((video: any) => ({
...video,
@@ -122,65 +138,64 @@ export async function POST(request: NextRequest) {
completedSources,
totalSources: sources.length,
latency,
})}\n\n`));
});
}
// Progress update for each additional page
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
safeSend({
type: 'progress',
completedSources,
totalSources: sources.length,
totalVideosFound
})}\n\n`));
} catch (pageError) {
console.error(`[Search Parallel] Source ${source.id} page ${pg} failed:`, pageError);
totalVideosFound,
});
} catch {
// Page fetch failed, continue
}
});
await Promise.all(pagePromises);
}
}
} catch (error) {
const endTime = performance.now();
const latency = Math.round(endTime - startTime);
// Log error but continue with other sources
console.error(`[Search Parallel] Source ${source.id} failed after ${latency}ms:`, error);
console.error(
`[Search] Source ${source.id} failed after ${latency}ms:`,
error
);
completedSources++;
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
safeSend({
type: 'progress',
completedSources,
totalSources: sources.length,
totalVideosFound
})}\n\n`));
totalVideosFound,
});
} finally {
clearTimeout(sourceTimeout);
signal.removeEventListener('abort', onRequestAbort);
}
});
// Wait for all sources to complete
await Promise.all(searchPromises);
// Send completion signal
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'complete',
totalVideosFound,
totalSources: sources.length,
maxPageCount
})}\n\n`));
if (!signal.aborted) {
safeSend({
type: 'complete',
totalVideosFound,
totalSources: sources.length,
maxPageCount: MAX_PAGES_PER_SOURCE,
});
}
controller.close();
} catch (error) {
console.error('Search error:', error);
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'error',
message: error instanceof Error ? error.message : 'Unknown error'
})}\n\n`));
if (!signal.aborted) {
console.error('Search error:', error);
safeSend({
type: 'error',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
controller.close();
}
}
},
});
return new Response(stream, {
@@ -191,5 +206,3 @@ export async function POST(request: NextRequest) {
},
});
}
+83
View File
@@ -0,0 +1,83 @@
/**
* User Config Sync API Route
* File-based settings persistence for cross-device and PWA support.
* Stores user settings (sources, IPTV, display preferences) server-side
* so they persist across browsers, devices, and PWA installs.
*
* No external dependencies required — uses local JSON files.
*/
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
const DATA_DIR = path.join(process.cwd(), '.data', 'user-config');
async function ensureDir() {
await fs.mkdir(DATA_DIR, { recursive: true });
}
function getFilePath(profileId: string): string {
// Sanitize profileId to prevent path traversal
const safe = profileId.replace(/[^a-zA-Z0-9_-]/g, '');
return path.join(DATA_DIR, `${safe}.json`);
}
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 {
await ensureDir();
const filePath = getFilePath(profileId);
const content = await fs.readFile(filePath, 'utf-8');
const data = JSON.parse(content);
return NextResponse.json({ success: true, data });
} catch (error: any) {
if (error?.code === 'ENOENT') {
return NextResponse.json({ success: true, data: null });
}
console.error('Config read error:', error);
return NextResponse.json(
{ error: 'Failed to read config' },
{ 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 {
await ensureDir();
const body = await request.json();
const filePath = getFilePath(profileId);
// Merge with existing data if present
let existing: any = {};
try {
const content = await fs.readFile(filePath, 'utf-8');
existing = JSON.parse(content);
} catch {
// File doesn't exist yet
}
const merged = { ...existing, ...body, updatedAt: Date.now() };
await fs.writeFile(filePath, JSON.stringify(merged, null, 2), 'utf-8');
return NextResponse.json({ success: true });
} catch (error) {
console.error('Config write error:', error);
return NextResponse.json(
{ error: 'Failed to save config' },
{ status: 500 }
);
}
}
+2
View File
@@ -22,6 +22,7 @@ function HomePage() {
totalSources,
handleSearch,
handleReset,
handleCancelSearch,
} = useHomePage();
// Real-time latency pinging
@@ -48,6 +49,7 @@ function HomePage() {
<SearchForm
onSearch={handleSearch}
onClear={handleReset}
onCancelSearch={handleCancelSearch}
isLoading={loading}
initialQuery={query}
currentSource=""
+2
View File
@@ -21,6 +21,7 @@ function PremiumHomePage() {
totalSources,
handleSearch,
handleReset,
handleCancelSearch,
} = usePremiumHomePage();
return (
@@ -36,6 +37,7 @@ function PremiumHomePage() {
<SearchForm
onSearch={handleSearch}
onClear={handleReset}
onCancelSearch={handleCancelSearch}
isLoading={loading}
initialQuery={query}
currentSource=""
@@ -23,6 +23,9 @@ export function usePremiumSettingsPage() {
const [danmakuFontSize, setDanmakuFontSize] = useState(20);
const [danmakuDisplayArea, setDanmakuDisplayArea] = useState(0.5);
// Content filter
const [blockedCategories, setBlockedCategories] = useState<string[]>([]);
useEffect(() => {
// Sources come from main settings store
const settings = settingsStore.getSettings();
@@ -40,6 +43,9 @@ export function usePremiumSettingsPage() {
setDanmakuOpacity(modeSettings.danmakuOpacity);
setDanmakuFontSize(modeSettings.danmakuFontSize);
setDanmakuDisplayArea(modeSettings.danmakuDisplayArea);
// blockedCategories is global
setBlockedCategories(settings.blockedCategories || []);
}, []);
// --- Source management (uses main settingsStore) ---
@@ -137,6 +143,12 @@ export function usePremiumSettingsPage() {
savePremiumModeSetting({ danmakuDisplayArea: value });
};
const handleBlockedCategoriesChange = (categories: string[]) => {
setBlockedCategories(categories);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({ ...currentSettings, blockedCategories: categories });
};
return {
premiumSources,
isAddModalOpen,
@@ -171,5 +183,7 @@ export function usePremiumSettingsPage() {
handleDanmakuFontSizeChange,
danmakuDisplayArea,
handleDanmakuDisplayAreaChange,
blockedCategories,
handleBlockedCategoriesChange,
};
}
+4
View File
@@ -44,6 +44,8 @@ export default function PremiumSettingsPage() {
handleDanmakuFontSizeChange,
danmakuDisplayArea,
handleDanmakuDisplayAreaChange,
blockedCategories,
handleBlockedCategoriesChange,
} = usePremiumSettingsPage();
return (
@@ -97,6 +99,8 @@ export default function PremiumSettingsPage() {
onRememberScrollPositionChange={handleRememberScrollPositionChange}
locale={locale}
onLocaleChange={handleLocaleChange}
blockedCategories={blockedCategories}
onBlockedCategoriesChange={handleBlockedCategoriesChange}
/>
{/* Premium Source Management */}
+15
View File
@@ -33,6 +33,9 @@ export function useSettingsPage() {
const [danmakuFontSize, setDanmakuFontSize] = useState(20);
const [danmakuDisplayArea, setDanmakuDisplayArea] = useState(0.5);
// Content filter
const [blockedCategories, setBlockedCategories] = useState<string[]>([]);
useEffect(() => {
const settings = settingsStore.getSettings();
setSources(settings.sources || []);
@@ -48,6 +51,7 @@ export function useSettingsPage() {
setDanmakuOpacity(settings.danmakuOpacity);
setDanmakuFontSize(settings.danmakuFontSize);
setDanmakuDisplayArea(settings.danmakuDisplayArea);
setBlockedCategories(settings.blockedCategories || []);
}, []);
const handleSourcesChange = (newSources: VideoSource[]) => {
@@ -304,6 +308,15 @@ export function useSettingsPage() {
});
};
const handleBlockedCategoriesChange = (categories: string[]) => {
setBlockedCategories(categories);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({
...currentSettings,
blockedCategories: categories,
});
};
const handleRestoreDefaults = () => {
const defaults = getDefaultSources();
handleSourcesChange(defaults);
@@ -364,5 +377,7 @@ export function useSettingsPage() {
handleDanmakuFontSizeChange,
danmakuDisplayArea,
handleDanmakuDisplayAreaChange,
blockedCategories,
handleBlockedCategoriesChange,
};
}
+4
View File
@@ -66,6 +66,8 @@ export default function SettingsPage() {
handleDanmakuFontSizeChange,
danmakuDisplayArea,
handleDanmakuDisplayAreaChange,
blockedCategories,
handleBlockedCategoriesChange,
} = useSettingsPage();
return (
@@ -106,6 +108,8 @@ export default function SettingsPage() {
onRememberScrollPositionChange={handleRememberScrollPositionChange}
locale={locale}
onLocaleChange={handleLocaleChange}
blockedCategories={blockedCategories}
onBlockedCategoriesChange={handleBlockedCategoriesChange}
/>
{/* Per-User Source Settings (visible to all logged-in users) */}
+4
View File
@@ -4,6 +4,7 @@ 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 { useConfigSync } from '@/lib/hooks/useConfigSync';
import { getSession } from '@/lib/store/auth-store';
// 防抖函数,防止频繁请求
@@ -18,6 +19,9 @@ function debounce(fn: Function, delay: number) {
export function AutoSync() {
const { pushToCloud, pullFromCloud } = useCloudSync();
// Config sync (sources, settings) — works without Redis, file-based
useConfigSync();
useEffect(() => {
const session = getSession();
if (!session) return; // 未登录不进行同步
+13 -1
View File
@@ -8,6 +8,7 @@ interface SearchLoadingAnimationProps {
totalSources?: number;
isPaused?: boolean;
onComplete?: (checkedSources: number, totalSources: number) => void;
onCancel?: () => void;
}
export function SearchLoadingAnimation({
@@ -16,6 +17,7 @@ export function SearchLoadingAnimation({
totalSources = 16,
isPaused = false,
onComplete,
onCancel,
}: SearchLoadingAnimationProps) {
const [dots, setDots] = useState('');
const dotIntervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -123,7 +125,17 @@ export function SearchLoadingAnimation({
</span>
)}
</span>
<span className="font-medium">{Math.round(progress)}%</span>
<span className="flex items-center gap-2">
<span className="font-medium">{Math.round(progress)}%</span>
{!isComplete && onCancel && (
<button
onClick={onCancel}
className="px-2 py-0.5 rounded-[var(--radius-full)] bg-[var(--glass-bg)] hover:bg-red-500/20 text-[10px] transition-colors cursor-pointer"
>
</button>
)}
</span>
</div>
</div>
</div>
+18 -4
View File
@@ -200,6 +200,11 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
video.load();
const proxiedUrl = getProxiedUrl(url, channel.httpUserAgent, channel.httpReferrer);
const hasCustomHeaders = !!(channel.httpUserAgent || channel.httpReferrer);
// When custom headers are needed, skip direct attempt (browsers cannot set
// User-Agent on XHR/fetch). Always go through our proxy which can forward
// the headers server-side. This fixes audio-only issues on CCTV and similar.
const initialUrl = hasCustomHeaders ? proxiedUrl : url;
// Global loading timeout
let loadingResolved = false;
@@ -315,8 +320,8 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
}
};
// First try direct URL with HLS.js
hls.loadSource(url);
// First try initial URL (direct or proxied based on custom headers)
hls.loadSource(initialUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
// Filter HEVC levels to prevent audio-only playback
@@ -337,12 +342,17 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS (Safari/iOS)
video.src = url;
video.src = initialUrl;
video.addEventListener('canplay', () => {
markLoaded();
video.play().catch(() => {});
}, { once: true });
video.addEventListener('error', () => {
// If direct failed, try proxy; if already proxied, fail
if (initialUrl === proxiedUrl) {
markError('播放错误');
return;
}
video.src = proxiedUrl;
video.addEventListener('canplay', () => {
markLoaded();
@@ -354,12 +364,16 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
}, { once: true });
} else {
// Direct video fallback
video.src = url;
video.src = initialUrl;
video.addEventListener('canplay', () => {
markLoaded();
video.play().catch(() => {});
}, { once: true });
video.addEventListener('error', () => {
if (initialUrl === proxiedUrl) {
markError('播放错误,请尝试其他频道');
return;
}
video.src = proxiedUrl;
video.addEventListener('canplay', () => {
markLoaded();
+6 -4
View File
@@ -7,6 +7,7 @@ import { useHlsPlayer } from './hooks/useHlsPlayer';
import { useAutoSkip } from './hooks/useAutoSkip';
import { useStallDetection } from './hooks/useStallDetection';
import { useVideoResolution } from './hooks/useVideoResolution';
import { useResolutionBadge } from './hooks/useResolutionBadge';
import { DesktopControlsWrapper } from './desktop/DesktopControlsWrapper';
import { DesktopOverlayWrapper } from './desktop/DesktopOverlayWrapper';
import { DanmakuCanvas } from './DanmakuCanvas';
@@ -57,6 +58,7 @@ export function DesktopVideoPlayer({
// Detect actual video resolution
const videoResolution = useVideoResolution(refs.videoRef);
const { badgeVisible, flashBadge } = useResolutionBadge(videoResolution);
// Notify parent when resolution is detected
React.useEffect(() => {
@@ -206,7 +208,7 @@ export function DesktopVideoPlayer({
ref={containerRef}
className={`kvideo-container relative aspect-video bg-black rounded-[var(--radius-2xl)] group ${data.isFullscreen && fullscreenType === 'window' ? 'is-web-fullscreen' : ''
} ${shouldForceLandscape ? 'force-landscape' : ''}`}
onMouseMove={handleMouseMove}
onMouseMove={() => { handleMouseMove(); flashBadge(); }}
onMouseLeave={() => isPlaying && setShowControls(false)}
>
{/* Clipping Wrapper for video and overlays - Restores the 'Liquid Glass' rounded look */}
@@ -245,10 +247,10 @@ export function DesktopVideoPlayer({
/>
)}
{/* Video Resolution Badge - shows actual resolution from video stream */}
{/* Video Resolution Badge - auto-hides after 5 seconds */}
{videoResolution && (
<div className="absolute top-3 left-3 z-20 pointer-events-none">
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-white ${videoResolution.color} opacity-80`}>
<div className={`absolute top-3 left-3 z-20 pointer-events-none transition-opacity duration-500 ${badgeVisible ? 'opacity-80' : 'opacity-0'}`}>
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-white ${videoResolution.color}`}>
{videoResolution.label}
<span className="font-normal opacity-80">{videoResolution.width}x{videoResolution.height}</span>
</span>
@@ -0,0 +1,52 @@
/**
* Resolution Badge Auto-Hide Hook
* Shows the resolution badge when detected, then auto-hides after a delay.
* Reappears briefly on user interaction (mouse move / touch).
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import type { VideoResolutionInfo } from './useVideoResolution';
const AUTO_HIDE_DELAY = 5000; // 5 seconds
export function useResolutionBadge(resolution: VideoResolutionInfo | null) {
const [visible, setVisible] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const startHideTimer = useCallback(() => {
clearTimer();
timerRef.current = setTimeout(() => {
setVisible(false);
}, AUTO_HIDE_DELAY);
}, [clearTimer]);
// Show badge when resolution first detected or changes
useEffect(() => {
if (resolution) {
setVisible(true);
startHideTimer();
} else {
setVisible(false);
clearTimer();
}
}, [resolution, startHideTimer, clearTimer]);
// Briefly show badge on user interaction
const flashBadge = useCallback(() => {
if (!resolution) return;
setVisible(true);
startHideTimer();
}, [resolution, startHideTimer]);
// Cleanup
useEffect(() => clearTimer, [clearTimer]);
return { badgeVisible: visible, flashBadge };
}
+3
View File
@@ -6,6 +6,7 @@ import { SearchBox } from './SearchBox';
interface SearchFormProps {
onSearch: (query: string) => void;
onClear?: () => void;
onCancelSearch?: () => void;
isLoading: boolean;
initialQuery?: string;
currentSource?: string;
@@ -18,6 +19,7 @@ interface SearchFormProps {
export function SearchForm({
onSearch,
onClear,
onCancelSearch,
isLoading,
initialQuery = '',
currentSource = '',
@@ -43,6 +45,7 @@ export function SearchForm({
currentSource={currentSource}
checkedSources={checkedSources}
totalSources={totalSources}
onCancel={onCancelSearch}
/>
</div>
)}
+65
View File
@@ -5,6 +5,7 @@
* Following Liquid Glass design system
*/
import { useState } from 'react';
import { type SearchDisplayMode, type LocaleOption } from '@/lib/store/settings-store';
import { Switch } from '@/components/ui/Switch';
@@ -13,10 +14,12 @@ interface DisplaySettingsProps {
searchDisplayMode: SearchDisplayMode;
rememberScrollPosition: boolean;
locale: LocaleOption;
blockedCategories: string[];
onRealtimeLatencyChange: (enabled: boolean) => void;
onSearchDisplayModeChange: (mode: SearchDisplayMode) => void;
onRememberScrollPositionChange: (enabled: boolean) => void;
onLocaleChange: (locale: LocaleOption) => void;
onBlockedCategoriesChange: (categories: string[]) => void;
}
export function DisplaySettings({
@@ -24,11 +27,29 @@ export function DisplaySettings({
searchDisplayMode,
rememberScrollPosition,
locale,
blockedCategories,
onRealtimeLatencyChange,
onSearchDisplayModeChange,
onRememberScrollPositionChange,
onLocaleChange,
onBlockedCategoriesChange,
}: DisplaySettingsProps) {
const [newCategory, setNewCategory] = useState('');
const addCategory = () => {
const trimmed = newCategory.trim();
if (!trimmed) return;
if (blockedCategories.includes(trimmed)) {
setNewCategory('');
return;
}
onBlockedCategoriesChange([...blockedCategories, trimmed]);
setNewCategory('');
};
const removeCategory = (cat: string) => {
onBlockedCategoriesChange(blockedCategories.filter(c => c !== cat));
};
return (
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
<h2 className="text-xl font-semibold text-[var(--text-color)] mb-4"></h2>
@@ -126,6 +147,50 @@ export function DisplaySettings({
</button>
</div>
</div>
{/* Blocked Categories */}
<div className="mt-6">
<h3 className="font-medium text-[var(--text-color)] mb-2"></h3>
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
"伦理"
</p>
<div className="flex gap-2 mb-3">
<input
type="text"
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addCategory()}
placeholder="输入类目关键词..."
className="flex-1 px-3 py-2 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)] focus:outline-none focus:border-[var(--accent-color)]"
/>
<button
onClick={addCategory}
disabled={!newCategory.trim()}
className="px-4 py-2 rounded-[var(--radius-2xl)] bg-[var(--accent-color)] text-white text-sm font-medium disabled:opacity-40 hover:opacity-90 transition-opacity cursor-pointer"
>
</button>
</div>
{blockedCategories.length > 0 && (
<div className="flex flex-wrap gap-2">
{blockedCategories.map(cat => (
<span
key={cat}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-[var(--radius-full)] bg-red-500/10 text-red-500 text-sm border border-red-500/20"
>
{cat}
<button
onClick={() => removeCategory(cat)}
className="hover:text-red-700 cursor-pointer"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</span>
))}
</div>
)}
</div>
</div>
);
}
+13
View File
@@ -12,6 +12,7 @@ const RETRY_DELAY = 200;
/**
* Fetch with timeout support
* Accepts an optional external AbortSignal for cancellation cascade.
*/
export async function fetchWithTimeout(
url: string,
@@ -21,6 +22,18 @@ export async function fetchWithTimeout(
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
// If an external signal is provided, propagate its abort
const externalSignal = options.signal;
if (externalSignal) {
if (externalSignal.aborted) {
clearTimeout(timeoutId);
controller.abort();
} else {
const onAbort = () => controller.abort();
externalSignal.addEventListener('abort', onAbort, { once: true });
}
}
try {
const response = await fetch(url, {
...options,
+6 -3
View File
@@ -10,7 +10,8 @@ import { fetchWithTimeout, withRetry } from './http-utils';
async function searchVideosBySource(
query: string,
source: VideoSource,
page: number = 1
page: number = 1,
signal?: AbortSignal
): Promise<{ results: VideoItem[]; source: string; responseTime: number; pagecount: number }> {
const startTime = Date.now();
@@ -27,6 +28,7 @@ async function searchVideosBySource(
'User-Agent': 'Mozilla/5.0',
...source.headers,
},
signal,
});
if (!res.ok) {
@@ -71,11 +73,12 @@ async function searchVideosBySource(
export async function searchVideos(
query: string,
sources: VideoSource[],
page: number = 1
page: number = 1,
signal?: AbortSignal
): Promise<Array<{ results: VideoItem[]; source: string; responseTime?: number; pagecount?: number; error?: string }>> {
const searchPromises = sources.map(async source => {
try {
return await searchVideosBySource(query, source, page);
return await searchVideosBySource(query, source, page, signal);
} catch (error) {
return {
results: [],
+134
View File
@@ -0,0 +1,134 @@
/**
* useConfigSync - Syncs user settings to the server for cross-device
* and PWA persistence. Pulls on mount, pushes on change.
*/
import { useEffect, useRef, useCallback } from 'react';
import { settingsStore } from '@/lib/store/settings-store';
import { getProfileId } from '@/lib/store/auth-store';
const DEBOUNCE_MS = 3000;
export function useConfigSync() {
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasPulled = useRef(false);
const getHeaders = useCallback(() => {
const profileId = getProfileId();
if (!profileId) return null;
return {
'x-profile-id': profileId,
'Content-Type': 'application/json',
};
}, []);
// Pull config from server on mount (once)
useEffect(() => {
if (hasPulled.current) return;
hasPulled.current = true;
const pull = async () => {
const headers = getHeaders();
if (!headers) return;
try {
const res = await fetch('/api/user/config', { headers });
const result = await res.json();
if (result.success && result.data) {
const serverData = result.data;
const local = settingsStore.getSettings();
// Only merge server data if it's newer or local is default
const serverTime = serverData.updatedAt || 0;
const localStr = localStorage.getItem('kvideo-settings');
const localTime = localStr
? JSON.parse(localStr)?._syncedAt || 0
: 0;
if (serverTime > localTime) {
// Server is newer — merge server settings into local
const merged = { ...local };
if (serverData.sources?.length > 0) {
merged.sources = serverData.sources;
}
if (serverData.premiumSources?.length > 0) {
merged.premiumSources = serverData.premiumSources;
}
if (serverData.subscriptions?.length > 0) {
merged.subscriptions = serverData.subscriptions;
}
if (serverData.blockedCategories) {
merged.blockedCategories = serverData.blockedCategories;
}
settingsStore.saveSettings(merged);
// Update sync timestamp
const stored = localStorage.getItem('kvideo-settings');
if (stored) {
const parsed = JSON.parse(stored);
parsed._syncedAt = serverTime;
localStorage.setItem(
'kvideo-settings',
JSON.stringify(parsed)
);
}
}
}
} catch {
// Server may not be available (e.g. Cloudflare Pages)
}
};
pull();
}, [getHeaders]);
// Push config to server on settings change (debounced)
useEffect(() => {
const push = () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
const headers = getHeaders();
if (!headers) return;
try {
const settings = settingsStore.getSettings();
await fetch('/api/user/config', {
method: 'POST',
headers,
body: JSON.stringify({
sources: settings.sources,
premiumSources: settings.premiumSources,
subscriptions: settings.subscriptions,
blockedCategories: settings.blockedCategories,
sortBy: settings.sortBy,
locale: settings.locale,
}),
});
// Update local sync timestamp
const stored = localStorage.getItem('kvideo-settings');
if (stored) {
const parsed = JSON.parse(stored);
parsed._syncedAt = Date.now();
localStorage.setItem(
'kvideo-settings',
JSON.stringify(parsed)
);
}
} catch {
// Silently fail — local storage is the primary source
}
}, DEBOUNCE_MS);
};
const unsubscribe = settingsStore.subscribe(push);
return () => {
unsubscribe();
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [getHeaders]);
}
+6
View File
@@ -32,6 +32,7 @@ export function useHomePage() {
totalSources,
performSearch,
resetSearch,
cancelSearch,
loadCachedResults,
applySorting,
loadMore,
@@ -147,6 +148,10 @@ export function useHomePage() {
const handleCancelSearch = useCallback(() => {
cancelSearch();
}, [cancelSearch]);
const handleReset = useCallback(() => {
setHasSearched(false);
setQuery('');
@@ -165,6 +170,7 @@ export function useHomePage() {
totalSources,
handleSearch,
handleReset,
handleCancelSearch,
loadMore,
hasMore,
loadingMore,
+2
View File
@@ -16,6 +16,7 @@ interface ParallelSearchResult {
totalVideosFound: number;
performSearch: (query: string, sources?: any[], sortBy?: SortOption) => Promise<void>;
resetSearch: () => void;
cancelSearch: () => void;
loadCachedResults: (results: Video[], sources: any[]) => void;
applySorting: (sortBy: SortOption) => void;
loadMore: () => Promise<void>;
@@ -85,6 +86,7 @@ export function useParallelSearch(
totalVideosFound,
performSearch,
resetSearch,
cancelSearch,
loadCachedResults,
applySorting,
loadMore: loadMoreAction,
+2
View File
@@ -34,6 +34,7 @@ export function usePremiumHomePage() {
totalSources,
performSearch,
resetSearch,
cancelSearch,
loadCachedResults,
applySorting,
loadMore,
@@ -143,6 +144,7 @@ export function usePremiumHomePage() {
totalSources,
handleSearch,
handleReset,
handleCancelSearch: cancelSearch,
loadMore,
hasMore,
loadingMore,
+3
View File
@@ -55,6 +55,7 @@ export interface AppSettings {
danmakuFontSize: number; // px
danmakuDisplayArea: number; // 0.25 | 0.5 | 0.75 | 1.0
locale: LocaleOption; // 'zh-CN' (Simplified) or 'zh-TW' (Traditional)
blockedCategories: string[]; // Category keywords to hide from search results (e.g. '伦理')
}
import { exportSettings, importSettings, SEARCH_HISTORY_KEY, WATCH_HISTORY_KEY } from './settings-helpers';
@@ -134,6 +135,7 @@ function getDefaultAppSettings(): AppSettings {
danmakuFontSize: 20,
danmakuDisplayArea: 0.5,
locale: 'zh-CN',
blockedCategories: [],
};
}
@@ -217,6 +219,7 @@ export const settingsStore = {
danmakuFontSize: typeof parsed.danmakuFontSize === 'number' ? parsed.danmakuFontSize : 20,
danmakuDisplayArea: typeof parsed.danmakuDisplayArea === 'number' ? parsed.danmakuDisplayArea : 0.5,
locale: parsed.locale === 'zh-TW' ? 'zh-TW' : 'zh-CN',
blockedCategories: Array.isArray(parsed.blockedCategories) ? parsed.blockedCategories : [],
};
} catch {
// Even if localStorage fails, we should return defaults + ENV subscriptions
+12
View File
@@ -1,6 +1,16 @@
import { Video } from '@/lib/types';
import { getSourceName } from '@/lib/utils/source-names';
import { calculateRelevanceScore, hasMinimumMatch } from '@/lib/utils/search';
import { settingsStore } from '@/lib/store/settings-store';
/**
* Check if a video's category matches any blocked keyword.
*/
function isCategoryBlocked(video: any, blockedCategories: string[]): boolean {
if (blockedCategories.length === 0) return false;
const typeName = (video.type_name || video.vod_class || '').toLowerCase();
return blockedCategories.some(cat => typeName.includes(cat.toLowerCase()));
}
interface StreamHandlerParams {
reader: ReadableStreamDefaultReader<Uint8Array>;
@@ -43,6 +53,7 @@ export async function processSearchStream({
try {
resetTimeout(); // Start initial timeout
const blockedCategories = settingsStore.getSettings().blockedCategories;
while (true) {
const { done, value } = await reader.read();
@@ -64,6 +75,7 @@ export async function processSearchStream({
} else if (data.type === 'videos') {
const newVideos: Video[] = data.videos
.filter((video: any) => hasMinimumMatch(video.vod_name, currentQuery))
.filter((video: any) => !isCategoryBlocked(video, blockedCategories))
.map((video: any) => ({
...video,
sourceName: video.sourceDisplayName || getSourceName(video.source),
+139 -113
View File
@@ -1,25 +1,25 @@
{
"name": "kvideo",
"version": "4.5.0",
"version": "4.8.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "kvideo",
"version": "4.5.0",
"version": "4.8.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@upstash/redis": "^1.37.0",
"@vercel/analytics": "^2.0.0",
"@vercel/analytics": "^2.0.1",
"hls.js": "^1.6.15",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"next": "16.1.7",
"opencc-js": "^1.0.5",
"react": "19.2.4",
"react-dom": "19.2.4",
"zustand": "^5.0.11"
"zustand": "^5.0.12"
},
"devDependencies": {
"@cloudflare/next-on-pages": "^1.13.16",
@@ -28,7 +28,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^10",
"eslint-config-next": "16.1.6",
"eslint-config-next": "16.1.7",
"postcss": "^8.5.8",
"postcss-preset-env": "^11.2.0",
"tailwindcss": "^4",
@@ -2520,26 +2520,29 @@
}
},
"node_modules/@next/env": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
"integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz",
"integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz",
"integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.7.tgz",
"integrity": "sha512-v/bRGOJlfRCO+NDKt0bZlIIWjhMKU8xbgEQBo+rV9C8S6czZvs96LZ/v24/GvpEnovZlL4QDpku/RzWHVbmPpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-glob": "3.3.1"
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
"integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz",
"integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -2549,12 +2552,13 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
"integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz",
"integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -2564,12 +2568,13 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
"integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz",
"integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -2579,12 +2584,13 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
"integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz",
"integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -2594,12 +2600,13 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
"integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz",
"integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -2609,12 +2616,13 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
"integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz",
"integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -2624,12 +2632,13 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
"integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz",
"integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -2639,12 +2648,13 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
"integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz",
"integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -2658,6 +2668,7 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
@@ -2671,6 +2682,7 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
@@ -2680,6 +2692,7 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
@@ -3507,9 +3520,9 @@
}
},
"node_modules/@vercel/analytics": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.0.tgz",
"integrity": "sha512-fP/ASXXz+1K/C2vWTnocd8RsGnkO9f1qOIDrhgQ3DagJtnea1EsM9AV9fDzjXlPIPb2vBQapxOIMCjtGIW8PZw==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz",
"integrity": "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==",
"license": "MIT",
"peerDependencies": {
"@remix-run/react": "^2",
@@ -3531,6 +3544,9 @@
"next": {
"optional": true
},
"nuxt": {
"optional": true
},
"react": {
"optional": true
},
@@ -5007,12 +5023,13 @@
}
},
"node_modules/eslint-config-next": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz",
"integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.7.tgz",
"integrity": "sha512-FTq1i/QDltzq+zf9aB/cKWAiZ77baG0V7h8dRQh3thVx7I4dwr6ZXQrWKAaTB7x5VwVXlzoUTyMLIVQPLj2gJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/eslint-plugin-next": "16.1.6",
"@next/eslint-plugin-next": "16.1.7",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
@@ -5483,6 +5500,7 @@
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
@@ -5511,6 +5529,7 @@
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
@@ -6817,6 +6836,7 @@
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
@@ -6826,6 +6846,7 @@
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
@@ -6958,13 +6979,14 @@
"dev": true
},
"node_modules/next": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz",
"integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==",
"license": "MIT",
"dependencies": {
"@next/env": "16.1.6",
"@next/env": "16.1.7",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.8.3",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@@ -6976,14 +6998,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.1.6",
"@next/swc-darwin-x64": "16.1.6",
"@next/swc-linux-arm64-gnu": "16.1.6",
"@next/swc-linux-arm64-musl": "16.1.6",
"@next/swc-linux-x64-gnu": "16.1.6",
"@next/swc-linux-x64-musl": "16.1.6",
"@next/swc-win32-arm64-msvc": "16.1.6",
"@next/swc-win32-x64-msvc": "16.1.6",
"@next/swc-darwin-arm64": "16.1.7",
"@next/swc-darwin-x64": "16.1.7",
"@next/swc-linux-arm64-gnu": "16.1.7",
"@next/swc-linux-arm64-musl": "16.1.7",
"@next/swc-linux-x64-gnu": "16.1.7",
"@next/swc-linux-x64-musl": "16.1.7",
"@next/swc-win32-arm64-msvc": "16.1.7",
"@next/swc-win32-x64-msvc": "16.1.7",
"sharp": "^0.34.4"
},
"peerDependencies": {
@@ -8113,7 +8135,8 @@
"type": "consulting",
"url": "https://feross.org/support"
}
]
],
"license": "MIT"
},
"node_modules/react": {
"version": "19.2.4",
@@ -8234,6 +8257,7 @@
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
@@ -8258,6 +8282,7 @@
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
@@ -9281,9 +9306,10 @@
}
},
"node_modules/zustand": {
"version": "5.0.11",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
@@ -10491,65 +10517,65 @@
}
},
"@next/env": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
"integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz",
"integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg=="
},
"@next/eslint-plugin-next": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz",
"integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.7.tgz",
"integrity": "sha512-v/bRGOJlfRCO+NDKt0bZlIIWjhMKU8xbgEQBo+rV9C8S6czZvs96LZ/v24/GvpEnovZlL4QDpku/RzWHVbmPpA==",
"dev": true,
"requires": {
"fast-glob": "3.3.1"
}
},
"@next/swc-darwin-arm64": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
"integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz",
"integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==",
"optional": true
},
"@next/swc-darwin-x64": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
"integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz",
"integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==",
"optional": true
},
"@next/swc-linux-arm64-gnu": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
"integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz",
"integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==",
"optional": true
},
"@next/swc-linux-arm64-musl": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
"integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz",
"integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==",
"optional": true
},
"@next/swc-linux-x64-gnu": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
"integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz",
"integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==",
"optional": true
},
"@next/swc-linux-x64-musl": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
"integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz",
"integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==",
"optional": true
},
"@next/swc-win32-arm64-msvc": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
"integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz",
"integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==",
"optional": true
},
"@next/swc-win32-x64-msvc": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
"integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz",
"integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==",
"optional": true
},
"@nodelib/fs.scandir": {
@@ -11063,9 +11089,9 @@
}
},
"@vercel/analytics": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.0.tgz",
"integrity": "sha512-fP/ASXXz+1K/C2vWTnocd8RsGnkO9f1qOIDrhgQ3DagJtnea1EsM9AV9fDzjXlPIPb2vBQapxOIMCjtGIW8PZw=="
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz",
"integrity": "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g=="
},
"acorn": {
"version": "8.16.0",
@@ -11982,12 +12008,12 @@
}
},
"eslint-config-next": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz",
"integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.7.tgz",
"integrity": "sha512-FTq1i/QDltzq+zf9aB/cKWAiZ77baG0V7h8dRQh3thVx7I4dwr6ZXQrWKAaTB7x5VwVXlzoUTyMLIVQPLj2gJg==",
"dev": true,
"requires": {
"@next/eslint-plugin-next": "16.1.6",
"@next/eslint-plugin-next": "16.1.7",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
@@ -13272,21 +13298,21 @@
"dev": true
},
"next": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz",
"integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==",
"requires": {
"@next/env": "16.1.6",
"@next/swc-darwin-arm64": "16.1.6",
"@next/swc-darwin-x64": "16.1.6",
"@next/swc-linux-arm64-gnu": "16.1.6",
"@next/swc-linux-arm64-musl": "16.1.6",
"@next/swc-linux-x64-gnu": "16.1.6",
"@next/swc-linux-x64-musl": "16.1.6",
"@next/swc-win32-arm64-msvc": "16.1.6",
"@next/swc-win32-x64-msvc": "16.1.6",
"@next/env": "16.1.7",
"@next/swc-darwin-arm64": "16.1.7",
"@next/swc-darwin-x64": "16.1.7",
"@next/swc-linux-arm64-gnu": "16.1.7",
"@next/swc-linux-arm64-musl": "16.1.7",
"@next/swc-linux-x64-gnu": "16.1.7",
"@next/swc-linux-x64-musl": "16.1.7",
"@next/swc-win32-arm64-msvc": "16.1.7",
"@next/swc-win32-x64-msvc": "16.1.7",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.8.3",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"sharp": "^0.34.4",
@@ -14698,9 +14724,9 @@
"dev": true
},
"zustand": {
"version": "5.0.11",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="
}
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.5.0",
"version": "4.8.0",
"private": true,
"scripts": {
"dev": "next dev --port ${PORT:-3000}",
@@ -14,14 +14,14 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@upstash/redis": "^1.37.0",
"@vercel/analytics": "^2.0.0",
"@vercel/analytics": "^2.0.1",
"hls.js": "^1.6.15",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"next": "16.1.7",
"opencc-js": "^1.0.5",
"react": "19.2.4",
"react-dom": "19.2.4",
"zustand": "^5.0.11"
"zustand": "^5.0.12"
},
"devDependencies": {
"@cloudflare/next-on-pages": "^1.13.16",
@@ -30,7 +30,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^10",
"eslint-config-next": "16.1.6",
"eslint-config-next": "16.1.7",
"postcss": "^8.5.8",
"postcss-preset-env": "^11.2.0",
"tailwindcss": "^4",