diff --git a/.gitignore b/.gitignore index f544e37..3c5a96f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ next-env.d.ts # unused/generated files contrast-test-results.json + +# user data (server-side config sync) +/.data/ diff --git a/README.md b/README.md index 51f3e39..7780cb1 100644 --- a/README.md +++ b/README.md @@ -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 模式下配置不再丢失 ### 无障碍设计 diff --git a/app/api/search-parallel/route.ts b/app/api/search-parallel/route.ts index 4be0299..2e8a58d 100644 --- a/app/api/search-parallel/route.ts +++ b/app/api/search-parallel/route.ts @@ -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) { }, }); } - - diff --git a/app/api/user/config/route.ts b/app/api/user/config/route.ts new file mode 100644 index 0000000..6e9435f --- /dev/null +++ b/app/api/user/config/route.ts @@ -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 } + ); + } +} diff --git a/app/page.tsx b/app/page.tsx index 7102e61..5836ee2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -22,6 +22,7 @@ function HomePage() { totalSources, handleSearch, handleReset, + handleCancelSearch, } = useHomePage(); // Real-time latency pinging @@ -48,6 +49,7 @@ function HomePage() { ([]); + 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, }; } diff --git a/app/premium/settings/page.tsx b/app/premium/settings/page.tsx index afb1f27..52af4c7 100644 --- a/app/premium/settings/page.tsx +++ b/app/premium/settings/page.tsx @@ -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 */} diff --git a/app/settings/hooks/useSettingsPage.ts b/app/settings/hooks/useSettingsPage.ts index 3b14402..c9aa066 100644 --- a/app/settings/hooks/useSettingsPage.ts +++ b/app/settings/hooks/useSettingsPage.ts @@ -33,6 +33,9 @@ export function useSettingsPage() { const [danmakuFontSize, setDanmakuFontSize] = useState(20); const [danmakuDisplayArea, setDanmakuDisplayArea] = useState(0.5); + // Content filter + const [blockedCategories, setBlockedCategories] = useState([]); + 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, }; } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index fcd0724..b969357 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -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) */} diff --git a/components/AutoSync.tsx b/components/AutoSync.tsx index a64ddb4..e2bb6e0 100644 --- a/components/AutoSync.tsx +++ b/components/AutoSync.tsx @@ -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; // 未登录不进行同步 diff --git a/components/SearchLoadingAnimation.tsx b/components/SearchLoadingAnimation.tsx index b3c020e..28f47e3 100644 --- a/components/SearchLoadingAnimation.tsx +++ b/components/SearchLoadingAnimation.tsx @@ -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(null); @@ -123,7 +125,17 @@ export function SearchLoadingAnimation({ )} - {Math.round(progress)}% + + {Math.round(progress)}% + {!isComplete && onCancel && ( + + )} + diff --git a/components/iptv/IPTVPlayer.tsx b/components/iptv/IPTVPlayer.tsx index 3f4ab34..d785f43 100644 --- a/components/iptv/IPTVPlayer.tsx +++ b/components/iptv/IPTVPlayer.tsx @@ -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(); diff --git a/components/player/DesktopVideoPlayer.tsx b/components/player/DesktopVideoPlayer.tsx index 55df33d..4b35530 100644 --- a/components/player/DesktopVideoPlayer.tsx +++ b/components/player/DesktopVideoPlayer.tsx @@ -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 && ( -
- +
+ {videoResolution.label} {videoResolution.width}x{videoResolution.height} diff --git a/components/player/hooks/useResolutionBadge.ts b/components/player/hooks/useResolutionBadge.ts new file mode 100644 index 0000000..a90c9eb --- /dev/null +++ b/components/player/hooks/useResolutionBadge.ts @@ -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 | 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 }; +} diff --git a/components/search/SearchForm.tsx b/components/search/SearchForm.tsx index 55cb846..5736403 100644 --- a/components/search/SearchForm.tsx +++ b/components/search/SearchForm.tsx @@ -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} />
)} diff --git a/components/settings/DisplaySettings.tsx b/components/settings/DisplaySettings.tsx index a3c7104..62dfe0a 100644 --- a/components/settings/DisplaySettings.tsx +++ b/components/settings/DisplaySettings.tsx @@ -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 (

显示设置

@@ -126,6 +147,50 @@ export function DisplaySettings({
+ {/* Blocked Categories */} +
+

内容类目过滤

+

+ 添加要从搜索结果中隐藏的类目关键词(如"伦理"),匹配的视频将不会显示 +

+
+ 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)]" + /> + +
+ {blockedCategories.length > 0 && ( +
+ {blockedCategories.map(cat => ( + + {cat} + + + ))} +
+ )} +
); } diff --git a/lib/api/http-utils.ts b/lib/api/http-utils.ts index 2f03833..9bec6b1 100644 --- a/lib/api/http-utils.ts +++ b/lib/api/http-utils.ts @@ -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, diff --git a/lib/api/search-api.ts b/lib/api/search-api.ts index 583b19d..19efe98 100644 --- a/lib/api/search-api.ts +++ b/lib/api/search-api.ts @@ -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> { const searchPromises = sources.map(async source => { try { - return await searchVideosBySource(query, source, page); + return await searchVideosBySource(query, source, page, signal); } catch (error) { return { results: [], diff --git a/lib/hooks/useConfigSync.ts b/lib/hooks/useConfigSync.ts new file mode 100644 index 0000000..5dfbe57 --- /dev/null +++ b/lib/hooks/useConfigSync.ts @@ -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 | 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]); +} diff --git a/lib/hooks/useHomePage.ts b/lib/hooks/useHomePage.ts index 51f42b7..b845f60 100644 --- a/lib/hooks/useHomePage.ts +++ b/lib/hooks/useHomePage.ts @@ -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, diff --git a/lib/hooks/useParallelSearch.ts b/lib/hooks/useParallelSearch.ts index da189ab..da3f5df 100644 --- a/lib/hooks/useParallelSearch.ts +++ b/lib/hooks/useParallelSearch.ts @@ -16,6 +16,7 @@ interface ParallelSearchResult { totalVideosFound: number; performSearch: (query: string, sources?: any[], sortBy?: SortOption) => Promise; resetSearch: () => void; + cancelSearch: () => void; loadCachedResults: (results: Video[], sources: any[]) => void; applySorting: (sortBy: SortOption) => void; loadMore: () => Promise; @@ -85,6 +86,7 @@ export function useParallelSearch( totalVideosFound, performSearch, resetSearch, + cancelSearch, loadCachedResults, applySorting, loadMore: loadMoreAction, diff --git a/lib/hooks/usePremiumHomePage.ts b/lib/hooks/usePremiumHomePage.ts index 3d041bb..38d741f 100644 --- a/lib/hooks/usePremiumHomePage.ts +++ b/lib/hooks/usePremiumHomePage.ts @@ -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, diff --git a/lib/store/settings-store.ts b/lib/store/settings-store.ts index d49a8fe..3c5a9b0 100644 --- a/lib/store/settings-store.ts +++ b/lib/store/settings-store.ts @@ -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 diff --git a/lib/utils/search-stream.ts b/lib/utils/search-stream.ts index c7b9cd9..8b08c41 100644 --- a/lib/utils/search-stream.ts +++ b/lib/utils/search-stream.ts @@ -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; @@ -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), diff --git a/package-lock.json b/package-lock.json index a672234..ef667ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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==" } } } diff --git a/package.json b/package.json index 9d75180..367fad5 100644 --- a/package.json +++ b/package.json @@ -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",