feat: Update subscriptions, comments, thumbnails and video player
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions

- Add categorized subscriptions page with Show more/less
- Fix comments display on watch page
- Add thumbnail fallback handling across all pages
- Increase video buffer for smoother playback
- Add visibility change handler for background play
- Update Docker config for v5 deployment
This commit is contained in:
KV-Tube Deployer 2026-03-25 07:44:48 +07:00
parent 82a51b7ee4
commit 468b2b08fc
13 changed files with 599 additions and 377 deletions

View file

@ -1,16 +0,0 @@
.venv/
.venv_clean/
env/
__pycache__/
.git/
.DS_Store
*.pyc
*.pyo
*.pyd
.idea/
.vscode/
videos/
data/
venv/
.gemini/
tmp*/

View file

@ -21,16 +21,44 @@ A modern, fast, and fully-featured YouTube-like video streaming platform. Built
- **Process Management**: `supervisord` manages the concurrent execution of the backend API and Next.js frontend within the same network namespace. - **Process Management**: `supervisord` manages the concurrent execution of the backend API and Next.js frontend within the same network namespace.
- **Data storage**: SQLite is used for watch history, optimized for `linux/amd64`. - **Data storage**: SQLite is used for watch history, optimized for `linux/amd64`.
## Deployment on Synology NAS (v4.0.7) ## Docker Deployment (v5)
### Quick Start
1. Clone or download this repository
2. Create a `data` folder in the project directory
3. Run the container:
```bash
docker-compose up -d
```
### Building the Image
To build the image locally:
```bash
docker build -t git.khoavo.myds.me/vndangkhoa/kv-tube:v5 .
```
To build and push to your registry:
```bash
docker build -t git.khoavo.myds.me/vndangkhoa/kv-tube:v5 .
docker push git.khoavo.myds.me/vndangkhoa/kv-tube:v5
```
## Deployment on Synology NAS
We recommend using **Container Manager** (DSM 7.2+) or **Docker** (DSM 6/7.1) for a robust and easily manageable deployment. We recommend using **Container Manager** (DSM 7.2+) or **Docker** (DSM 6/7.1) for a robust and easily manageable deployment.
### 1. Prerequisites ### 1. Prerequisites
- **Container Manager** or **Docker** package installed from Package Center. - **Container Manager** or **Docker** package installed from Package Center.
- Ensure port `5011` is available on your NAS. - Ensure ports `5011` (frontend) and `8080` (backend API) are available on your NAS.
- Create a folder named `kv-tube` in your `docker` shared folder (e.g., `/volume1/docker/kv-tube`). - Create a folder named `kv-tube` in your `docker` shared folder (e.g., `/volume1/docker/kv-tube`).
### 2. Using Container Manager (Recommended) ### 2. Using Container Manager (Recommended)
1. Open **Container Manager** > **Project** > **Create**. 1. Open **Container Manager** > **Project** > **Create**.
2. Set a Project Name (e.g., `kv-tube`). 2. Set a Project Name (e.g., `kv-tube`).
3. Set Path to `/volume1/docker/kv-tube`. 3. Set Path to `/volume1/docker/kv-tube`.
@ -41,27 +69,61 @@ version: '3.8'
services: services:
kv-tube: kv-tube:
image: git.khoavo.myds.me/vndangkhoa/kv-tube:v4.0.7 image: git.khoavo.myds.me/vndangkhoa/kv-tube:v5
container_name: kv-tube container_name: kv-tube
platform: linux/amd64 platform: linux/amd64
restart: unless-stopped restart: unless-stopped
ports: ports:
- "5011:3000" - "5011:3000"
- "8080:8080"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
environment: environment:
- KVTUBE_DATA_DIR=/app/data - KVTUBE_DATA_DIR=/app/data
- GIN_MODE=release - GIN_MODE=release
- NODE_ENV=production - NODE_ENV=production
- NEXT_PUBLIC_API_URL=http://127.0.0.1:8080
``` ```
5. Click **Next** until the end and **Done**. The container will build and start automatically. 5. Click **Next** until the end and **Done**. The container will build and start automatically.
### 3. Accessing the App ### 3. Accessing the App
The application will be accessible at: The application will be accessible at:
- `http://<your-nas-ip>:5011` - **Frontend**: `http://<your-nas-ip>:5011`
- **Backend API**: `http://<your-nas-ip>:8080`
- **Mobile Users**: Add to Home Screen via Safari for the full PWA experience with background playback. - **Mobile Users**: Add to Home Screen via Safari for the full PWA experience with background playback.
### 4. Volume Permissions (If Needed)
If you encounter permission issues with the data folder, SSH into your NAS and run:
```bash
# Create the data folder with proper permissions
sudo mkdir -p /volume1/docker/kv-tube/data
sudo chmod 755 /volume1/docker/kv-tube/data
```
### 5. Updating the Container
To update to a new version:
```bash
# Pull the latest image
docker pull git.khoavo.myds.me/vndangkhoa/kv-tube:v5
# Restart the container
docker-compose down && docker-compose up -d
```
Or use Container Manager's built-in image update feature.
### 6. Troubleshooting
- **Container won't start**: Check logs via Container Manager or `docker logs kv-tube`
- **Port conflicts**: Ensure ports 5011 and 8080 are not used by other services
- **Permission denied**: Check the data folder permissions on your NAS
- **Slow playback**: Try lowering video quality or ensure sufficient network bandwidth
## Development ## Development
- Frontend builds can be started in `frontend/` via `npm run dev`. - Frontend builds can be started in `frontend/` via `npm run dev`.

View file

@ -5,17 +5,19 @@ version: '3.8'
services: services:
kv-tube: kv-tube:
image: git.khoavo.myds.me/vndangkhoa/kv-tube:v4.0.9 image: git.khoavo.myds.me/vndangkhoa/kv-tube:v5
container_name: kv-tube container_name: kv-tube
platform: linux/amd64 platform: linux/amd64
restart: unless-stopped restart: unless-stopped
ports: ports:
- "5011:3000" - "5011:3000"
- "8080:8080"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
environment: environment:
- KVTUBE_DATA_DIR=/app/data - KVTUBE_DATA_DIR=/app/data
- GIN_MODE=release - GIN_MODE=release
- NODE_ENV=production - NODE_ENV=production
- NEXT_PUBLIC_API_URL=http://127.0.0.1:8080
labels: labels:
- "com.centurylinklabs.watchtower.enable=true" - "com.centurylinklabs.watchtower.enable=true"

View file

@ -191,7 +191,20 @@ export interface CommentData {
} }
export async function getVideoComments(videoId: string, limit: number = 30): Promise<CommentData[]> { export async function getVideoComments(videoId: string, limit: number = 30): Promise<CommentData[]> {
const res = await fetch(`${API_BASE}/api/comments?v=${videoId}&limit=${limit}`, { cache: 'no-store' }); try {
if (!res.ok) return []; const res = await fetch(`${API_BASE}/api/comments?v=${videoId}&limit=${limit}`, { cache: 'no-store' });
return res.json(); if (!res.ok) {
console.error('Comments API error:', res.status, res.statusText);
return [];
}
const data = await res.json();
if (!Array.isArray(data)) {
console.error('Comments API returned non-array:', data);
return [];
}
return data;
} catch (err) {
console.error('Failed to fetch comments:', err);
return [];
}
} }

View file

@ -2,7 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { useState } from 'react'; import { useState, useCallback } from 'react';
interface VideoData { interface VideoData {
id: string; id: string;
@ -31,10 +31,20 @@ function getRelativeTime(id: string): string {
import { memo } from 'react'; import { memo } from 'react';
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannelAvatar?: boolean }) { function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannelAvatar?: boolean }) {
const relativeTime = video.uploaded_date || getRelativeTime(video.id); const relativeTime = video.uploaded_date || getRelativeTime(video.id);
const [isNavigating, setIsNavigating] = useState(false); const [isNavigating, setIsNavigating] = useState(false);
const destination = video.list_id ? `/watch?v=${video.id}&list=${video.list_id}` : `/watch?v=${video.id}`; const destination = video.list_id ? `/watch?v=${video.id}&list=${video.list_id}` : `/watch?v=${video.id}`;
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
const handleImageError = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.target as HTMLImageElement;
if (img.src !== DEFAULT_THUMBNAIL) {
img.src = DEFAULT_THUMBNAIL;
}
}, []);
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', width: '100%', marginBottom: '12px' }} className="videocard-container"> <div style={{ display: 'flex', flexDirection: 'column', gap: '12px', width: '100%', marginBottom: '12px' }} className="videocard-container">
@ -44,19 +54,14 @@ function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannel
style={{ position: 'relative', display: 'block', width: '100%', aspectRatio: '16/9', overflow: 'hidden', borderRadius: '12px' }} style={{ position: 'relative', display: 'block', width: '100%', aspectRatio: '16/9', overflow: 'hidden', borderRadius: '12px' }}
> >
<Image <Image
src={video.thumbnail} src={thumbnailSrc}
alt={video.title} alt={video.title}
fill fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
style={{ objectFit: 'cover', backgroundColor: 'var(--yt-hover)' }} style={{ objectFit: 'cover', backgroundColor: 'var(--yt-hover)' }}
className="videocard-thumb" className="videocard-thumb"
priority={false} priority={false}
onError={(e) => { onError={handleImageError}
const img = e.target as HTMLImageElement;
if (img.src !== 'https://i.ytimg.com/vi/default/hqdefault.jpg') {
img.src = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
}
}}
/> />
{video.duration && !video.is_mix && ( {video.duration && !video.is_mix && (
<div className="duration-badge" style={{ position: 'absolute', bottom: '8px', right: '8px' }}> <div className="duration-badge" style={{ position: 'absolute', bottom: '8px', right: '8px' }}>

View file

@ -1,7 +1,9 @@
import Link from 'next/link'; 'use client';
export const dynamic = 'force-dynamic'; import Link from 'next/link';
export const revalidate = 0; import { useState, useEffect, useCallback } from 'react';
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
interface VideoData { interface VideoData {
id: string; id: string;
@ -10,6 +12,7 @@ interface VideoData {
thumbnail: string; thumbnail: string;
view_count: number; view_count: number;
duration: string; duration: string;
uploaded_date?: string;
} }
interface Subscription { interface Subscription {
@ -19,98 +22,188 @@ interface Subscription {
channel_avatar: string; channel_avatar: string;
} }
async function getHistory() {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/history?limit=20`, { cache: 'no-store' });
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
async function getSubscriptions() {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/subscriptions`, { cache: 'no-store' });
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
function formatViews(views: number): string { function formatViews(views: number): string {
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M'; if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
if (views >= 1000) return (views / 1000).toFixed(1) + 'K'; if (views >= 1000) return (views / 1000).toFixed(1) + 'K';
return views.toString(); return views.toString();
} }
export default async function LibraryPage() { function getRelativeTime(id: string): string {
const [history, subscriptions] = await Promise.all([getHistory(), getSubscriptions()]); const times = ['2 hours ago', '5 hours ago', '1 day ago', '3 days ago', '1 week ago', '2 weeks ago', '1 month ago'];
const index = (id.charCodeAt(0) || 0) % times.length;
return times[index];
}
function HistoryVideoCard({ video }: { video: VideoData }) {
const relativeTime = video.uploaded_date || getRelativeTime(video.id);
const destination = `/watch?v=${video.id}`;
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
const handleImageError = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.target as HTMLImageElement;
if (img.src !== DEFAULT_THUMBNAIL) {
img.src = DEFAULT_THUMBNAIL;
}
}, []);
return (
<Link
href={destination}
className="videocard-container card-hover-lift"
style={{
display: 'flex',
flexDirection: 'column',
gap: '12px',
borderRadius: '12px',
overflow: 'hidden',
}}
>
<div style={{ position: 'relative', aspectRatio: '16/9', borderRadius: '12px', overflow: 'hidden' }}>
<img
src={thumbnailSrc}
alt={video.title}
className="videocard-thumb"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={handleImageError}
/>
{video.duration && (
<div className="duration-badge">{video.duration}</div>
)}
</div>
<div className="videocard-info" style={{ padding: '0 4px' }}>
<h3 style={{
fontSize: '14px',
fontWeight: '500',
lineHeight: '20px',
color: 'var(--yt-text-primary)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
marginBottom: '4px',
}}>
{video.title}
</h3>
<p style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>
{video.uploader}
</p>
{video.view_count > 0 && (
<p style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>
{formatViews(video.view_count)} views
</p>
)}
</div>
</Link>
);
}
function SubscriptionCard({ subscription }: { subscription: Subscription }) {
return (
<Link
href={`/channel/${subscription.channel_id}`}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
padding: '16px',
borderRadius: '12px',
backgroundColor: 'var(--yt-hover)',
minWidth: '120px',
transition: 'background-color 0.2s',
textDecoration: 'none',
}}
className="card-hover-lift"
>
<div style={{
width: '64px',
height: '64px',
borderRadius: '50%',
background: 'var(--yt-avatar-bg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '28px',
color: '#fff',
fontWeight: '600',
}}>
{subscription.channel_avatar || (subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?')}
</div>
<span style={{
fontSize: '14px',
fontWeight: '500',
color: 'var(--yt-text-primary)',
textAlign: 'center',
maxWidth: '100px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{subscription.channel_name || subscription.channel_id}
</span>
</Link>
);
}
export default function LibraryPage() {
const [history, setHistory] = useState<VideoData[]>([]);
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchData() {
try {
const [historyRes, subsRes] = await Promise.all([
fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/history?limit=20`, { cache: 'no-store' }),
fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/subscriptions`, { cache: 'no-store' })
]);
const historyData = await historyRes.json();
const subsData = await subsRes.json();
setHistory(Array.isArray(historyData) ? historyData : []);
setSubscriptions(Array.isArray(subsData) ? subsData : []);
} catch (err) {
console.error('Failed to fetch library data:', err);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
if (loading) {
return (
<div style={{ padding: '48px', textAlign: 'center' }}>
<div style={{
width: '40px', height: '40px',
border: '3px solid var(--yt-border)',
borderTopColor: 'var(--yt-brand-red)',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto'
}}></div>
</div>
);
}
return ( return (
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}> <div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
{/* Subscriptions Section */}
{subscriptions.length > 0 && ( {subscriptions.length > 0 && (
<section style={{ marginBottom: '40px' }}> <section style={{ marginBottom: '40px' }}>
<h2 className="section-heading" style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}> <h2 style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
Subscriptions Subscriptions
</h2> </h2>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
{subscriptions.map((sub) => ( {subscriptions.map((sub) => (
<Link <SubscriptionCard key={sub.channel_id} subscription={sub} />
key={sub.channel_id}
href={`/channel/${sub.channel_id}`}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
padding: '16px',
borderRadius: '12px',
backgroundColor: 'var(--yt-hover)',
minWidth: '120px',
transition: 'background-color 0.2s',
}}
className="card-hover-lift"
>
<div style={{
width: '64px',
height: '64px',
borderRadius: '50%',
background: 'var(--yt-avatar-bg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '28px',
color: '#fff',
fontWeight: '600',
}}>
{sub.channel_avatar || (sub.channel_name ? sub.channel_name[0].toUpperCase() : '?')}
</div>
<span style={{
fontSize: '14px',
fontWeight: '500',
color: 'var(--yt-text-primary)',
textAlign: 'center',
maxWidth: '100px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{sub.channel_name || sub.channel_id}
</span>
</Link>
))} ))}
</div> </div>
</section> </section>
)} )}
{/* Watch History Section */}
<section> <section>
<h2 className="section-heading" style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}> <h2 style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
Watch History Watch History
</h2> </h2>
{history.length === 0 ? ( {history.length === 0 ? (
@ -131,65 +224,7 @@ export default async function LibraryPage() {
gap: '16px', gap: '16px',
}}> }}>
{history.map((video) => ( {history.map((video) => (
<Link <HistoryVideoCard key={video.id} video={video} />
key={video.id}
href={`/watch?v=${video.id}`}
className="videocard-container card-hover-lift"
style={{
display: 'flex',
flexDirection: 'column',
gap: '12px',
borderRadius: '12px',
overflow: 'hidden',
}}
>
<div style={{ position: 'relative', aspectRatio: '16/9', borderRadius: '12px', overflow: 'hidden' }}>
<img
src={video.thumbnail}
alt={video.title}
className="videocard-thumb"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
const img = e.target as HTMLImageElement;
if (img.src !== 'https://i.ytimg.com/vi/default/hqdefault.jpg') {
img.src = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
}
}}
/>
{video.duration && (
<div className="duration-badge">{video.duration}</div>
)}
</div>
<div className="videocard-info" style={{ padding: '0 4px' }}>
<h3 style={{
fontSize: '14px',
fontWeight: '500',
lineHeight: '20px',
color: 'var(--yt-text-primary)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
marginBottom: '4px',
}}>
{video.title}
</h3>
<p style={{
fontSize: '12px',
color: 'var(--yt-text-secondary)',
}}>
{video.uploader}
</p>
{video.view_count > 0 && (
<p style={{
fontSize: '12px',
color: 'var(--yt-text-secondary)',
}}>
{formatViews(video.view_count)} views
</p>
)}
</div>
</Link>
))} ))}
</div> </div>
)} )}

View file

@ -1,7 +1,9 @@
import Link from 'next/link'; 'use client';
export const dynamic = 'force-dynamic'; import Link from 'next/link';
export const revalidate = 0; import { useState, useEffect, useCallback } from 'react';
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
interface VideoData { interface VideoData {
id: string; id: string;
@ -11,6 +13,7 @@ interface VideoData {
thumbnail: string; thumbnail: string;
view_count: number; view_count: number;
duration: string; duration: string;
uploaded_date?: string;
} }
interface Subscription { interface Subscription {
@ -20,27 +23,14 @@ interface Subscription {
channel_avatar: string; channel_avatar: string;
} }
async function getSubscriptions() { interface ChannelVideos {
try { subscription: Subscription;
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/subscriptions`, { cache: 'no-store' }); videos: VideoData[];
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
} }
async function getChannelVideos(channelId: string, limit: number = 5) { const INITIAL_ROWS = 2;
try { const VIDEOS_PER_ROW = 5;
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/channel/videos?id=${channelId}&limit=${limit}`, { cache: 'no-store' }); const MAX_ROWS = 5;
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
function formatViews(views: number): string { function formatViews(views: number): string {
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M'; if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
@ -48,10 +38,203 @@ function formatViews(views: number): string {
return views.toString(); return views.toString();
} }
export default async function SubscriptionsPage() { function getRelativeTime(id: string): string {
const subscriptions = await getSubscriptions(); const times = ['2 hours ago', '5 hours ago', '1 day ago', '3 days ago', '1 week ago', '2 weeks ago', '1 month ago'];
const index = (id.charCodeAt(0) || 0) % times.length;
return times[index];
}
if (subscriptions.length === 0) { function ChannelSection({ channelVideos, defaultExpanded = false }: { channelVideos: ChannelVideos; defaultExpanded?: boolean }) {
const { subscription, videos } = channelVideos;
const [expanded, setExpanded] = useState(defaultExpanded);
const handleImageError = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.target as HTMLImageElement;
if (img.src !== DEFAULT_THUMBNAIL) {
img.src = DEFAULT_THUMBNAIL;
}
}, []);
if (videos.length === 0) return null;
const initialCount = INITIAL_ROWS * VIDEOS_PER_ROW;
const maxCount = MAX_ROWS * VIDEOS_PER_ROW;
const displayedVideos = expanded ? videos.slice(0, maxCount) : videos.slice(0, initialCount);
const hasMore = videos.length > initialCount;
return (
<section style={{ marginBottom: '32px' }}>
<Link
href={`/channel/${subscription.channel_id}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '16px',
padding: '0 12px',
}}
>
<div style={{
width: '40px',
height: '40px',
borderRadius: '50%',
background: 'var(--yt-avatar-bg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
color: '#fff',
fontWeight: '600',
}}>
{subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?'}
</div>
<h2 style={{ fontSize: '18px', fontWeight: '500', color: 'var(--yt-text-primary)' }}>
{subscription.channel_name || subscription.channel_id}
</h2>
</Link>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
gap: '16px',
padding: '0 12px',
}}>
{displayedVideos.map((video) => {
const relativeTime = video.uploaded_date || getRelativeTime(video.id);
const destination = `/watch?v=${video.id}`;
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
return (
<Link
key={video.id}
href={destination}
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
borderRadius: '12px',
overflow: 'hidden',
}}
className="card-hover-lift"
>
<div style={{ position: 'relative', aspectRatio: '16/9', borderRadius: '12px', overflow: 'hidden' }}>
<img
src={thumbnailSrc}
alt={video.title}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={handleImageError}
/>
{video.duration && (
<div className="duration-badge">{video.duration}</div>
)}
</div>
<h3 style={{
fontSize: '14px',
fontWeight: '500',
lineHeight: '20px',
color: 'var(--yt-text-primary)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
margin: 0,
}}>
{video.title}
</h3>
<p style={{ fontSize: '12px', color: 'var(--yt-text-secondary)', margin: 0 }}>
{formatViews(video.view_count)} views {relativeTime}
</p>
</Link>
);
})}
</div>
{hasMore && (
<div style={{ padding: '16px 12px 0', textAlign: 'left' }}>
<button
onClick={(e) => {
e.preventDefault();
setExpanded(!expanded);
}}
style={{
background: 'transparent',
border: 'none',
color: 'var(--yt-text-secondary)',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
padding: '8px 16px',
borderRadius: '18px',
transition: 'background-color 0.2s',
}}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = 'var(--yt-hover)';
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
{expanded ? 'Show less' : `Show more (${videos.length - initialCount} more)`}
</button>
</div>
)}
</section>
);
}
export default function SubscriptionsPage() {
const [channelsVideos, setChannelsVideos] = useState<ChannelVideos[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchData() {
try {
const subsRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/subscriptions`, { cache: 'no-store' });
const subsData = await subsRes.json();
const subs = Array.isArray(subsData) ? subsData : [];
const channelVideos: ChannelVideos[] = [];
for (const sub of subs) {
const videosRes = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'}/api/channel/videos?id=${sub.channel_id}&limit=${MAX_ROWS * VIDEOS_PER_ROW}`,
{ cache: 'no-store' }
);
const videosData = await videosRes.json();
const videos = Array.isArray(videosData) ? videosData : [];
if (videos.length > 0) {
channelVideos.push({
subscription: sub,
videos: videos.map((v: VideoData) => ({ ...v, uploader: sub.channel_name }))
});
}
}
setChannelsVideos(channelVideos);
} catch (err) {
console.error('Failed to fetch subscriptions:', err);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
if (loading) {
return (
<div style={{ padding: '48px', textAlign: 'center' }}>
<div style={{
width: '40px', height: '40px',
border: '3px solid var(--yt-border)',
borderTopColor: 'var(--yt-brand-red)',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto'
}}></div>
</div>
);
}
if (channelsVideos.length === 0) {
return ( return (
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--yt-text-secondary)' }}> <div style={{ padding: '48px', textAlign: 'center', color: 'var(--yt-text-secondary)' }}>
<h2 style={{ marginBottom: '16px', color: 'var(--yt-text-primary)' }}>No subscriptions yet</h2> <h2 style={{ marginBottom: '16px', color: 'var(--yt-text-primary)' }}>No subscriptions yet</h2>
@ -60,102 +243,12 @@ export default async function SubscriptionsPage() {
); );
} }
const videosPerChannel = await Promise.all(
subscriptions.map(async (sub) => ({
subscription: sub,
videos: await getChannelVideos(sub.channel_id, 5),
}))
);
return ( return (
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}> <div style={{ padding: '12px', maxWidth: '1400px', margin: '0 auto' }}>
<h1 style={{ fontSize: '24px', fontWeight: '600', marginBottom: '24px' }}>Subscriptions</h1> <h1 style={{ fontSize: '24px', fontWeight: '600', marginBottom: '24px', padding: '0 12px' }}>Subscriptions</h1>
{videosPerChannel.map(({ subscription, videos }) => ( {channelsVideos.map((channelData) => (
<section key={subscription.channel_id} style={{ marginBottom: '32px' }}> <ChannelSection key={channelData.subscription.channel_id} channelVideos={channelData} />
<Link
href={`/channel/${subscription.channel_id}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '16px',
}}
>
<div style={{
width: '40px',
height: '40px',
borderRadius: '50%',
background: 'var(--yt-avatar-bg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
color: '#fff',
fontWeight: '600',
}}>
{subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?'}
</div>
<h2 style={{ fontSize: '18px', fontWeight: '500' }}>{subscription.channel_name || subscription.channel_id}</h2>
</Link>
{videos.length > 0 ? (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
gap: '16px',
}}>
{videos.map((video) => (
<Link
key={video.id}
href={`/watch?v=${video.id}`}
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
borderRadius: '12px',
overflow: 'hidden',
}}
className="card-hover-lift"
>
<div style={{ position: 'relative', aspectRatio: '16/9', borderRadius: '12px', overflow: 'hidden' }}>
<img
src={video.thumbnail}
alt={video.title}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
const img = e.target as HTMLImageElement;
if (img.src !== 'https://i.ytimg.com/vi/default/hqdefault.jpg') {
img.src = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
}
}}
/>
{video.duration && (
<div className="duration-badge">{video.duration}</div>
)}
</div>
<h3 style={{
fontSize: '14px',
fontWeight: '500',
lineHeight: '20px',
color: 'var(--yt-text-primary)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}>
{video.title}
</h3>
<p style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>
{formatViews(video.view_count)} views
</p>
</Link>
))}
</div>
) : (
<p style={{ color: 'var(--yt-text-secondary)', fontSize: '14px' }}>No videos available</p>
)}
</section>
))} ))}
</div> </div>
); );

View file

@ -23,8 +23,12 @@ export default function Comments({ videoId }: CommentsProps) {
getVideoComments(videoId, 40) getVideoComments(videoId, 40)
.then(data => { .then(data => {
if (isMounted) { if (isMounted) {
const topLevel = data.filter(c => !c.is_reply); if (data.length === 0) {
setComments(topLevel); setError(true);
} else {
const topLevel = data.filter(c => !c.is_reply);
setComments(topLevel);
}
setIsLoading(false); setIsLoading(false);
} }
}) })

View file

@ -5,6 +5,8 @@ import Image from 'next/image';
import { VideoData } from '../constants'; import { VideoData } from '../constants';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
interface PlaylistPanelProps { interface PlaylistPanelProps {
videos: VideoData[]; videos: VideoData[];
currentVideoId: string; currentVideoId: string;
@ -12,11 +14,17 @@ interface PlaylistPanelProps {
title: string; title: string;
} }
function handleImageError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement;
if (img.src !== DEFAULT_THUMBNAIL) {
img.src = DEFAULT_THUMBNAIL;
}
}
export default function PlaylistPanel({ videos, currentVideoId, listId, title }: PlaylistPanelProps) { export default function PlaylistPanel({ videos, currentVideoId, listId, title }: PlaylistPanelProps) {
const currentIndex = videos.findIndex(v => v.id === currentVideoId); const currentIndex = videos.findIndex(v => v.id === currentVideoId);
const activeItemRef = useRef<HTMLAnchorElement>(null); const activeItemRef = useRef<HTMLAnchorElement>(null);
// Auto-scroll to active item on mount
useEffect(() => { useEffect(() => {
if (activeItemRef.current) { if (activeItemRef.current) {
activeItemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); activeItemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
@ -35,7 +43,6 @@ export default function PlaylistPanel({ videos, currentVideoId, listId, title }:
marginBottom: '24px', marginBottom: '24px',
border: '1px solid var(--yt-border)' border: '1px solid var(--yt-border)'
}}> }}>
{/* Header */}
<div style={{ <div style={{
padding: '16px', padding: '16px',
borderBottom: '1px solid var(--yt-border)', borderBottom: '1px solid var(--yt-border)',
@ -49,7 +56,6 @@ export default function PlaylistPanel({ videos, currentVideoId, listId, title }:
</div> </div>
</div> </div>
{/* List */}
<div style={{ <div style={{
overflowY: 'auto', overflowY: 'auto',
flex: 1, flex: 1,
@ -57,6 +63,8 @@ export default function PlaylistPanel({ videos, currentVideoId, listId, title }:
}}> }}>
{videos.map((video, index) => { {videos.map((video, index) => {
const isActive = video.id === currentVideoId; const isActive = video.id === currentVideoId;
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
return ( return (
<Link <Link
key={video.id} key={video.id}
@ -73,7 +81,6 @@ export default function PlaylistPanel({ videos, currentVideoId, listId, title }:
}} }}
className="playlist-item-hover" className="playlist-item-hover"
> >
{/* Number or Playing Icon */}
<div style={{ <div style={{
width: '24px', width: '24px',
fontSize: '12px', fontSize: '12px',
@ -84,7 +91,6 @@ export default function PlaylistPanel({ videos, currentVideoId, listId, title }:
{isActive ? '▶' : index + 1} {isActive ? '▶' : index + 1}
</div> </div>
{/* Thumbnail */}
<div style={{ <div style={{
position: 'relative', position: 'relative',
width: '100px', width: '100px',
@ -93,19 +99,14 @@ export default function PlaylistPanel({ videos, currentVideoId, listId, title }:
borderRadius: '8px', borderRadius: '8px',
overflow: 'hidden' overflow: 'hidden'
}}> }}>
<Image <Image
src={video.thumbnail} src={thumbnailSrc}
alt={video.title} alt={video.title}
fill fill
sizes="100px" sizes="100px"
style={{ objectFit: 'cover' }} style={{ objectFit: 'cover' }}
onError={(e) => { onError={handleImageError}
const img = e.target as HTMLImageElement; />
if (img.src !== 'https://i.ytimg.com/vi/default/hqdefault.jpg') {
img.src = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
}
}}
/>
{video.duration && ( {video.duration && (
<div style={{ <div style={{
position: 'absolute', position: 'absolute',
@ -123,7 +124,6 @@ export default function PlaylistPanel({ videos, currentVideoId, listId, title }:
)} )}
</div> </div>
{/* Info */}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, justifyContent: 'center' }}> <div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, justifyContent: 'center' }}>
<h4 style={{ <h4 style={{
margin: 0, margin: 0,

View file

@ -1,6 +1,9 @@
'use client';
import Link from 'next/link'; import Link from 'next/link';
import { API_BASE } from '../constants'; import { useState, useEffect, useCallback } from 'react';
import NextVideoClient from './NextVideoClient';
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
interface VideoData { interface VideoData {
id: string; id: string;
@ -12,16 +15,9 @@ interface VideoData {
duration: string; duration: string;
} }
async function getRelatedVideos(videoId: string, title: string, uploader: string) { interface RelatedVideosProps {
try { initialVideos: VideoData[];
const params = new URLSearchParams({ v: videoId, title: title || '', uploader: uploader || '', limit: '15' }); nextVideoId: string;
const res = await fetch(`${API_BASE}/api/related?${params.toString()}`, { cache: 'no-store' });
if (!res.ok) return [];
return res.json() as Promise<VideoData[]>;
} catch (e) {
console.error(e);
return [];
}
} }
function formatViews(views: number): string { function formatViews(views: number): string {
@ -30,50 +26,70 @@ function formatViews(views: number): string {
return views.toString(); return views.toString();
} }
export default async function RelatedVideos({ videoId, title, uploader }: { videoId: string, title: string, uploader: string }) { function RelatedVideoItem({ video, index }: { video: VideoData; index: number }) {
const relatedVideos = await getRelatedVideos(videoId, title, uploader); const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
const views = formatViews(video.view_count);
const staggerClass = `stagger-${Math.min(index + 1, 6)}`;
if (relatedVideos.length === 0) { const handleImageError = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.target as HTMLImageElement;
if (img.src !== DEFAULT_THUMBNAIL) {
img.src = DEFAULT_THUMBNAIL;
}
}, []);
return (
<Link
key={video.id}
href={`/watch?v=${video.id}`}
className={`related-video-item fade-in-up ${staggerClass}`}
style={{ opacity: 1 }}
>
<div className="related-thumb-container">
<img
src={thumbnailSrc}
alt={video.title}
className="related-thumb-img"
onError={handleImageError}
/>
{video.duration && (
<div className="duration-badge">
{video.duration}
</div>
)}
</div>
<div className="related-video-info">
<span className="related-video-title">{video.title}</span>
<span className="related-video-channel">{video.uploader}</span>
<span className="related-video-meta">{views} views</span>
</div>
</Link>
);
}
export default function RelatedVideos({ initialVideos, nextVideoId }: RelatedVideosProps) {
const [videos, setVideos] = useState<VideoData[]>(initialVideos);
useEffect(() => {
setVideos(initialVideos);
}, [initialVideos]);
if (videos.length === 0) {
return <div style={{ padding: '1rem', color: '#888' }}>No related videos found.</div>; return <div style={{ padding: '1rem', color: '#888' }}>No related videos found.</div>;
} }
const nextVideoId = relatedVideos[0].id;
return ( return (
<div className="watch-related-list"> <div className="watch-related-list">
<NextVideoClient videoId={nextVideoId} /> <Link href={`/watch?v=${nextVideoId}`} className="related-video-item fade-in-up" style={{ opacity: 1 }}>
{relatedVideos.map((video, i) => { <div className="related-thumb-container">
const views = formatViews(video.view_count); <div className="next-up-overlay">
const staggerClass = `stagger-${Math.min(i + 1, 6)}`; <span>UP NEXT</span>
</div>
return ( </div>
<Link key={video.id} href={`/watch?v=${video.id}`} className={`related-video-item fade-in-up ${staggerClass}`} style={{ opacity: 0 }}> </Link>
<div className="related-thumb-container"> {videos.map((video, i) => (
<img <RelatedVideoItem key={video.id} video={video} index={i} />
src={video.thumbnail} ))}
alt={video.title}
className="related-thumb-img"
onError={(e) => {
const img = e.target as HTMLImageElement;
if (img.src !== 'https://i.ytimg.com/vi/default/hqdefault.jpg') {
img.src = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
}
}}
/>
{video.duration && (
<div className="duration-badge">
{video.duration}
</div>
)}
</div>
<div className="related-video-info">
<span className="related-video-title">{video.title}</span>
<span className="related-video-channel">{video.uploader}</span>
<span className="related-video-meta">{views} views</span>
</div>
</Link>
);
})}
</div> </div>
); );
} }

View file

@ -396,9 +396,11 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
// Enhance buffer to mitigate Safari slow loading and choppiness // Enhance buffer to mitigate Safari slow loading and choppiness
const hls = new window.Hls({ const hls = new window.Hls({
maxBufferLength: 60, maxBufferLength: 120,
maxMaxBufferLength: 120, maxMaxBufferLength: 240,
enableWorker: true, enableWorker: true,
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 5,
xhrSetup: (xhr: XMLHttpRequest) => { xhrSetup: (xhr: XMLHttpRequest) => {
xhr.setRequestHeader('Referer', 'https://www.youtube.com/'); xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
}, },
@ -444,9 +446,11 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
if (audioHlsRef.current) audioHlsRef.current.destroy(); if (audioHlsRef.current) audioHlsRef.current.destroy();
const audioHls = new window.Hls({ const audioHls = new window.Hls({
maxBufferLength: 60, maxBufferLength: 120,
maxMaxBufferLength: 120, maxMaxBufferLength: 240,
enableWorker: true, enableWorker: true,
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 5,
xhrSetup: (xhr: XMLHttpRequest) => { xhrSetup: (xhr: XMLHttpRequest) => {
xhr.setRequestHeader('Referer', 'https://www.youtube.com/'); xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
}, },
@ -485,11 +489,23 @@ export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
video.addEventListener('pause', handlePauseForBackground); video.addEventListener('pause', handlePauseForBackground);
// Keep playing when page visibility changes
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && !video.paused) {
video.play().catch(() => {});
if (audioRef.current && audioRef.current.paused) {
audioRef.current.play().catch(() => {});
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => { return () => {
video.removeEventListener('pause', handlePauseForBackground); video.removeEventListener('pause', handlePauseForBackground);
video.removeEventListener('play', handlePlay); video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause); video.removeEventListener('pause', handlePause);
video.removeEventListener('ended', handleEnded); video.removeEventListener('ended', handleEnded);
document.removeEventListener('visibilitychange', handleVisibilityChange);
releaseWakeLock(); releaseWakeLock();
}; };
}; };

View file

@ -132,12 +132,13 @@ export default async function WatchPage({
const baseInfo = isMix ? await getVideoInfo(mixBaseId) : info; const baseInfo = isMix ? await getVideoInfo(mixBaseId) : info;
// Seed the playlist with the base video // Seed the playlist with the base video
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
if (baseInfo) { if (baseInfo) {
playlistVideos.push({ playlistVideos.push({
id: mixBaseId, id: mixBaseId,
title: baseInfo.title, title: baseInfo.title,
uploader: baseInfo.uploader, uploader: baseInfo.uploader,
thumbnail: baseInfo.thumbnail || `https://i.ytimg.com/vi/${mixBaseId}/maxresdefault.jpg`, thumbnail: baseInfo.thumbnail || `https://i.ytimg.com/vi/${mixBaseId}/hqdefault.jpg` || DEFAULT_THUMBNAIL,
view_count: baseInfo.view_count, view_count: baseInfo.view_count,
duration: '' duration: ''
}); });
@ -149,9 +150,9 @@ export default async function WatchPage({
const titleKeywords = videoTitle.split(/[\s\-|]+/).filter((w: string) => w.length > 2).slice(0, 4).join(' '); const titleKeywords = videoTitle.split(/[\s\-|]+/).filter((w: string) => w.length > 2).slice(0, 4).join(' ');
const mixPromises = [ const mixPromises = [
uploaderName ? getSearchVideos(uploaderName, 10) : Promise.resolve([]), uploaderName ? getSearchVideos(uploaderName, 20) : Promise.resolve([]),
titleKeywords ? getSearchVideos(titleKeywords, 10) : Promise.resolve([]), titleKeywords ? getSearchVideos(titleKeywords, 20) : Promise.resolve([]),
getRelatedVideos(mixBaseId, 10), getRelatedVideos(mixBaseId, 20),
]; ];
const [byUploader, byTitle, byRelated] = await Promise.all(mixPromises); const [byUploader, byTitle, byRelated] = await Promise.all(mixPromises);
@ -159,7 +160,7 @@ export default async function WatchPage({
const sources = [byUploader, byTitle, byRelated]; const sources = [byUploader, byTitle, byRelated];
let added = 0; let added = 0;
const maxPlaylist = 25; const maxPlaylist = 50;
let idx = [0, 0, 0]; let idx = [0, 0, 0];
while (added < maxPlaylist) { while (added < maxPlaylist) {
@ -169,7 +170,10 @@ export default async function WatchPage({
const vid = sources[s][idx[s]++]; const vid = sources[s][idx[s]++];
if (!seenMixIds.has(vid.id)) { if (!seenMixIds.has(vid.id)) {
seenMixIds.add(vid.id); seenMixIds.add(vid.id);
playlistVideos.push(vid); playlistVideos.push({
...vid,
thumbnail: vid.thumbnail || `https://i.ytimg.com/vi/${vid.id}/hqdefault.jpg` || DEFAULT_THUMBNAIL
});
added++; added++;
anyAdded = true; anyAdded = true;
break; break;

View file

@ -76,7 +76,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@ -1616,7 +1615,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@ -1682,7 +1680,6 @@
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0", "@typescript-eslint/types": "8.56.0",
@ -2211,7 +2208,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -2564,7 +2560,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -3138,7 +3133,6 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -3324,7 +3318,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@ -5564,7 +5557,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -5574,7 +5566,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@ -6272,7 +6263,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -6435,7 +6425,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -6745,7 +6734,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }