feat: add watch page controls and custom KV-Tube branding
Some checks failed
Build & Push Docker Image / build (push) Failing after 0s

- Add fullscreen, loop, and wide mode toggles to watch page
- Add custom SVG icon components for consistent branding
- Update Header, Sidebar, and MobileNav with custom icons
- Add KV-Tube logo component with play button
- Create PWA icon SVGs for favicon and app icons
This commit is contained in:
Khoa Vo 2026-05-14 10:59:21 +07:00
parent 970c2f920a
commit ad5b2d871a
18 changed files with 445 additions and 223 deletions

View file

@ -3,10 +3,11 @@
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState, useRef, useEffect } from 'react';
import { IoSearchOutline, IoMoonOutline, IoSunnyOutline, IoArrowBack, IoMenuOutline } from 'react-icons/io5';
import { IoMoonOutline, IoSunnyOutline, IoArrowBack, IoMenuOutline } from 'react-icons/io5';
import RegionSelector from './RegionSelector';
import { useTheme } from '../context/ThemeContext';
import { useSidebar } from '../context/SidebarContext';
import { Logo, SearchIcon } from '../icons';
export default function Header() {
const [searchQuery, setSearchQuery] = useState('');
@ -45,8 +46,8 @@ export default function Header() {
}} title="Menu">
<IoMenuOutline size={22} />
</button>
<Link href="/" style={{ display: 'flex', alignItems: 'center', gap: '4px', marginLeft: '12px' }}>
<span style={{ fontSize: '18px', fontWeight: '700', letterSpacing: '-0.5px', fontFamily: 'YouTube Sans, Roboto, Arial, sans-serif' }} className="hidden-mobile">KV-Tube</span>
<Link href="/" style={{ display: 'flex', alignItems: 'center', marginLeft: '12px' }}>
<Logo size={24} showText className="hidden-mobile" />
</Link>
</div>
@ -54,7 +55,7 @@ export default function Header() {
<div className="yt-header-center hidden-mobile">
<form className="search-container" onSubmit={handleSearch}>
<div className="search-input-wrapper">
<IoSearchOutline size={18} className="search-input-icon" />
<SearchIcon size={18} className="search-input-icon" />
<input
ref={inputRef}
type="text"
@ -78,7 +79,7 @@ export default function Header() {
</button>
)}
<button type="submit" className="search-btn" title="Search">
<IoSearchOutline size={18} />
<SearchIcon size={18} />
</button>
</div>
</form>
@ -87,7 +88,7 @@ export default function Header() {
{/* Right - Region and Theme */}
<div className="yt-header-right">
<button className="yt-icon-btn visible-mobile" onClick={() => setIsMobileSearchActive(true)} title="Search">
<IoSearchOutline size={22} />
<SearchIcon size={22} />
</button>
<button className="yt-icon-btn" onClick={toggleTheme} title="Toggle Theme">
{theme === 'dark' ? <IoSunnyOutline size={22} /> : <IoMoonOutline size={22} />}
@ -103,7 +104,7 @@ export default function Header() {
</button>
<form className="search-container" onSubmit={handleSearch} style={{ flex: 1 }}>
<div className="search-input-wrapper">
<IoSearchOutline size={16} className="search-input-icon" />
<SearchIcon size={16} className="search-input-icon" />
<input
ref={mobileInputRef}
type="text"

View file

@ -2,17 +2,15 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { MdHomeFilled, MdOutlineSubscriptions, MdOutlineVideoLibrary } from 'react-icons/md';
import { SiYoutubeshorts } from 'react-icons/si';
import { HomeIcon, SubscriptionsIcon, LibraryIcon } from '../icons';
export default function MobileNav() {
const pathname = usePathname();
const navItems = [
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
// { icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Sub', path: '/feed/subscriptions' },
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
{ icon: <HomeIcon size={24} />, label: 'Home', path: '/' },
{ icon: <SubscriptionsIcon size={24} />, label: 'Sub', path: '/feed/subscriptions' },
{ icon: <LibraryIcon size={24} />, label: 'You', path: '/feed/library' },
];
return (

View file

@ -2,19 +2,17 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { MdHomeFilled, MdOutlineSubscriptions, MdOutlineVideoLibrary } from 'react-icons/md';
import { SiYoutubeshorts } from 'react-icons/si';
import { useSidebar } from '../context/SidebarContext';
import { HomeIcon, SubscriptionsIcon, LibraryIcon } from '../icons';
export default function Sidebar() {
const pathname = usePathname();
const { isSidebarOpen } = useSidebar();
const navItems = [
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
// { icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Sub', path: '/feed/subscriptions' },
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
{ icon: <HomeIcon size={24} />, label: 'Home', path: '/' },
{ icon: <SubscriptionsIcon size={24} />, label: 'Sub', path: '/feed/subscriptions' },
{ icon: <LibraryIcon size={24} />, label: 'You', path: '/feed/library' },
];
return (

View file

@ -0,0 +1,26 @@
'use client';
interface IconProps {
size?: number;
className?: string;
style?: React.CSSProperties;
}
export default function HomeIcon({ size = 24, className, style }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
style={style}
>
<path
d="M10 20V14H14V20H19V10H21L12 3L3 10H5V20H10Z"
fill="currentColor"
/>
</svg>
);
}

View file

@ -0,0 +1,26 @@
'use client';
interface IconProps {
size?: number;
className?: string;
style?: React.CSSProperties;
}
export default function LibraryIcon({ size = 24, className, style }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
style={style}
>
<path
d="M4 6H2V20C2 21.1 2.9 22 4 22H18V20H4V6ZM20 2H8C6.9 2 6 2.9 6 4V16C6 17.1 6.9 18 8 18H20C21.1 18 22 17.1 22 16V4C22 2.9 21.1 2 20 2ZM20 16H8V4H20V16ZM10 9H12V14H10V9ZM14 9H18V14H14V9Z"
fill="currentColor"
/>
</svg>
);
}

View file

@ -0,0 +1,46 @@
'use client';
interface LogoProps {
size?: number;
showText?: boolean;
className?: string;
}
export default function Logo({ size = 24, showText = true, className }: LogoProps) {
return (
<div
className={className}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
{/* Play Button Icon */}
<svg
width={size}
height={size}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ flexShrink: 0 }}
>
<circle cx="16" cy="16" r="15" fill="#ff0000"/>
<path d="M12 10L22 16L12 22V10Z" fill="white"/>
</svg>
{/* Text */}
{showText && (
<span style={{
fontSize: '18px',
fontWeight: '700',
letterSpacing: '-0.5px',
fontFamily: 'YouTube Sans, Roboto, Arial, sans-serif',
color: 'var(--yt-text-primary)',
}}>
KV-Tube
</span>
)}
</div>
);
}

View file

@ -0,0 +1,24 @@
'use client';
interface IconProps {
size?: number;
className?: string;
style?: React.CSSProperties;
}
export default function PlayIcon({ size = 24, className, style }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
style={style}
>
<circle cx="12" cy="12" r="10" fill="#ff0000"/>
<path d="M9.5 7.5V16.5L17.5 12L9.5 7.5Z" fill="white"/>
</svg>
);
}

View file

@ -0,0 +1,26 @@
'use client';
interface IconProps {
size?: number;
className?: string;
style?: React.CSSProperties;
}
export default function SearchIcon({ size = 24, className, style }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
style={style}
>
<path
d="M15.5 14H14.71L14.43 13.73C15.41 12.59 16 11.11 16 9.5C16 5.91 13.09 3 9.5 3C5.91 3 3 5.91 3 9.5C3 13.09 5.91 16 9.5 16C11.11 16 12.59 15.41 13.73 14.43L14 14.71V15.5L19 20.49L20.49 19L15.5 14ZM9.5 14C7.01 14 5 11.99 5 9.5C5 7.01 7.01 5 9.5 5C11.99 5 14 7.01 14 9.5C14 11.99 11.99 14 9.5 14Z"
fill="currentColor"
/>
</svg>
);
}

View file

@ -0,0 +1,24 @@
'use client';
interface IconProps {
size?: number;
className?: string;
style?: React.CSSProperties;
}
export default function ShortsIcon({ size = 24, className, style }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
style={style}
>
<rect x="2" y="4" width="20" height="16" rx="4" fill="#ff0000"/>
<path d="M10 8L16 12L10 16V8Z" fill="white"/>
</svg>
);
}

View file

@ -0,0 +1,26 @@
'use client';
interface IconProps {
size?: number;
className?: string;
style?: React.CSSProperties;
}
export default function SubscriptionsIcon({ size = 24, className, style }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
style={style}
>
<path
d="M4 6C4 4.89543 4.89543 4 6 4H18C19.1046 4 20 4.89543 20 6V18C20 19.1046 19.1046 20 18 20H6C4.89543 20 4 19.1046 4 18V6ZM6 6V18H18V6H6ZM8 8H10V10H8V8ZM8 12H10V14H8V12ZM12 8H14V10H12V8ZM12 12H14V14H12V12Z"
fill="currentColor"
/>
</svg>
);
}

View file

@ -0,0 +1,26 @@
'use client';
interface IconProps {
size?: number;
className?: string;
style?: React.CSSProperties;
}
export default function TrendingIcon({ size = 24, className, style }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
style={style}
>
<path
d="M16 6L18.29 8.29L13.41 13.17L9.41 9.17L2 16.59L3.41 18L9.41 12L13.41 16L19.71 9.71L22 12V6H16Z"
fill="currentColor"
/>
</svg>
);
}

View file

@ -0,0 +1,8 @@
export { default as Logo } from './Logo';
export { default as HomeIcon } from './HomeIcon';
export { default as TrendingIcon } from './TrendingIcon';
export { default as SubscriptionsIcon } from './SubscriptionsIcon';
export { default as LibraryIcon } from './LibraryIcon';
export { default as ShortsIcon } from './ShortsIcon';
export { default as PlayIcon } from './PlayIcon';
export { default as SearchIcon } from './SearchIcon';

View file

@ -612,6 +612,8 @@ export default function ClientWatchPage() {
const [currentIndex, setCurrentIndex] = useState(-1);
const [activeTab, setActiveTab] = useState<'upnext' | 'mix'>('upnext');
const [apiError, setApiError] = useState<string | null>(null);
const [wideMode, setWideMode] = useState(false);
const [loopMode, setLoopMode] = useState(false);
// Scroll to top when video changes or page loads
useEffect(() => {
@ -763,11 +765,11 @@ export default function ClientWatchPage() {
minHeight: '100vh',
}}>
<div className="watch-page-container" style={{
maxWidth: '1800px',
maxWidth: wideMode ? '100%' : '1800px',
margin: '0 auto',
padding: '24px',
display: 'grid',
gridTemplateColumns: '1fr 400px',
gridTemplateColumns: wideMode ? '1fr' : '1fr 400px',
gap: '24px',
}}>
{/* Main Content */}
@ -779,6 +781,7 @@ export default function ClientWatchPage() {
title={videoInfo?.title}
autoplay={true}
onVideoEnd={handleVideoEnd}
loop={loopMode}
/>
</div>
@ -790,52 +793,106 @@ export default function ClientWatchPage() {
padding: '8px 0',
gap: '8px',
}}>
<button
onClick={handlePrevious}
disabled={currentIndex <= 0}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
backgroundColor: currentIndex > 0 ? 'var(--yt-hover)' : 'transparent',
color: currentIndex > 0 ? 'var(--yt-text-primary)' : 'var(--yt-text-secondary)',
border: '1px solid var(--yt-border)',
borderRadius: '18px',
cursor: currentIndex > 0 ? 'pointer' : 'not-allowed',
fontSize: '13px',
fontWeight: '500',
opacity: currentIndex > 0 ? 1 : 0.5,
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</svg>
Previous
</button>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handlePrevious}
disabled={currentIndex <= 0}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
backgroundColor: currentIndex > 0 ? 'var(--yt-hover)' : 'transparent',
color: currentIndex > 0 ? 'var(--yt-text-primary)' : 'var(--yt-text-secondary)',
border: '1px solid var(--yt-border)',
borderRadius: '18px',
cursor: currentIndex > 0 ? 'pointer' : 'not-allowed',
fontSize: '13px',
fontWeight: '500',
opacity: currentIndex > 0 ? 1 : 0.5,
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</svg>
Previous
</button>
<button
onClick={handleNext}
disabled={currentIndex >= currentPlaylist.length - 1}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
backgroundColor: currentIndex < currentPlaylist.length - 1 ? 'var(--yt-blue)' : 'var(--yt-hover)',
color: '#fff',
border: 'none',
borderRadius: '18px',
cursor: currentIndex < currentPlaylist.length - 1 ? 'pointer' : 'not-allowed',
fontSize: '13px',
fontWeight: '500',
}}
>
Next
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
</svg>
</button>
</div>
<button
onClick={handleNext}
disabled={currentIndex >= currentPlaylist.length - 1}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
backgroundColor: currentIndex < currentPlaylist.length - 1 ? 'var(--yt-blue)' : 'var(--yt-hover)',
color: '#fff',
border: 'none',
borderRadius: '18px',
cursor: currentIndex < currentPlaylist.length - 1 ? 'pointer' : 'not-allowed',
fontSize: '13px',
fontWeight: '500',
}}
>
Next
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
</svg>
</button>
<div style={{ display: 'flex', gap: '8px' }}>
{/* Loop Toggle */}
<button
onClick={() => setLoopMode(!loopMode)}
title={loopMode ? 'Disable loop' : 'Enable loop'}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
backgroundColor: loopMode ? 'var(--yt-blue)' : 'var(--yt-hover)',
color: loopMode ? '#fff' : 'var(--yt-text-primary)',
border: 'none',
borderRadius: '18px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: '500',
transition: 'all 0.2s',
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill={loopMode ? '#fff' : 'currentColor'}>
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
</svg>
Loop
</button>
{/* Wide Mode Toggle */}
<button
onClick={() => setWideMode(!wideMode)}
title={wideMode ? 'Exit wide mode' : 'Enter wide mode'}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
backgroundColor: wideMode ? 'var(--yt-blue)' : 'var(--yt-hover)',
color: wideMode ? '#fff' : 'var(--yt-text-primary)',
border: 'none',
borderRadius: '18px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: '500',
transition: 'all 0.2s',
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill={wideMode ? '#fff' : 'currentColor'}>
<path d="M19 4H5c-1.11 0-2 .9-2 2v12c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H5V8h14v10z"/>
</svg>
Wide
</button>
</div>
</div>
{/* Video Info */}
@ -852,7 +909,7 @@ export default function ClientWatchPage() {
height: 'fit-content',
maxHeight: 'calc(100vh - 80px)',
overflowY: 'auto',
display: 'flex',
display: wideMode ? 'none' : 'flex',
flexDirection: 'column',
gap: '12px',
}}>

View file

@ -17,6 +17,7 @@ interface YouTubePlayerProps {
autoplay?: boolean;
onVideoEnd?: () => void;
onVideoReady?: () => void;
loop?: boolean;
}
function PlayerSkeleton() {
@ -40,15 +41,31 @@ export default function YouTubePlayer({
title,
autoplay = true,
onVideoEnd,
onVideoReady
onVideoReady,
loop = false
}: YouTubePlayerProps) {
const playerRef = useRef<HTMLDivElement>(null);
const playerContainerRef = useRef<HTMLDivElement>(null);
const playerInstanceRef = useRef<any>(null);
const loopRef = useRef(loop);
const [isApiReady, setIsApiReady] = useState(false);
const [isPlayerReady, setIsPlayerReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const router = useRouter();
// Keep loop ref in sync
loopRef.current = loop;
// Fullscreen change listener
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
// Load YouTube IFrame API
useEffect(() => {
if (window.YT && window.YT.Player) {
@ -134,7 +151,11 @@ export default function YouTubePlayer({
onStateChange: (event: any) => {
// Video ended
if (event.data === window.YT.PlayerState.ENDED) {
if (onVideoEnd) {
if (loopRef.current) {
// Loop mode: restart video
event.target.seekTo(0);
event.target.playVideo();
} else if (onVideoEnd) {
onVideoEnd();
}
}
@ -208,7 +229,17 @@ export default function YouTubePlayer({
}
return (
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9', backgroundColor: '#000', borderRadius: '12px', overflow: 'hidden' }}>
<div
ref={playerContainerRef}
style={{
position: 'relative',
width: '100%',
aspectRatio: '16/9',
backgroundColor: '#000',
borderRadius: isFullscreen ? '0' : '12px',
overflow: 'hidden'
}}
>
{!isPlayerReady && !error && <PlayerSkeleton />}
<div
ref={playerRef}
@ -221,6 +252,44 @@ export default function YouTubePlayer({
left: 0,
}}
/>
{/* Fullscreen button */}
<button
onClick={() => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
playerContainerRef.current?.requestFullscreen();
}
}}
style={{
position: 'absolute',
bottom: '8px',
right: '8px',
backgroundColor: 'rgba(0,0,0,0.6)',
border: 'none',
borderRadius: '4px',
padding: '6px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background-color 0.2s',
zIndex: 10,
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.8)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.6)'}
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/>
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
</svg>
)}
</button>
</div>
);
}

View file

@ -8,18 +8,12 @@
"name": "frontend",
"version": "0.1.0",
"dependencies": {
"@clappr/core": "^0.13.2",
"@clappr/player": "^0.11.16",
"@fontsource/roboto": "^5.2.9",
"@vidstack/react": "^1.12.13",
"artplayer": "^5.3.0",
"clappr": "^0.3.13",
"hls.js": "^1.6.15",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-icons": "^5.5.0",
"vidstack": "^1.12.13"
"react-icons": "^5.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@ -285,18 +279,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@clappr/core": {
"version": "0.13.2",
"resolved": "https://registry.npmjs.org/@clappr/core/-/core-0.13.2.tgz",
"integrity": "sha512-QW2wx5BHFfnoQY6biGLyVYBHCrx4amMScHCVpXZhSWgwqP6l8YNcz8fNkQuJWjLT5ISgFym3F4cxO8Imhmk2Kg==",
"license": "BSD-3-Clause"
},
"node_modules/@clappr/player": {
"version": "0.11.16",
"resolved": "https://registry.npmjs.org/@clappr/player/-/player-0.11.16.tgz",
"integrity": "sha512-A6rVmOqJ93rBJ4KDc3bFe3HHdtazC3LKGcnsw+0w52wfa8IpsiU2XpeSSLY44tybEJ8gpPKtm7vJrMf4uFVSzA==",
"license": "BSD-3-Clause"
},
"node_modules/@emnapi/core": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
@ -474,31 +456,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
"integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
"integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.4",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@fontsource/roboto": {
"version": "5.2.9",
"resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.9.tgz",
@ -1614,6 +1571,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@ -1629,12 +1587,6 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz",
@ -2186,27 +2138,11 @@
"win32"
]
},
"node_modules/@vidstack/react": {
"version": "1.12.13",
"resolved": "https://registry.npmjs.org/@vidstack/react/-/react-1.12.13.tgz",
"integrity": "sha512-zyNydy1+HtoK6cJ8EmqFNkPPGHIFMrr2KH+ef3654EqXx4IcJ8A5LCNMXBuALQE8IMxtk040JMoR9OKyeXjBOQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.10",
"media-captions": "^1.0.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@ -2435,15 +2371,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/artplayer": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/artplayer/-/artplayer-5.3.0.tgz",
"integrity": "sha512-yExO39MpEg4P+bxgChxx1eJfiUPE4q1QQRLCmqGhlsj+ANuaoEkR8hF93LdI5ZyrAcIbJkuEndxEiUoKobifDw==",
"license": "MIT",
"dependencies": {
"option-validator": "^2.0.6"
}
},
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@ -2671,13 +2598,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/clappr": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/clappr/-/clappr-0.3.13.tgz",
"integrity": "sha512-cAtGhtSAYIavKqVQb/wX5pB9twb/W7gFUGBaGHy6dbpUqCtCmtH0Eu05rNSwtbaREWgSTUJyEMTgaNSZ/62KlQ==",
"deprecated": "This version is no longer supported. Please use the @clappr/player versions instead.",
"license": "BSD-3-Clause"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@ -2737,6 +2657,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@ -4611,15 +4532,6 @@
"json-buffer": "3.0.1"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/language-subtag-registry": {
"version": "0.3.23",
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
@ -4915,15 +4827,6 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lit-html": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz",
"integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -4990,15 +4893,6 @@
"node": ">= 0.4"
}
},
"node_modules/media-captions": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/media-captions/-/media-captions-1.0.4.tgz",
"integrity": "sha512-cyDNmuZvvO4H27rcBq2Eudxo9IZRDCOX/I7VEyqbxsEiD2Ei7UYUhG/Sc5fvMZjmathgz3fEK7iAKqvpY+Ux1w==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -5324,15 +5218,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/option-validator": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/option-validator/-/option-validator-2.0.6.tgz",
"integrity": "sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==",
"license": "MIT",
"dependencies": {
"kind-of": "^6.0.3"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -6483,19 +6368,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/unplugin": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz",
"integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==",
"license": "MIT",
"dependencies": {
"acorn": "^8.14.0",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/unrs-resolver": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
@ -6572,27 +6444,6 @@
"punycode": "^2.1.0"
}
},
"node_modules/vidstack": {
"version": "1.12.13",
"resolved": "https://registry.npmjs.org/vidstack/-/vidstack-1.12.13.tgz",
"integrity": "sha512-vuNeyRmWoH/7EoFVDYjp9nkgcqtCMmal518LDeb78dYKgWb+p6+vtY0AzDhrkBv5q1UiCn+xwmjMmwvSlPLuhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.10",
"lit-html": "^2.8.0",
"media-captions": "^1.0.4",
"unplugin": "^1.12.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

5
frontend/public/icon.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#ff0000"/>
<circle cx="16" cy="16" r="10" fill="white"/>
<path d="M13 10 L22 16 L13 22 Z" fill="#ff0000"/>
</svg>

After

Width:  |  Height:  |  Size: 222 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#ff0000"/>
<circle cx="256" cy="256" r="160" fill="white"/>
<path d="M205 150 L355 256 L205 362 Z" fill="#ff0000"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

6
package-lock.json generated Normal file
View file

@ -0,0 +1,6 @@
{
"name": "kv-tube",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}