feat: youtube preview #2
5
.gitignore
vendored
5
.gitignore
vendored
@@ -32,5 +32,10 @@ uploads/*
|
|||||||
# Testing
|
# Testing
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
.playwright/
|
||||||
|
|
||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
Auto-generated from all feature plans. Last updated: 2026-01-29
|
Auto-generated from all feature plans. Last updated: 2026-01-29
|
||||||
|
|
||||||
## Active Technologies
|
## Active Technologies
|
||||||
|
- TypeScript 5.9.x (frontend + backend) + React 19.x, Tailwind CSS 4.x, shadcn/ui, Zustand, Playwright MCP (002-youtube-design-preview)
|
||||||
|
- Browser localStorage (preview persistence), file system (reference screenshots) (002-youtube-design-preview)
|
||||||
|
|
||||||
- TypeScript 5.x (frontend + backend) (001-google-oauth-auth)
|
- TypeScript 5.x (frontend + backend) (001-google-oauth-auth)
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ npm test && npm run lint
|
|||||||
TypeScript 5.x (frontend + backend): Follow standard conventions
|
TypeScript 5.x (frontend + backend): Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 002-youtube-design-preview: Added TypeScript 5.9.x (frontend + backend) + React 19.x, Tailwind CSS 4.x, shadcn/ui, Zustand, Playwright MCP
|
||||||
|
|
||||||
- 001-google-oauth-auth: Added TypeScript 5.x (frontend + backend)
|
- 001-google-oauth-auth: Added TypeScript 5.x (frontend + backend)
|
||||||
|
|
||||||
|
|||||||
1204
backend/package-lock.json
generated
1204
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@
|
|||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
"pg": "^8.17.2",
|
"pg": "^8.17.2",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ThumbnailsModule } from './modules/thumbnails/thumbnails.module';
|
import { ThumbnailsModule } from './modules/thumbnails/thumbnails.module';
|
||||||
import { YouTubeModule } from './modules/youtube/youtube.module';
|
import { YouTubeModule } from './modules/youtube/youtube.module';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
// import { AuthModule } from './modules/auth/auth.module'; // TODO: Enable when Google OAuth is configured
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -26,7 +26,7 @@ import { AuthModule } from './modules/auth/auth.module';
|
|||||||
}),
|
}),
|
||||||
ThumbnailsModule,
|
ThumbnailsModule,
|
||||||
YouTubeModule,
|
YouTubeModule,
|
||||||
AuthModule,
|
// AuthModule, // TODO: Enable when Google OAuth is configured
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -42,7 +42,13 @@
|
|||||||
"mcp__gitea__gitea_collaborator_list",
|
"mcp__gitea__gitea_collaborator_list",
|
||||||
"mcp__gitea__gitea_collaborator_add",
|
"mcp__gitea__gitea_collaborator_add",
|
||||||
"mcp__gitea__gitea_collaborator_permission",
|
"mcp__gitea__gitea_collaborator_permission",
|
||||||
"Bash(git config:*)"
|
"Bash(git config:*)",
|
||||||
|
"mcp__playwright__playwright_evaluate",
|
||||||
|
"mcp__playwright__playwright_close",
|
||||||
|
"mcp__playwright__playwright_fill",
|
||||||
|
"mcp__playwright__playwright_click",
|
||||||
|
"mcp__playwright__playwright_resize",
|
||||||
|
"Bash(git -C /Users/andrei/Work/Web/thumbnail-preview-tool rev-parse:*)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"enabledMcpjsonServers": [
|
"enabledMcpjsonServers": [
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
<title>PrevThumb</title>
|
<title>PrevThumb</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
64
frontend/package-lock.json
generated
64
frontend/package-lock.json
generated
@@ -28,6 +28,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@playwright/test": "^1.58.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
@@ -1721,6 +1722,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.58.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz",
|
||||||
|
"integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.58.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/primitive": {
|
"node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
@@ -6750,6 +6767,53 @@
|
|||||||
"node": ">=16.20.0"
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.58.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
|
||||||
|
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.58.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.58.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
|
||||||
|
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:visual": "playwright test",
|
||||||
|
"test:visual:update": "playwright test --update-snapshots",
|
||||||
|
"test:visual:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
@@ -30,6 +33,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@playwright/test": "^1.58.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
|
|||||||
59
frontend/playwright.config.ts
Normal file
59
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright configuration for YouTube Design Preview visual regression testing
|
||||||
|
* @see https://playwright.dev/docs/test-configuration
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/visual',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'html',
|
||||||
|
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mobile-chrome',
|
||||||
|
use: { ...devices['Pixel 5'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mobile-safari',
|
||||||
|
use: { ...devices['iPhone 12'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
url: 'http://localhost:5173',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120 * 1000,
|
||||||
|
},
|
||||||
|
|
||||||
|
expect: {
|
||||||
|
toHaveScreenshot: {
|
||||||
|
// 98% match threshold as per specification
|
||||||
|
threshold: 0.02,
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
maxDiffPixelRatio: 0.02,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import { ToolPage } from './pages/ToolPage';
|
|||||||
import { LogoPreview } from './pages/LogoPreview';
|
import { LogoPreview } from './pages/LogoPreview';
|
||||||
import { LoginPage } from './pages/LoginPage';
|
import { LoginPage } from './pages/LoginPage';
|
||||||
import { AuthCallbackPage } from './pages/AuthCallbackPage';
|
import { AuthCallbackPage } from './pages/AuthCallbackPage';
|
||||||
import { AuthGuard } from './components/AuthGuard';
|
// import { AuthGuard } from './components/AuthGuard'; // TODO: Enable when Google OAuth is configured
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -13,14 +13,8 @@ function App() {
|
|||||||
<Route path="/" element={<LandingPage />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||||
<Route
|
{/* TODO: Re-enable AuthGuard when Google OAuth is configured */}
|
||||||
path="/tool"
|
<Route path="/tool" element={<ToolPage />} />
|
||||||
element={
|
|
||||||
<AuthGuard>
|
|
||||||
<ToolPage />
|
|
||||||
</AuthGuard>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="/logo-preview" element={<LogoPreview />} />
|
<Route path="/logo-preview" element={<LogoPreview />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { usePreviewStore } from '../store/previewStore';
|
import { usePreviewStore } from '../store/previewStore';
|
||||||
import { YouTubeVideoCard } from './YouTubeVideoCard';
|
import { YouTubeVideoCard } from './YouTubeVideoCard';
|
||||||
|
import { formatViewCount, formatRelativeTime } from '@/lib/youtube-styles';
|
||||||
|
import { Menu, Video, Bell, Search, Mic } from 'lucide-react';
|
||||||
|
|
||||||
export const PreviewGrid = () => {
|
export const PreviewGrid = () => {
|
||||||
const {
|
const {
|
||||||
@@ -7,8 +9,9 @@ export const PreviewGrid = () => {
|
|||||||
activeThumbnailIndex,
|
activeThumbnailIndex,
|
||||||
youtubeResults,
|
youtubeResults,
|
||||||
viewMode,
|
viewMode,
|
||||||
|
metadata,
|
||||||
userTitle,
|
userTitle,
|
||||||
userChannel
|
userChannel,
|
||||||
} = usePreviewStore();
|
} = usePreviewStore();
|
||||||
|
|
||||||
const activeThumbnail = thumbnails[activeThumbnailIndex];
|
const activeThumbnail = thumbnails[activeThumbnailIndex];
|
||||||
@@ -19,45 +22,74 @@ export const PreviewGrid = () => {
|
|||||||
<p className="yt-meta text-sm">
|
<p className="yt-meta text-sm">
|
||||||
{!activeThumbnail
|
{!activeThumbnail
|
||||||
? 'Upload a thumbnail to see preview'
|
? 'Upload a thumbnail to see preview'
|
||||||
: 'Search for competitors to see preview'
|
: 'Search for competitors to see preview'}
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format user's metadata
|
||||||
|
const formattedUserViews = formatViewCount(metadata.viewCount);
|
||||||
|
const formattedUserTime = formatRelativeTime(metadata.publishedAt);
|
||||||
|
|
||||||
const combinedResults = [
|
const combinedResults = [
|
||||||
{
|
{
|
||||||
videoId: 'user',
|
videoId: 'user',
|
||||||
title: userTitle,
|
title: metadata.title || userTitle,
|
||||||
channelTitle: userChannel,
|
channelTitle: metadata.channelName || userChannel,
|
||||||
thumbnailUrl: activeThumbnail.url,
|
thumbnailUrl: activeThumbnail.url,
|
||||||
publishedAt: new Date().toISOString(),
|
publishedAt: formattedUserTime,
|
||||||
viewCount: '0',
|
viewCount: formattedUserViews,
|
||||||
|
duration: metadata.duration,
|
||||||
isUser: true,
|
isUser: true,
|
||||||
|
isVerified: true, // User is verified for preview
|
||||||
},
|
},
|
||||||
...youtubeResults.map(v => ({ ...v, isUser: false })),
|
...youtubeResults.map((v) => ({
|
||||||
|
...v,
|
||||||
|
viewCount: v.viewCount
|
||||||
|
? formatViewCount(parseInt(v.viewCount, 10))
|
||||||
|
: undefined,
|
||||||
|
publishedAt: formatRelativeTime(new Date(v.publishedAt)),
|
||||||
|
isUser: false,
|
||||||
|
duration: '10:30', // Default duration for YouTube results
|
||||||
|
isVerified: Math.random() > 0.5, // Randomly verify 50% of results for realism
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Mobile view - YouTube mobile app style
|
// Mobile view - YouTube mobile app style
|
||||||
if (viewMode === 'mobile') {
|
if (viewMode === 'mobile') {
|
||||||
return (
|
return (
|
||||||
<div className="yt-surface max-w-[400px] mx-auto rounded-xl overflow-hidden border border-yt-border">
|
<div className="yt-surface max-w-[428px] mx-auto rounded-xl overflow-hidden border border-yt-border">
|
||||||
{/* Mobile header */}
|
{/* Mobile header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-yt-border">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-yt-border">
|
||||||
<svg className="w-[90px] h-5" viewBox="0 0 90 20" fill="none">
|
<svg height="20" viewBox="0 0 90 20" width="90" focusable="false">
|
||||||
<g className="fill-yt-title">
|
<g>
|
||||||
<path d="M27.973 18.062V1.938h3.254v16.124h-3.254zm5.735-12.14c.69-.527 1.566-.79 2.626-.79 1.06 0 1.936.263 2.626.79.69.528 1.035 1.21 1.035 2.048v8.217c0 .837-.345 1.52-1.035 2.048-.69.527-1.566.79-2.626.79-1.06 0-1.935-.263-2.626-.79-.69-.528-1.035-1.21-1.035-2.048V7.97c0-.838.345-1.52 1.035-2.048zm1.566 9.884c0 .378.136.68.407.907.272.228.63.342 1.076.342.447 0 .805-.114 1.076-.342.272-.227.407-.53.407-.907V8.35c0-.378-.135-.68-.407-.907-.271-.228-.63-.342-1.076-.342-.446 0-.804.114-1.076.342-.271.228-.407.53-.407.907v7.456zm13.32-9.884c.69-.527 1.566-.79 2.626-.79 1.06 0 1.936.263 2.626.79.69.528 1.035 1.21 1.035 2.048v.76h-3.051V8.35c0-.378-.136-.68-.407-.907-.272-.228-.63-.342-1.076-.342-.447 0-.805.114-1.076.342-.272.228-.407.53-.407.907v7.456c0 .378.135.68.407.907.271.228.63.342 1.076.342.446 0 .804-.114 1.076-.342.271-.227.407-.53.407-.907v-2.28h-1.627v-2.281h4.678v4.942c0 .837-.345 1.52-1.035 2.048-.69.527-1.566.79-2.626.79-1.06 0-1.936-.263-2.626-.79-.69-.528-1.035-1.21-1.035-2.048V7.97c0-.838.345-1.52 1.035-2.048zm12.082 12.14V5.922h-2.829V3.64h8.91v2.28h-2.829v12.14h-3.252zm10.865 0V1.938h3.254v10.234l3.66-6.25h3.558l-3.862 6.173 4.165 5.967h-3.66l-3.861-5.815v5.815h-3.254zm12.69-12.14c.689-.527 1.565-.79 2.625-.79 1.061 0 1.937.263 2.627.79.69.528 1.035 1.21 1.035 2.048v8.217c0 .837-.345 1.52-1.035 2.048-.69.527-1.566.79-2.627.79-1.06 0-1.936-.263-2.626-.79-.69-.528-1.035-1.21-1.035-2.048V7.97c0-.838.345-1.52 1.035-2.048zm1.566 9.884c0 .378.136.68.407.907.272.228.63.342 1.076.342.447 0 .805-.114 1.076-.342.272-.227.407-.53.407-.907V8.35c0-.378-.135-.68-.407-.907-.271-.228-.629-.342-1.076-.342-.446 0-.804.114-1.076.342-.271.228-.407.53-.407.907v7.456z"/>
|
<path
|
||||||
|
d="M27.9727 3.12324C27.6435 1.89323 26.6768 0.926623 25.4468 0.597366C23.2197 2.24288e-07 14.285 0 14.285 0C14.285 0 5.35042 2.24288e-07 3.12323 0.597366C1.89323 0.926623 0.926623 1.89323 0.597366 3.12324C2.24288e-07 5.35042 0 10 0 10C0 10 2.24288e-07 14.6496 0.597366 16.8768C0.926623 18.1068 1.89323 19.0734 3.12323 19.4026C5.35042 20 14.285 20 14.285 20C14.285 20 23.2197 20 25.4468 19.4026C26.6768 19.0734 27.6435 18.1068 27.9727 16.8768C28.5701 14.6496 28.5701 10 28.5701 10C28.5701 10 28.5677 5.35042 27.9727 3.12324Z"
|
||||||
|
fill="#FF0000"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M11.4253 14.2854L18.8477 10.0004L11.4253 5.71533V14.2854Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<g className="fill-yt-title">
|
||||||
|
<path d="M34.6024 13.0036L31.3945 1.41846H34.1932L35.3174 6.6701C35.6043 7.96361 35.8136 9.06662 35.95 9.97913H36.0323C36.1264 9.32532 36.3381 8.22937 36.665 6.68892L37.8291 1.41846H40.6278L37.3799 13.0036V18.561H34.6001V13.0036H34.6024Z" />
|
||||||
|
<path d="M41.4697 18.1937C40.9053 17.8127 40.5031 17.22 40.2632 16.4157C40.0257 15.6114 39.9058 14.5437 39.9058 13.2078V11.3898C39.9058 10.0422 40.0422 8.95805 40.315 8.14196C40.5878 7.32588 41.0135 6.72851 41.592 6.35457C42.1706 5.98063 42.9302 5.79248 43.871 5.79248C44.7976 5.79248 45.5384 5.98298 46.0981 6.36398C46.6555 6.74497 47.0647 7.34234 47.3234 8.15137C47.5821 8.96275 47.7115 10.0422 47.7115 11.3898V13.2078C47.7115 14.5437 47.5845 15.6161 47.3329 16.4251C47.0812 17.2365 46.672 17.8292 46.1075 18.2031C45.5431 18.5771 44.7764 18.7652 43.8098 18.7652C42.8126 18.7675 42.0342 18.5747 41.4697 18.1937ZM44.6353 16.2323C44.7905 15.8231 44.8705 15.1575 44.8705 14.2309V10.3292C44.8705 9.43077 44.7929 8.77225 44.6353 8.35833C44.4777 7.94206 44.2026 7.7351 43.8074 7.7351C43.4265 7.7351 43.156 7.94206 43.0008 8.35833C42.8432 8.77461 42.7656 9.43077 42.7656 10.3292V14.2309C42.7656 15.1575 42.8408 15.8254 42.9914 16.2323C43.1419 16.6415 43.4123 16.8461 43.8074 16.8461C44.2026 16.8461 44.4777 16.6415 44.6353 16.2323Z" />
|
||||||
|
<path d="M56.8154 18.5634H54.6094L54.3648 17.03H54.3037C53.7039 18.1871 52.8055 18.7656 51.6061 18.7656C50.7759 18.7656 50.1621 18.4928 49.767 17.9496C49.3719 17.4039 49.1743 16.5526 49.1743 15.3955V6.03751H51.9942V15.2308C51.9942 15.7906 52.0553 16.188 52.1776 16.4256C52.2999 16.6631 52.5045 16.783 52.7914 16.783C53.036 16.783 53.2712 16.7078 53.497 16.5573C53.7228 16.4067 53.8874 16.2162 53.9979 15.9858V6.03516H56.8154V18.5634Z" />
|
||||||
|
<path d="M64.4755 3.68758H61.6768V18.5629H58.9181V3.68758H56.1194V1.42041H64.4755V3.68758Z" />
|
||||||
|
<path d="M71.2768 18.5634H69.0708L68.8262 17.03H68.7651C68.1654 18.1871 67.267 18.7656 66.0675 18.7656C65.2373 18.7656 64.6235 18.4928 64.2284 17.9496C63.8333 17.4039 63.6357 16.5526 63.6357 15.3955V6.03751H66.4556V15.2308C66.4556 15.7906 66.5167 16.188 66.639 16.4256C66.7613 16.6631 66.9659 16.783 67.2529 16.783C67.4974 16.783 67.7326 16.7078 67.9584 16.5573C68.1842 16.4067 68.3488 16.2162 68.4593 15.9858V6.03516H71.2768V18.5634Z" />
|
||||||
|
<path d="M80.609 8.0387C80.4373 7.24849 80.1621 6.67699 79.7812 6.32186C79.4002 5.96674 78.8757 5.79035 78.2078 5.79035C77.6904 5.79035 77.2059 5.93616 76.7567 6.23014C76.3075 6.52412 75.9594 6.90747 75.7148 7.38489H75.6937V0.785645H72.9773V18.5608H75.3056L75.5925 17.3755H75.6537C75.8724 17.7988 76.1993 18.1304 76.6344 18.3774C77.0695 18.622 77.554 18.7443 78.0855 18.7443C79.038 18.7443 79.7412 18.3045 80.1904 17.4272C80.6396 16.5476 80.8653 15.1765 80.8653 13.3092V11.3266C80.8653 9.92722 80.7783 8.82892 80.609 8.0387ZM78.0243 13.1492C78.0243 14.0617 77.9867 14.7767 77.9114 15.2941C77.8362 15.8115 77.7115 16.1808 77.5328 16.3971C77.3564 16.6158 77.1165 16.724 76.8178 16.724C76.585 16.724 76.371 16.6699 76.1734 16.5594C75.9759 16.4512 75.816 16.2866 75.6937 16.0702V8.96062C75.7877 8.6196 75.9524 8.34209 76.1852 8.12337C76.4157 7.90465 76.6697 7.79646 76.9401 7.79646C77.2271 7.79646 77.4481 7.90935 77.6034 8.13278C77.7609 8.35855 77.8691 8.73485 77.9303 9.26636C77.9914 9.79787 78.022 10.5528 78.022 11.5335V13.1492H78.0243Z" />
|
||||||
|
<path d="M84.8657 13.8712C84.8657 14.6755 84.8892 15.2776 84.9363 15.6798C84.9833 16.0819 85.0821 16.3736 85.2326 16.5594C85.3831 16.7428 85.6136 16.8345 85.9264 16.8345C86.3474 16.8345 86.639 16.6699 86.7942 16.343C86.9518 16.0161 87.0365 15.4705 87.0506 14.7085L89.4824 14.8519C89.4965 14.9601 89.5035 15.1106 89.5035 15.3011C89.5035 16.4582 89.186 17.3237 88.5534 17.8952C87.9208 18.4667 87.0247 18.7536 85.8676 18.7536C84.4777 18.7536 83.504 18.3185 82.9466 17.446C82.3869 16.5735 82.1094 15.2259 82.1094 13.4008V11.2136C82.1094 9.33452 82.3987 7.96105 82.9772 7.09558C83.5558 6.2301 84.5459 5.79736 85.9499 5.79736C86.9165 5.79736 87.6597 5.97375 88.1771 6.32888C88.6945 6.684 89.059 7.23433 89.2707 7.98457C89.4824 8.7348 89.5882 9.76961 89.5882 11.0913V13.2362H84.8657V13.8712ZM85.2232 7.96811C85.0797 8.14449 84.9857 8.43377 84.9363 8.83593C84.8892 9.2381 84.8657 9.84722 84.8657 10.6657V11.5641H86.9283V10.6657C86.9283 9.86133 86.9001 9.25221 86.846 8.83593C86.7919 8.41966 86.6931 8.12803 86.5496 7.95635C86.4062 7.78702 86.1851 7.7 85.8864 7.7C85.5854 7.70235 85.3643 7.79172 85.2232 7.96811Z" />
|
||||||
|
</g>
|
||||||
</g>
|
</g>
|
||||||
<path d="M8.522 0C3.816 0 0 3.816 0 8.522v2.956C0 16.184 3.816 20 8.522 20h2.956C16.184 20 20 16.184 20 11.478V8.522C20 3.816 16.184 0 11.478 0H8.522z" fill="#FF0000"/>
|
|
||||||
<path d="M14.6 10L8 6v8l6.6-4z" fill="white"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<svg className="w-5 h-5 yt-icon" viewBox="0 0 24 24" fill="currentColor">
|
<svg
|
||||||
<path d="M20 12h2v-2h-2v2zm0 4h2v-2h-2v2zm-4-4h2v-2h-2v2zm0 4h2v-2h-2v2zm0-8h2V6h-2v2zm4 0h2V6h-2v2zM8 18v-4.3c0-.14.06-.23.18-.28.12-.06.24-.03.34.08L12 17l3.48-3.5c.1-.11.22-.14.34-.08.12.05.18.14.18.28V18H8zm0-6v-1.7c0-.14.06-.23.18-.28.12-.06.24-.03.34.08L12 13.6l3.48-3.5c.1-.11.22-.14.34-.08.12.05.18.14.18.28V12H8zM4 6v2h2V6H4zm0 4v2h2v-2H4zm0 4v2h2v-2H4z"/>
|
className="w-5 h-5 yt-icon"
|
||||||
</svg>
|
viewBox="0 0 24 24"
|
||||||
<svg className="w-5 h-5 yt-icon" viewBox="0 0 24 24" fill="currentColor">
|
fill="currentColor"
|
||||||
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
|
>
|
||||||
|
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,16 +97,18 @@ export const PreviewGrid = () => {
|
|||||||
{/* Mobile content */}
|
{/* Mobile content */}
|
||||||
<div className="divide-y divide-yt-border">
|
<div className="divide-y divide-yt-border">
|
||||||
{combinedResults.slice(0, 6).map((video) => (
|
{combinedResults.slice(0, 6).map((video) => (
|
||||||
<div key={video.videoId} className="p-3">
|
<div key={video.videoId} className="py-3">
|
||||||
<YouTubeVideoCard
|
<YouTubeVideoCard
|
||||||
thumbnailUrl={video.thumbnailUrl}
|
thumbnailUrl={video.thumbnailUrl}
|
||||||
title={video.title}
|
title={video.title}
|
||||||
channelTitle={video.channelTitle}
|
channelTitle={video.channelTitle}
|
||||||
viewCount={video.viewCount}
|
viewCount={video.viewCount}
|
||||||
publishedAt={video.publishedAt}
|
publishedAt={video.publishedAt}
|
||||||
isUserThumbnail={video.isUser}
|
isUserThumbnail={video.isUser}
|
||||||
variant="mobile"
|
variant="mobile"
|
||||||
/>
|
duration={video.duration}
|
||||||
|
isVerified={video.isVerified}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -100,6 +134,8 @@ export const PreviewGrid = () => {
|
|||||||
publishedAt={video.publishedAt}
|
publishedAt={video.publishedAt}
|
||||||
isUserThumbnail={video.isUser}
|
isUserThumbnail={video.isUser}
|
||||||
variant="sidebar"
|
variant="sidebar"
|
||||||
|
duration={video.duration}
|
||||||
|
isVerified={video.isVerified}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -107,49 +143,83 @@ export const PreviewGrid = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search view - YouTube search results page style (default)
|
// Desktop view - YouTube homepage/search grid style (default)
|
||||||
|
// Uses responsive breakpoints matching YouTube: 6→5→4→3→2→1 columns
|
||||||
return (
|
return (
|
||||||
<div className="yt-surface rounded-xl border border-yt-border overflow-hidden">
|
<div className="yt-surface rounded-xl border border-yt-border overflow-hidden">
|
||||||
{/* YouTube header */}
|
{/* YouTube header */}
|
||||||
<div className="flex items-center gap-4 px-4 py-3 border-b border-yt-border">
|
<div className="flex items-center justify-between px-4 py-2 border-b border-yt-border h-[56px]">
|
||||||
<svg className="w-[90px] h-5 flex-shrink-0" viewBox="0 0 90 20" fill="none">
|
<div className="flex items-center gap-4">
|
||||||
<g className="fill-yt-title">
|
<button className="p-2 hover:bg-yt-hover rounded-full transition-colors">
|
||||||
<path d="M27.973 18.062V1.938h3.254v16.124h-3.254zm5.735-12.14c.69-.527 1.566-.79 2.626-.79 1.06 0 1.936.263 2.626.79.69.528 1.035 1.21 1.035 2.048v8.217c0 .837-.345 1.52-1.035 2.048-.69.527-1.566.79-2.626.79-1.06 0-1.935-.263-2.626-.79-.69-.528-1.035-1.21-1.035-2.048V7.97c0-.838.345-1.52 1.035-2.048zm1.566 9.884c0 .378.136.68.407.907.272.228.63.342 1.076.342.447 0 .805-.114 1.076-.342.272-.227.407-.53.407-.907V8.35c0-.378-.135-.68-.407-.907-.271-.228-.63-.342-1.076-.342-.446 0-.804.114-1.076.342-.271.228-.407.53-.407.907v7.456zm13.32-9.884c.69-.527 1.566-.79 2.626-.79 1.06 0 1.936.263 2.626.79.69.528 1.035 1.21 1.035 2.048v.76h-3.051V8.35c0-.378-.136-.68-.407-.907-.272-.228-.63-.342-1.076-.342-.447 0-.805.114-1.076.342-.272.228-.407.53-.407.907v7.456c0 .378.135.68.407.907.271.228.63.342 1.076.342.446 0 .804-.114 1.076-.342.271-.227.407-.53.407-.907v-2.28h-1.627v-2.281h4.678v4.942c0 .837-.345 1.52-1.035 2.048-.69.527-1.566.79-2.626.79-1.06 0-1.936-.263-2.626-.79-.69-.528-1.035-1.21-1.035-2.048V7.97c0-.838.345-1.52 1.035-2.048zm12.082 12.14V5.922h-2.829V3.64h8.91v2.28h-2.829v12.14h-3.252zm10.865 0V1.938h3.254v10.234l3.66-6.25h3.558l-3.862 6.173 4.165 5.967h-3.66l-3.861-5.815v5.815h-3.254zm12.69-12.14c.689-.527 1.565-.79 2.625-.79 1.061 0 1.937.263 2.627.79.69.528 1.035 1.21 1.035 2.048v8.217c0 .837-.345 1.52-1.035 2.048-.69.527-1.566.79-2.627.79-1.06 0-1.936-.263-2.626-.79-.69-.528-1.035-1.21-1.035-2.048V7.97c0-.838.345-1.52 1.035-2.048zm1.566 9.884c0 .378.136.68.407.907.272.228.63.342 1.076.342.447 0 .805-.114 1.076-.342.272-.227.407-.53.407-.907V8.35c0-.378-.135-.68-.407-.907-.271-.228-.629-.342-1.076-.342-.446 0-.804.114-1.076.342-.271.228-.407.53-.407.907v7.456z"/>
|
<Menu className="w-6 h-6 text-yt-title" strokeWidth={1} />
|
||||||
</g>
|
</button>
|
||||||
<path d="M8.522 0C3.816 0 0 3.816 0 8.522v2.956C0 16.184 3.816 20 8.522 20h2.956C16.184 20 20 16.184 20 11.478V8.522C20 3.816 16.184 0 11.478 0H8.522z" fill="#FF0000"/>
|
<div className="flex items-center gap-1 cursor-pointer" title="YouTube Home">
|
||||||
<path d="M14.6 10L8 6v8l6.6-4z" fill="white"/>
|
<svg
|
||||||
</svg>
|
height="20"
|
||||||
|
viewBox="0 0 90 20"
|
||||||
<div className="flex-1 max-w-2xl">
|
width="90"
|
||||||
<div className="flex">
|
focusable="false"
|
||||||
<input
|
style={{ width: '90px', height: '20px' }}
|
||||||
type="text"
|
>
|
||||||
value={usePreviewStore.getState().searchQuery}
|
<g>
|
||||||
readOnly
|
<path
|
||||||
className="flex-1 h-10 px-4 yt-surface border border-yt-border rounded-l-full text-yt-title text-sm focus:outline-none focus:border-yt-blue"
|
d="M27.9727 3.12324C27.6435 1.89323 26.6768 0.926623 25.4468 0.597366C23.2197 2.24288e-07 14.285 0 14.285 0C14.285 0 5.35042 2.24288e-07 3.12323 0.597366C1.89323 0.926623 0.926623 1.89323 0.597366 3.12324C2.24288e-07 5.35042 0 10 0 10C0 10 2.24288e-07 14.6496 0.597366 16.8768C0.926623 18.1068 1.89323 19.0734 3.12323 19.4026C5.35042 20 14.285 20 14.285 20C14.285 20 23.2197 20 25.4468 19.4026C26.6768 19.0734 27.6435 18.1068 27.9727 16.8768C28.5701 14.6496 28.5701 10 28.5701 10C28.5701 10 28.5677 5.35042 27.9727 3.12324Z"
|
||||||
placeholder="Search"
|
fill="#FF0000"
|
||||||
/>
|
/>
|
||||||
<button className="h-10 px-6 bg-yt-chip-bg border border-l-0 border-yt-border rounded-r-full hover:bg-yt-hover transition-colors">
|
<path
|
||||||
<svg className="w-5 h-5 yt-icon" viewBox="0 0 24 24" fill="currentColor">
|
d="M11.4253 14.2854L18.8477 10.0004L11.4253 5.71533V14.2854Z"
|
||||||
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
|
fill="white"
|
||||||
</svg>
|
/>
|
||||||
</button>
|
<g className="fill-yt-title">
|
||||||
|
<path d="M34.6024 13.0036L31.3945 1.41846H34.1932L35.3174 6.6701C35.6043 7.96361 35.8136 9.06662 35.95 9.97913H36.0323C36.1264 9.32532 36.3381 8.22937 36.665 6.68892L37.8291 1.41846H40.6278L37.3799 13.0036V18.561H34.6001V13.0036H34.6024Z" />
|
||||||
|
<path d="M41.4697 18.1937C40.9053 17.8127 40.5031 17.22 40.2632 16.4157C40.0257 15.6114 39.9058 14.5437 39.9058 13.2078V11.3898C39.9058 10.0422 40.0422 8.95805 40.315 8.14196C40.5878 7.32588 41.0135 6.72851 41.592 6.35457C42.1706 5.98063 42.9302 5.79248 43.871 5.79248C44.7976 5.79248 45.5384 5.98298 46.0981 6.36398C46.6555 6.74497 47.0647 7.34234 47.3234 8.15137C47.5821 8.96275 47.7115 10.0422 47.7115 11.3898V13.2078C47.7115 14.5437 47.5845 15.6161 47.3329 16.4251C47.0812 17.2365 46.672 17.8292 46.1075 18.2031C45.5431 18.5771 44.7764 18.7652 43.8098 18.7652C42.8126 18.7675 42.0342 18.5747 41.4697 18.1937ZM44.6353 16.2323C44.7905 15.8231 44.8705 15.1575 44.8705 14.2309V10.3292C44.8705 9.43077 44.7929 8.77225 44.6353 8.35833C44.4777 7.94206 44.2026 7.7351 43.8074 7.7351C43.4265 7.7351 43.156 7.94206 43.0008 8.35833C42.8432 8.77461 42.7656 9.43077 42.7656 10.3292V14.2309C42.7656 15.1575 42.8408 15.8254 42.9914 16.2323C43.1419 16.6415 43.4123 16.8461 43.8074 16.8461C44.2026 16.8461 44.4777 16.6415 44.6353 16.2323Z" />
|
||||||
|
<path d="M56.8154 18.5634H54.6094L54.3648 17.03H54.3037C53.7039 18.1871 52.8055 18.7656 51.6061 18.7656C50.7759 18.7656 50.1621 18.4928 49.767 17.9496C49.3719 17.4039 49.1743 16.5526 49.1743 15.3955V6.03751H51.9942V15.2308C51.9942 15.7906 52.0553 16.188 52.1776 16.4256C52.2999 16.6631 52.5045 16.783 52.7914 16.783C53.036 16.783 53.2712 16.7078 53.497 16.5573C53.7228 16.4067 53.8874 16.2162 53.9979 15.9858V6.03516H56.8154V18.5634Z" />
|
||||||
|
<path d="M64.4755 3.68758H61.6768V18.5629H58.9181V3.68758H56.1194V1.42041H64.4755V3.68758Z" />
|
||||||
|
<path d="M71.2768 18.5634H69.0708L68.8262 17.03H68.7651C68.1654 18.1871 67.267 18.7656 66.0675 18.7656C65.2373 18.7656 64.6235 18.4928 64.2284 17.9496C63.8333 17.4039 63.6357 16.5526 63.6357 15.3955V6.03751H66.4556V15.2308C66.4556 15.7906 66.5167 16.188 66.639 16.4256C66.7613 16.6631 66.9659 16.783 67.2529 16.783C67.4974 16.783 67.7326 16.7078 67.9584 16.5573C68.1842 16.4067 68.3488 16.2162 68.4593 15.9858V6.03516H71.2768V18.5634Z" />
|
||||||
|
<path d="M80.609 8.0387C80.4373 7.24849 80.1621 6.67699 79.7812 6.32186C79.4002 5.96674 78.8757 5.79035 78.2078 5.79035C77.6904 5.79035 77.2059 5.93616 76.7567 6.23014C76.3075 6.52412 75.9594 6.90747 75.7148 7.38489H75.6937V0.785645H72.9773V18.5608H75.3056L75.5925 17.3755H75.6537C75.8724 17.7988 76.1993 18.1304 76.6344 18.3774C77.0695 18.622 77.554 18.7443 78.0855 18.7443C79.038 18.7443 79.7412 18.3045 80.1904 17.4272C80.6396 16.5476 80.8653 15.1765 80.8653 13.3092V11.3266C80.8653 9.92722 80.7783 8.82892 80.609 8.0387ZM78.0243 13.1492C78.0243 14.0617 77.9867 14.7767 77.9114 15.2941C77.8362 15.8115 77.7115 16.1808 77.5328 16.3971C77.3564 16.6158 77.1165 16.724 76.8178 16.724C76.585 16.724 76.371 16.6699 76.1734 16.5594C75.9759 16.4512 75.816 16.2866 75.6937 16.0702V8.96062C75.7877 8.6196 75.9524 8.34209 76.1852 8.12337C76.4157 7.90465 76.6697 7.79646 76.9401 7.79646C77.2271 7.79646 77.4481 7.90935 77.6034 8.13278C77.7609 8.35855 77.8691 8.73485 77.9303 9.26636C77.9914 9.79787 78.022 10.5528 78.022 11.5335V13.1492H78.0243Z" />
|
||||||
|
<path d="M84.8657 13.8712C84.8657 14.6755 84.8892 15.2776 84.9363 15.6798C84.9833 16.0819 85.0821 16.3736 85.2326 16.5594C85.3831 16.7428 85.6136 16.8345 85.9264 16.8345C86.3474 16.8345 86.639 16.6699 86.7942 16.343C86.9518 16.0161 87.0365 15.4705 87.0506 14.7085L89.4824 14.8519C89.4965 14.9601 89.5035 15.1106 89.5035 15.3011C89.5035 16.4582 89.186 17.3237 88.5534 17.8952C87.9208 18.4667 87.0247 18.7536 85.8676 18.7536C84.4777 18.7536 83.504 18.3185 82.9466 17.446C82.3869 16.5735 82.1094 15.2259 82.1094 13.4008V11.2136C82.1094 9.33452 82.3987 7.96105 82.9772 7.09558C83.5558 6.2301 84.5459 5.79736 85.9499 5.79736C86.9165 5.79736 87.6597 5.97375 88.1771 6.32888C88.6945 6.684 89.059 7.23433 89.2707 7.98457C89.4824 8.7348 89.5882 9.76961 89.5882 11.0913V13.2362H84.8657V13.8712ZM85.2232 7.96811C85.0797 8.14449 84.9857 8.43377 84.9363 8.83593C84.8892 9.2381 84.8657 9.84722 84.8657 10.6657V11.5641H86.9283V10.6657C86.9283 9.86133 86.9001 9.25221 86.846 8.83593C86.7919 8.41966 86.6931 8.12803 86.5496 7.95635C86.4062 7.78702 86.1851 7.7 85.8864 7.7C85.5854 7.70235 85.3643 7.79172 85.2232 7.96811Z" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<span className="text-[10px] text-yt-meta mb-auto ml-1 mt-1">PL</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex-1 max-w-[720px] mx-10 hidden sm:flex items-center gap-4">
|
||||||
<button className="w-10 h-10 rounded-full hover:bg-yt-hover flex items-center justify-center transition-colors">
|
<div className="flex flex-1">
|
||||||
<svg className="w-6 h-6 yt-icon" viewBox="0 0 24 24" fill="currentColor">
|
<div className="flex flex-1 items-center">
|
||||||
<path d="M14 13h-3v3H9v-3H6v-2h3V8h2v3h3v2zm3-7H3v12h14v-6.39l4 1.83V8.56l-4 1.83V6m1-1v3.83L22 7v8l-4-1.83V19H2V5h16z"/>
|
<div className="flex flex-1 relative">
|
||||||
</svg>
|
<input
|
||||||
|
type="text"
|
||||||
|
value={usePreviewStore.getState().searchQuery}
|
||||||
|
readOnly
|
||||||
|
className="w-full h-10 px-4 pl-4 bg-transparent border border-yt-border rounded-l-full text-yt-title text-base focus:outline-none focus:border-blue-500 shadow-inner ml-8"
|
||||||
|
placeholder="Search"
|
||||||
|
style={{ boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.05)' }}
|
||||||
|
/>
|
||||||
|
<div className="absolute left-0 top-0 h-full hidden pl-4 items-center">
|
||||||
|
<Search className="w-5 h-5 text-yt-meta" strokeWidth={1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="h-10 px-6 bg-yt-chip-bg border border-l-0 border-yt-border rounded-r-full hover:bg-yt-hover transition-colors flex items-center justify-center">
|
||||||
|
<Search className="w-5 h-5 text-yt-title" strokeWidth={1} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="w-10 h-10 bg-yt-chip-bg hover:bg-yt-hover rounded-full flex items-center justify-center transition-colors">
|
||||||
|
<Mic className="w-5 h-5 text-yt-title" strokeWidth={1} />
|
||||||
</button>
|
</button>
|
||||||
<button className="w-10 h-10 rounded-full hover:bg-yt-hover flex items-center justify-center transition-colors">
|
</div>
|
||||||
<svg className="w-6 h-6 yt-icon" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M10 20h4c0 1.1-.9 2-2 2s-2-.9-2-2zm10-2.65V19H4v-1.65l2-1.88v-5.15C6 7.4 7.56 5.1 10 4.34v-.38c0-1.42 1.49-2.5 2.99-1.76.65.32 1.01 1.03 1.01 1.76v.39c2.44.75 4 3.06 4 5.98v5.15l2 1.87zm-1 .42l-2-1.88v-5.47c0-2.47-1.19-4.36-3.13-5.1-1.26-.53-2.64-.5-3.84.03C8.15 6.11 7 7.99 7 10.42v5.47l-2 1.88V18h14v-.23z"/>
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
</svg>
|
<button className="p-2 hover:bg-yt-hover rounded-full transition-colors hidden sm:block">
|
||||||
|
<Video className="w-6 h-6 text-yt-title" strokeWidth={1} />
|
||||||
</button>
|
</button>
|
||||||
<div className="w-8 h-8 rounded-full bg-purple-600 flex items-center justify-center text-white text-sm font-medium">
|
<button className="p-2 hover:bg-yt-hover rounded-full transition-colors hidden sm:block">
|
||||||
U
|
<Bell className="w-6 h-6 text-yt-title" strokeWidth={1} />
|
||||||
|
</button>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-purple-600 flex items-center justify-center text-white text-sm font-medium cursor-pointer">
|
||||||
|
A
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,9 +246,16 @@ export const PreviewGrid = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Video grid */}
|
{/* Video grid with YouTube responsive breakpoints */}
|
||||||
|
{/* 6 cols @ >2136px, 5 cols @ 1712-2136px, 4 cols @ 1288-1712px, 3 cols @ 888-1288px, 2 cols @ 512-888px, 1 col @ <512px */}
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div
|
||||||
|
className="grid gap-x-4 gap-y-10"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns:
|
||||||
|
'repeat(auto-fill, minmax(min(100%, 320px), 1fr))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{combinedResults.map((video) => (
|
{combinedResults.map((video) => (
|
||||||
<YouTubeVideoCard
|
<YouTubeVideoCard
|
||||||
key={video.videoId}
|
key={video.videoId}
|
||||||
@@ -188,7 +265,9 @@ export const PreviewGrid = () => {
|
|||||||
viewCount={video.viewCount}
|
viewCount={video.viewCount}
|
||||||
publishedAt={video.publishedAt}
|
publishedAt={video.publishedAt}
|
||||||
isUserThumbnail={video.isUser}
|
isUserThumbnail={video.isUser}
|
||||||
variant="search"
|
variant="desktop"
|
||||||
|
duration={video.duration}
|
||||||
|
isVerified={video.isVerified}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const SearchInput = () => {
|
|||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
placeholder="Search for competitors (e.g., react tutorial)"
|
placeholder="Search for competitors (e.g., react tutorial)"
|
||||||
className="pl-12 h-12 bg-surface-1 border-border focus:border-primary focus:glow-border transition-all"
|
className="pl-12 h-11 bg-surface-1 border-border focus:border-primary focus:glow-border transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ export const SearchInput = () => {
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading || !inputValue.trim()}
|
disabled={isLoading || !inputValue.trim()}
|
||||||
size="lg"
|
size="lg"
|
||||||
className="h-12 px-6 gap-2"
|
className="h-11 px-6 gap-2"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
42
frontend/src/components/ThemeToggle.tsx
Normal file
42
frontend/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Sun, Moon } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { ThemeMode } from '../types';
|
||||||
|
|
||||||
|
interface ThemeToggleProps {
|
||||||
|
/** Current theme mode */
|
||||||
|
theme: ThemeMode;
|
||||||
|
/** Callback when theme changes */
|
||||||
|
onThemeChange: (theme: ThemeMode) => void;
|
||||||
|
/** Optional className for styling */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme toggle component for switching between light and dark modes
|
||||||
|
* Matches YouTube's manual theme toggle behavior (no auto-detection)
|
||||||
|
*/
|
||||||
|
export function ThemeToggle({ theme, onThemeChange, className }: ThemeToggleProps) {
|
||||||
|
const toggleTheme = () => {
|
||||||
|
onThemeChange(theme === 'light' ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg hover:bg-muted transition-colors',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? (
|
||||||
|
<Sun className="size-5" />
|
||||||
|
) : (
|
||||||
|
<Moon className="size-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone, type FileRejection } from 'react-dropzone';
|
||||||
import { ImageIcon, Upload } from 'lucide-react';
|
import { ImageIcon, Upload, AlertCircle, X } from 'lucide-react';
|
||||||
import { useUploadThumbnail } from '../hooks/useThumbnails';
|
import { useUploadThumbnail } from '../hooks/useThumbnails';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { VALIDATION_CONSTANTS } from '../types';
|
||||||
|
|
||||||
|
interface ValidationError {
|
||||||
|
message: string;
|
||||||
|
type: 'size' | 'format' | 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
export const ThumbnailUploader = () => {
|
export const ThumbnailUploader = () => {
|
||||||
const { mutate: upload, isPending } = useUploadThumbnail();
|
const { mutate: upload, isPending } = useUploadThumbnail();
|
||||||
|
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([]);
|
||||||
|
|
||||||
|
const clearErrors = () => setValidationErrors([]);
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(acceptedFiles: File[]) => {
|
(acceptedFiles: File[]) => {
|
||||||
|
clearErrors();
|
||||||
acceptedFiles.forEach((file) => {
|
acceptedFiles.forEach((file) => {
|
||||||
upload(file);
|
upload(file);
|
||||||
});
|
});
|
||||||
@@ -18,65 +28,126 @@ export const ThumbnailUploader = () => {
|
|||||||
[upload]
|
[upload]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onDropRejected = useCallback((fileRejections: FileRejection[]) => {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
|
||||||
|
fileRejections.forEach((rejection) => {
|
||||||
|
rejection.errors.forEach((error) => {
|
||||||
|
if (error.code === 'file-too-large') {
|
||||||
|
errors.push({
|
||||||
|
message: `File "${rejection.file.name}" exceeds 5MB limit. Please reduce file size.`,
|
||||||
|
type: 'size',
|
||||||
|
});
|
||||||
|
} else if (error.code === 'file-invalid-type') {
|
||||||
|
errors.push({
|
||||||
|
message: `File "${rejection.file.name}" has unsupported format. Accepted: JPG, PNG, WebP.`,
|
||||||
|
type: 'format',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
errors.push({
|
||||||
|
message: `File "${rejection.file.name}": ${error.message}`,
|
||||||
|
type: 'unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setValidationErrors(errors);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
onDrop,
|
onDrop,
|
||||||
|
onDropRejected,
|
||||||
accept: {
|
accept: {
|
||||||
'image/jpeg': ['.jpg', '.jpeg'],
|
'image/jpeg': ['.jpg', '.jpeg'],
|
||||||
'image/png': ['.png'],
|
'image/png': ['.png'],
|
||||||
'image/webp': ['.webp'],
|
'image/webp': ['.webp'],
|
||||||
},
|
},
|
||||||
maxSize: 5 * 1024 * 1024,
|
maxSize: VALIDATION_CONSTANTS.MAX_FILE_SIZE,
|
||||||
multiple: true,
|
multiple: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div className="space-y-3">
|
||||||
{...getRootProps()}
|
{/* Validation Errors */}
|
||||||
className={cn(
|
{validationErrors.length > 0 && (
|
||||||
'border border-dashed rounded-xl p-10 text-center cursor-pointer transition-all duration-300',
|
<div className="space-y-2">
|
||||||
'bg-surface-1 hover:bg-surface-2',
|
{validationErrors.map((error, index) => (
|
||||||
isDragActive
|
<div
|
||||||
? 'border-primary bg-primary/5 glow-sm'
|
key={index}
|
||||||
: 'border-border hover:border-muted-foreground',
|
className={cn(
|
||||||
isPending && 'opacity-50 pointer-events-none'
|
'flex items-start gap-3 p-3 rounded-lg text-sm',
|
||||||
|
error.type === 'size' && 'bg-destructive/10 text-destructive',
|
||||||
|
error.type === 'format' && 'bg-warning/10 text-warning',
|
||||||
|
error.type === 'unknown' && 'bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AlertCircle className="size-4 flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="flex-1">{error.message}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setValidationErrors((prev) =>
|
||||||
|
prev.filter((_, i) => i !== index)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="p-0.5 hover:bg-black/10 rounded transition-colors"
|
||||||
|
aria-label="Dismiss error"
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<input {...getInputProps()} />
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-5">
|
{/* Upload Area */}
|
||||||
<div
|
<Card
|
||||||
className={cn(
|
{...getRootProps()}
|
||||||
'size-16 rounded-2xl flex items-center justify-center transition-all duration-300',
|
className={cn(
|
||||||
isDragActive
|
'border border-dashed rounded-xl p-13.75 text-center cursor-pointer transition-all duration-300',
|
||||||
? 'bg-primary/10 glow-sm'
|
'bg-surface-1 hover:bg-surface-2',
|
||||||
: 'bg-surface-3'
|
isDragActive
|
||||||
)}
|
? 'border-primary bg-primary/5 glow-sm'
|
||||||
>
|
: 'border-border hover:border-muted-foreground',
|
||||||
|
isPending && 'opacity-50 pointer-events-none',
|
||||||
|
validationErrors.length > 0 && 'border-destructive/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-5">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'size-16 rounded-2xl flex items-center justify-center transition-all duration-300',
|
||||||
|
isDragActive ? 'bg-primary/10 glow-sm' : 'bg-surface-3'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Spinner className="size-7" />
|
||||||
|
) : isDragActive ? (
|
||||||
|
<Upload className="size-7 text-primary" />
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="size-7 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<Spinner className="size-7" />
|
<p className="text-muted-foreground">Uploading...</p>
|
||||||
) : isDragActive ? (
|
) : isDragActive ? (
|
||||||
<Upload className="size-7 text-primary" />
|
<p className="text-primary font-medium">Drop your thumbnail here</p>
|
||||||
) : (
|
) : (
|
||||||
<ImageIcon className="size-7 text-muted-foreground" />
|
<div className="space-y-2">
|
||||||
|
<p className="text-foreground font-medium">
|
||||||
|
Drag & drop your thumbnail
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-sm">or click to browse</p>
|
||||||
|
<p className="text-muted-foreground/60 text-xs pt-2">
|
||||||
|
1280×720 (16:9) • JPG, PNG, WebP • Max 5MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
{isPending ? (
|
</div>
|
||||||
<p className="text-muted-foreground">Uploading...</p>
|
|
||||||
) : isDragActive ? (
|
|
||||||
<p className="text-primary font-medium">Drop your thumbnail here</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-foreground font-medium">
|
|
||||||
Drag & drop your thumbnail
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-sm">or click to browse</p>
|
|
||||||
<p className="text-muted-foreground/60 text-xs pt-2">
|
|
||||||
1280×720 (16:9) • JPG, PNG, WebP • Max 5MB
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,35 @@
|
|||||||
import { usePreviewStore } from '../store/previewStore';
|
import { usePreviewStore } from '../store/previewStore';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { formatViewCount } from '@/lib/youtube-styles';
|
||||||
|
|
||||||
export const UserInfoInputs = () => {
|
export const UserInfoInputs = () => {
|
||||||
const { userTitle, userChannel, setUserTitle, setUserChannel } = usePreviewStore();
|
const { metadata, setMetadata, setUserTitle, setUserChannel } = usePreviewStore();
|
||||||
|
|
||||||
|
const handleTitleChange = (value: string) => {
|
||||||
|
setUserTitle(value);
|
||||||
|
setMetadata({ title: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChannelChange = (value: string) => {
|
||||||
|
setUserChannel(value);
|
||||||
|
setMetadata({ channelName: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewCountChange = (value: string) => {
|
||||||
|
const numValue = parseInt(value.replace(/[^0-9]/g, ''), 10) || 0;
|
||||||
|
setMetadata({ viewCount: numValue });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDurationChange = (value: string) => {
|
||||||
|
// Allow partial input during typing (just digits and colons)
|
||||||
|
const sanitized = value.replace(/[^0-9:]/g, '');
|
||||||
|
setMetadata({ duration: sanitized });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
{/* Video Title */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="video-title" className="text-muted-foreground text-sm">
|
<Label htmlFor="video-title" className="text-muted-foreground text-sm">
|
||||||
Video Title
|
Video Title
|
||||||
@@ -14,13 +37,18 @@ export const UserInfoInputs = () => {
|
|||||||
<Input
|
<Input
|
||||||
id="video-title"
|
id="video-title"
|
||||||
type="text"
|
type="text"
|
||||||
value={userTitle}
|
value={metadata.title}
|
||||||
onChange={(e) => setUserTitle(e.target.value)}
|
onChange={(e) => handleTitleChange(e.target.value)}
|
||||||
placeholder="Your Video Title Here"
|
placeholder="Your Video Title Here"
|
||||||
|
maxLength={100}
|
||||||
className="bg-surface-1 border-border focus:border-primary transition-colors"
|
className="bg-surface-1 border-border focus:border-primary transition-colors"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground/60">
|
||||||
|
{metadata.title.length}/100 characters
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Channel Name */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="channel-name" className="text-muted-foreground text-sm">
|
<Label htmlFor="channel-name" className="text-muted-foreground text-sm">
|
||||||
Channel Name
|
Channel Name
|
||||||
@@ -28,11 +56,52 @@ export const UserInfoInputs = () => {
|
|||||||
<Input
|
<Input
|
||||||
id="channel-name"
|
id="channel-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={userChannel}
|
value={metadata.channelName}
|
||||||
onChange={(e) => setUserChannel(e.target.value)}
|
onChange={(e) => handleChannelChange(e.target.value)}
|
||||||
placeholder="Your Channel"
|
placeholder="Your Channel"
|
||||||
|
maxLength={50}
|
||||||
className="bg-surface-1 border-border focus:border-primary transition-colors"
|
className="bg-surface-1 border-border focus:border-primary transition-colors"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground/60">
|
||||||
|
{metadata.channelName.length}/50 characters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Count */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="view-count" className="text-muted-foreground text-sm">
|
||||||
|
View Count
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="view-count"
|
||||||
|
type="text"
|
||||||
|
value={metadata.viewCount.toLocaleString()}
|
||||||
|
onChange={(e) => handleViewCountChange(e.target.value)}
|
||||||
|
placeholder="125000"
|
||||||
|
className="bg-surface-1 border-border focus:border-primary transition-colors"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground/60">
|
||||||
|
Displays as: {formatViewCount(metadata.viewCount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="duration" className="text-muted-foreground text-sm">
|
||||||
|
Duration
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="duration"
|
||||||
|
type="text"
|
||||||
|
value={metadata.duration}
|
||||||
|
onChange={(e) => handleDurationChange(e.target.value)}
|
||||||
|
placeholder="10:30"
|
||||||
|
maxLength={8}
|
||||||
|
className="bg-surface-1 border-border focus:border-primary transition-colors"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground/60">
|
||||||
|
Format: MM:SS or H:MM:SS
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const ViewSwitcher = () => {
|
|||||||
key={view.id}
|
key={view.id}
|
||||||
value={view.id}
|
value={view.id}
|
||||||
aria-label={view.label}
|
aria-label={view.label}
|
||||||
className="data-[state=on]:bg-surface-3 data-[state=on]:text-foreground rounded-md transition-colors"
|
className="data-[state=on]:bg-surface-3 data-[state=on]:text-foreground rounded-md transition-colors min-w-27.5 justify-center cursor-pointer"
|
||||||
>
|
>
|
||||||
{view.icon}
|
{view.icon}
|
||||||
<span className="ml-2">{view.label}</span>
|
<span className="ml-2">{view.label}</span>
|
||||||
|
|||||||
@@ -1,14 +1,69 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Clock, ListPlus, MoreVertical } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { PreviewMode } from '../types';
|
||||||
|
|
||||||
|
// YouTube official verified badge SVG
|
||||||
|
const VerifiedBadge = ({ className }: { className?: string }) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
focusable="false"
|
||||||
|
className={className}
|
||||||
|
style={{ pointerEvents: 'none', display: 'inline-block' }}
|
||||||
|
>
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zM9.8 17.3l-4.2-4.1L7 11.8l2.8 2.7L17 7.4l1.4 1.4-8.6 8.5z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// YouTube-like avatar colors based on channel name
|
||||||
|
const AVATAR_COLORS = [
|
||||||
|
'#ef4444', // red
|
||||||
|
'#f97316', // orange
|
||||||
|
'#eab308', // yellow
|
||||||
|
'#22c55e', // green
|
||||||
|
'#14b8a6', // teal
|
||||||
|
'#3b82f6', // blue
|
||||||
|
'#8b5cf6', // violet
|
||||||
|
'#ec4899', // pink
|
||||||
|
'#6366f1', // indigo
|
||||||
|
'#06b6d4', // cyan
|
||||||
|
];
|
||||||
|
|
||||||
|
function getChannelColor(channelName: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < channelName.length; i++) {
|
||||||
|
hash = channelName.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
interface YouTubeVideoCardProps {
|
interface YouTubeVideoCardProps {
|
||||||
|
/** Thumbnail image URL or data URL */
|
||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
|
/** Video title */
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Channel name */
|
||||||
channelTitle: string;
|
channelTitle: string;
|
||||||
|
/** Formatted view count (e.g., "1.2M views") */
|
||||||
viewCount?: string;
|
viewCount?: string;
|
||||||
|
/** Relative time string (e.g., "3 days ago") */
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
|
/** Whether this is user's uploaded thumbnail (for highlighting) */
|
||||||
isUserThumbnail?: boolean;
|
isUserThumbnail?: boolean;
|
||||||
variant?: 'search' | 'sidebar' | 'mobile';
|
/** Layout variant */
|
||||||
|
variant?: PreviewMode | 'search';
|
||||||
|
/** Video duration string */
|
||||||
duration?: string;
|
duration?: string;
|
||||||
|
/** Optional channel avatar URL */
|
||||||
|
channelAvatarUrl?: string;
|
||||||
|
/** Whether the channel is verified */
|
||||||
|
isVerified?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const YouTubeVideoCard = ({
|
export const YouTubeVideoCard = ({
|
||||||
@@ -17,129 +72,380 @@ export const YouTubeVideoCard = ({
|
|||||||
channelTitle,
|
channelTitle,
|
||||||
viewCount,
|
viewCount,
|
||||||
publishedAt,
|
publishedAt,
|
||||||
variant = 'search',
|
variant = 'desktop',
|
||||||
duration = '10:30',
|
duration = '10:30',
|
||||||
|
channelAvatarUrl,
|
||||||
|
isVerified = false,
|
||||||
}: YouTubeVideoCardProps) => {
|
}: YouTubeVideoCardProps) => {
|
||||||
const formatViews = (views?: string) => {
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
if (!views) return '0 views';
|
|
||||||
const num = parseInt(views);
|
|
||||||
if (num >= 1000000) {
|
|
||||||
const formatted = (num / 1000000).toFixed(1);
|
|
||||||
return `${formatted.replace('.0', '')}M views`;
|
|
||||||
}
|
|
||||||
if (num >= 1000) {
|
|
||||||
const formatted = (num / 1000).toFixed(0);
|
|
||||||
return `${formatted}K views`;
|
|
||||||
}
|
|
||||||
return `${num} views`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date?: string) => {
|
// Format view count from string or use as-is
|
||||||
if (!date) return '';
|
const formattedViews = viewCount || 'No views';
|
||||||
const diff = Date.now() - new Date(date).getTime();
|
|
||||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
||||||
if (days < 1) return 'Today';
|
|
||||||
if (days === 1) return '1 day ago';
|
|
||||||
if (days < 7) return `${days} days ago`;
|
|
||||||
const weeks = Math.floor(days / 7);
|
|
||||||
if (weeks === 1) return '1 week ago';
|
|
||||||
if (days < 30) return `${weeks} weeks ago`;
|
|
||||||
const months = Math.floor(days / 30);
|
|
||||||
if (months === 1) return '1 month ago';
|
|
||||||
if (days < 365) return `${months} months ago`;
|
|
||||||
const years = Math.floor(days / 365);
|
|
||||||
if (years === 1) return '1 year ago';
|
|
||||||
return `${years} years ago`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sidebar variant (YouTube "Up next" style)
|
// Format date if it's a date string
|
||||||
|
const formattedTime = publishedAt || '';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sidebar Variant - YouTube "Up next" style (168x94px thumbnail)
|
||||||
|
// No avatar, horizontal layout, compact spacing
|
||||||
|
// ============================================================================
|
||||||
if (variant === 'sidebar') {
|
if (variant === 'sidebar') {
|
||||||
return (
|
return (
|
||||||
<div className="yt-card flex gap-2 cursor-pointer group">
|
<div className="yt-card flex cursor-pointer group" style={{ gap: '8px' }}>
|
||||||
<div className="relative flex-shrink-0 w-[168px] h-[94px] rounded-lg overflow-hidden bg-yt-hover">
|
{/* Thumbnail - 168x94px, 8px radius */}
|
||||||
|
<div
|
||||||
|
className="relative flex-shrink-0 overflow-hidden bg-yt-hover"
|
||||||
|
style={{
|
||||||
|
width: '168px',
|
||||||
|
height: '94px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={thumbnailUrl}
|
src={thumbnailUrl}
|
||||||
alt={title}
|
alt={title}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
<span className="absolute bottom-1 right-1 bg-black/80 text-white text-xs font-medium px-1 py-0.5 rounded">
|
{/* Duration badge - bottom-right, 4px radius */}
|
||||||
|
<span
|
||||||
|
className="absolute text-white"
|
||||||
|
style={{
|
||||||
|
bottom: '4px',
|
||||||
|
right: '4px',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
padding: '3px 4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
lineHeight: 'normal',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{duration}
|
{duration}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0 pr-6">
|
|
||||||
<h3 className="yt-title text-sm font-medium line-clamp-2 mb-1">
|
{/* Metadata - right side, no avatar */}
|
||||||
|
<div className="flex-1 min-w-0 pr-6 relative">
|
||||||
|
{/* Title - 14px/500, 2-line clamp */}
|
||||||
|
<h3
|
||||||
|
className="yt-title line-clamp-2"
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: '20px',
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="yt-meta text-xs">
|
{/* Channel name - 12px, meta color */}
|
||||||
{channelTitle}
|
<div className="flex items-center flex-wrap">
|
||||||
</p>
|
<p
|
||||||
<p className="yt-meta text-xs">
|
className="yt-meta"
|
||||||
{formatViews(viewCount)} · {formatDate(publishedAt)}
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '18px',
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channelTitle}
|
||||||
|
</p>
|
||||||
|
{isVerified && (
|
||||||
|
<VerifiedBadge className="w-3.5 h-3.5 ml-1 text-yt-meta" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Views and time */}
|
||||||
|
<p
|
||||||
|
className="yt-meta"
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '18px',
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formattedViews} · {formattedTime}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Action Menu (3 dots) */}
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0 right-[-8px] p-1 opacity-0 group-hover:opacity-100 transition-opacity',
|
||||||
|
'hover:bg-yt-hover rounded-full'
|
||||||
|
)}
|
||||||
|
aria-label="Action menu"
|
||||||
|
>
|
||||||
|
<MoreVertical className="size-4 text-yt-title" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile variant
|
// ============================================================================
|
||||||
|
// Mobile Variant - Full-width thumbnail, 0px radius (edge-to-edge)
|
||||||
|
// Avatar below, 36x36px
|
||||||
|
// ============================================================================
|
||||||
if (variant === 'mobile') {
|
if (variant === 'mobile') {
|
||||||
return (
|
return (
|
||||||
<div className="yt-card cursor-pointer">
|
<div className="yt-card cursor-pointer">
|
||||||
<div className="relative w-full aspect-video bg-yt-hover rounded-xl overflow-hidden">
|
{/* Thumbnail - 100% width, 16:9, 0px radius */}
|
||||||
|
<div
|
||||||
|
className="relative w-full bg-yt-hover overflow-hidden"
|
||||||
|
style={{
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
borderRadius: '0px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={thumbnailUrl}
|
src={thumbnailUrl}
|
||||||
alt={title}
|
alt={title}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
<span className="absolute bottom-2 right-2 bg-black/80 text-white text-xs font-medium px-1 py-0.5 rounded">
|
{/* Duration badge */}
|
||||||
|
<span
|
||||||
|
className="absolute text-white"
|
||||||
|
style={{
|
||||||
|
bottom: '4px',
|
||||||
|
right: '4px',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
padding: '3px 4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
lineHeight: 'normal',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{duration}
|
{duration}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 mt-3 px-1">
|
|
||||||
<div className="w-9 h-9 rounded-full bg-yt-chip-bg flex-shrink-0 overflow-hidden">
|
{/* Metadata - below thumbnail */}
|
||||||
<div className="w-full h-full bg-gradient-to-br from-purple-500 to-pink-500" />
|
<div className="flex px-3" style={{ gap: '12px', marginTop: '12px' }}>
|
||||||
|
{/* Avatar - 36x36px circle */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channelAvatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={channelAvatarUrl}
|
||||||
|
alt={channelTitle}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-gradient-to-br from-purple-500 to-pink-500" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Text content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="yt-title text-sm font-medium line-clamp-2 leading-5">
|
{/* Title - 14px/500, 2-line clamp */}
|
||||||
|
<h3
|
||||||
|
className="yt-title line-clamp-2"
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: '20px',
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="yt-meta text-xs mt-1">
|
{/* Channel, views, time - single line */}
|
||||||
{channelTitle} · {formatViews(viewCount)} · {formatDate(publishedAt)}
|
<div
|
||||||
</p>
|
className="yt-meta flex items-center flex-wrap"
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '18px',
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{channelTitle}</span>
|
||||||
|
{isVerified && (
|
||||||
|
<VerifiedBadge className="w-3 h-3 mx-1 text-yt-meta" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{' '}
|
||||||
|
· {formattedViews} · {formattedTime}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Action Menu - Always visible on mobile */}
|
||||||
|
<button className="flex-shrink-0 -mr-2 p-1 text-yt-title" aria-label="Action menu">
|
||||||
|
<MoreVertical className="size-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search/Home variant (default)
|
// ============================================================================
|
||||||
|
// Desktop/Search Variant (default) - responsive thumbnail, 12px radius
|
||||||
|
// Avatar left, title/metadata right, hover states
|
||||||
|
// ============================================================================
|
||||||
return (
|
return (
|
||||||
<div className="yt-card cursor-pointer group">
|
<div
|
||||||
<div className="relative w-full aspect-video bg-yt-hover rounded-xl overflow-hidden">
|
className="yt-card cursor-pointer group w-full"
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
{/* Thumbnail - responsive, 16:9 aspect ratio, 12px radius */}
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden bg-yt-hover w-full"
|
||||||
|
style={{
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
borderRadius: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={thumbnailUrl}
|
src={thumbnailUrl}
|
||||||
alt={title}
|
alt={title}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
<span className="absolute bottom-2 right-2 bg-black/80 text-white text-xs font-medium px-1 py-0.5 rounded">
|
|
||||||
|
{/* Duration badge - bottom-right */}
|
||||||
|
<span
|
||||||
|
className="absolute text-white"
|
||||||
|
style={{
|
||||||
|
bottom: '4px',
|
||||||
|
right: '4px',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
padding: '3px 4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
lineHeight: 'normal',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{duration}
|
{duration}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* Hover overlay with icons */}
|
||||||
|
{isHovered && (
|
||||||
|
<div className="absolute top-2 right-2 flex flex-col gap-1">
|
||||||
|
{/* Watch later button */}
|
||||||
|
<button
|
||||||
|
className="p-1.5 bg-black/80 rounded-sm hover:bg-black transition-colors"
|
||||||
|
aria-label="Watch later"
|
||||||
|
>
|
||||||
|
<Clock className="size-4 text-white" />
|
||||||
|
</button>
|
||||||
|
{/* Add to queue button */}
|
||||||
|
<button
|
||||||
|
className="p-1.5 bg-black/80 rounded-sm hover:bg-black transition-colors"
|
||||||
|
aria-label="Add to queue"
|
||||||
|
>
|
||||||
|
<ListPlus className="size-4 text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 mt-3">
|
|
||||||
<div className="w-9 h-9 rounded-full bg-yt-chip-bg flex-shrink-0 overflow-hidden">
|
{/* Metadata - 12px gap from thumbnail */}
|
||||||
<div className="w-full h-full bg-gradient-to-br from-purple-500 to-pink-500" />
|
<div className="flex relative" style={{ gap: '12px', marginTop: '12px' }}>
|
||||||
|
{/* Avatar - 36x36px circle */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channelAvatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={channelAvatarUrl}
|
||||||
|
alt={channelTitle}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-full h-full flex items-center justify-center text-white font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: getChannelColor(channelTitle),
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channelTitle.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="yt-title text-sm font-medium line-clamp-2 leading-5 mb-1">
|
{/* Text content */}
|
||||||
|
<div className="flex-1 min-w-0 pr-6">
|
||||||
|
{/* Title - 16px/500, 22px line-height, 2-line clamp */}
|
||||||
|
<h3
|
||||||
|
className="yt-title line-clamp-2"
|
||||||
|
style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: '22px',
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="yt-meta text-xs hover:text-yt-title transition-colors">
|
|
||||||
{channelTitle}
|
{/* Channel name - 12px, meta color, hover effect */}
|
||||||
</p>
|
<div className="flex items-center flex-wrap">
|
||||||
<p className="yt-meta text-xs">
|
<p
|
||||||
{formatViews(viewCount)} · {formatDate(publishedAt)}
|
className={cn(
|
||||||
|
'yt-meta transition-colors cursor-pointer',
|
||||||
|
isHovered && 'text-yt-title'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '18px',
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channelTitle}
|
||||||
|
</p>
|
||||||
|
{isVerified && (
|
||||||
|
<VerifiedBadge className="w-3.5 h-3.5 ml-1 text-yt-meta" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Views and time */}
|
||||||
|
<p
|
||||||
|
className="yt-meta"
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '18px',
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formattedViews} · {formattedTime}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Action Menu (3 dots) - Absolute positioned right */}
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0 right-[-8px] p-2 opacity-0 group-hover:opacity-100 transition-opacity',
|
||||||
|
'hover:bg-yt-hover rounded-full'
|
||||||
|
)}
|
||||||
|
aria-label="Action menu"
|
||||||
|
>
|
||||||
|
<MoreVertical className="size-5 text-yt-title" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"focus-visible:border-ring focus-visible:ring-0",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
133
frontend/src/hooks/useLocalStorage.ts
Normal file
133
frontend/src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for persisting state to localStorage with JSON serialization
|
||||||
|
*
|
||||||
|
* @param key - localStorage key
|
||||||
|
* @param initialValue - default value if no stored value exists
|
||||||
|
* @returns [value, setValue, remove] tuple
|
||||||
|
*/
|
||||||
|
export function useLocalStorage<T>(
|
||||||
|
key: string,
|
||||||
|
initialValue: T
|
||||||
|
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
|
||||||
|
// Initialize state with stored value or initial value
|
||||||
|
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const item = window.localStorage.getItem(key);
|
||||||
|
if (item === null) {
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse stored JSON, handling Date objects
|
||||||
|
const parsed = JSON.parse(item, (_, value) => {
|
||||||
|
// Revive Date objects from ISO strings
|
||||||
|
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return parsed as T;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Error reading localStorage key "${key}":`, error);
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update localStorage when value changes
|
||||||
|
const setValue = useCallback(
|
||||||
|
(value: T | ((prev: T) => T)) => {
|
||||||
|
try {
|
||||||
|
// Handle function updates
|
||||||
|
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||||
|
setStoredValue(valueToStore);
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Error setting localStorage key "${key}":`, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[key, storedValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove from localStorage
|
||||||
|
const remove = useCallback(() => {
|
||||||
|
try {
|
||||||
|
setStoredValue(initialValue);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Error removing localStorage key "${key}":`, error);
|
||||||
|
}
|
||||||
|
}, [key, initialValue]);
|
||||||
|
|
||||||
|
// Listen for changes from other tabs/windows
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStorageChange = (event: StorageEvent) => {
|
||||||
|
if (event.key === key && event.newValue !== null) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.newValue, (_, value) => {
|
||||||
|
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
setStoredValue(parsed as T);
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors from other tabs
|
||||||
|
}
|
||||||
|
} else if (event.key === key && event.newValue === null) {
|
||||||
|
// Key was removed in another tab
|
||||||
|
setStoredValue(initialValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('storage', handleStorageChange);
|
||||||
|
return () => window.removeEventListener('storage', handleStorageChange);
|
||||||
|
}, [key, initialValue]);
|
||||||
|
|
||||||
|
return [storedValue, setValue, remove];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get localStorage usage in bytes
|
||||||
|
*/
|
||||||
|
export function getLocalStorageUsage(): number {
|
||||||
|
if (typeof window === 'undefined') return 0;
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
for (const key in window.localStorage) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(window.localStorage, key)) {
|
||||||
|
const value = window.localStorage.getItem(key);
|
||||||
|
if (value) {
|
||||||
|
// UTF-16 encoding: 2 bytes per character
|
||||||
|
total += (key.length + value.length) * 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if localStorage has sufficient space (approximately)
|
||||||
|
* Most browsers allow ~5MB
|
||||||
|
*/
|
||||||
|
export function hasLocalStorageSpace(requiredBytes: number): boolean {
|
||||||
|
const MAX_STORAGE = 5 * 1024 * 1024; // 5MB
|
||||||
|
const currentUsage = getLocalStorageUsage();
|
||||||
|
return currentUsage + requiredBytes < MAX_STORAGE;
|
||||||
|
}
|
||||||
@@ -266,6 +266,7 @@ body {
|
|||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
YOUTUBE THEME VARIABLES
|
YOUTUBE THEME VARIABLES
|
||||||
Pixel-perfect YouTube colors for both light and dark modes
|
Pixel-perfect YouTube colors for both light and dark modes
|
||||||
|
Extracted from live YouTube (January 2026)
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
||||||
/* Light mode YouTube colors */
|
/* Light mode YouTube colors */
|
||||||
@@ -282,6 +283,43 @@ body {
|
|||||||
--yt-chip-active-text: #ffffff;
|
--yt-chip-active-text: #ffffff;
|
||||||
--yt-blue: #065fd4;
|
--yt-blue: #065fd4;
|
||||||
--yt-red: #ff0000;
|
--yt-red: #ff0000;
|
||||||
|
|
||||||
|
/* YouTube Typography */
|
||||||
|
--yt-font-family: 'Roboto', 'Arial', sans-serif;
|
||||||
|
--yt-title-desktop-size: 16px;
|
||||||
|
--yt-title-desktop-weight: 500;
|
||||||
|
--yt-title-desktop-line-height: 22px;
|
||||||
|
--yt-title-sidebar-size: 14px;
|
||||||
|
--yt-title-sidebar-weight: 500;
|
||||||
|
--yt-title-sidebar-line-height: 20px;
|
||||||
|
--yt-meta-size: 12px;
|
||||||
|
--yt-meta-weight: 400;
|
||||||
|
--yt-meta-line-height: 18px;
|
||||||
|
|
||||||
|
/* YouTube Dimensions */
|
||||||
|
--yt-thumbnail-desktop-width: 360px;
|
||||||
|
--yt-thumbnail-desktop-height: 202px;
|
||||||
|
--yt-thumbnail-desktop-radius: 12px;
|
||||||
|
--yt-thumbnail-sidebar-width: 168px;
|
||||||
|
--yt-thumbnail-sidebar-height: 94px;
|
||||||
|
--yt-thumbnail-sidebar-radius: 8px;
|
||||||
|
--yt-avatar-size: 36px;
|
||||||
|
|
||||||
|
/* YouTube Duration Badge */
|
||||||
|
--yt-duration-bg: rgba(0, 0, 0, 0.8);
|
||||||
|
--yt-duration-color: #ffffff;
|
||||||
|
--yt-duration-size: 12px;
|
||||||
|
--yt-duration-weight: 500;
|
||||||
|
--yt-duration-padding: 3px 4px;
|
||||||
|
--yt-duration-radius: 4px;
|
||||||
|
|
||||||
|
/* YouTube Spacing */
|
||||||
|
--yt-card-gap-h: 16px;
|
||||||
|
--yt-card-gap-v: 40px;
|
||||||
|
--yt-thumbnail-meta-gap: 12px;
|
||||||
|
--yt-avatar-text-gap: 12px;
|
||||||
|
--yt-sidebar-card-gap: 8px;
|
||||||
|
--yt-sidebar-thumbnail-text-gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode YouTube colors */
|
/* Dark mode YouTube colors */
|
||||||
|
|||||||
259
frontend/src/lib/youtube-styles.ts
Normal file
259
frontend/src/lib/youtube-styles.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* YouTube Style Constants Module
|
||||||
|
* Extracted CSS tokens from live YouTube (January 2026)
|
||||||
|
* Source: specs/002-youtube-design-preview/research.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Typography
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const YT_TYPOGRAPHY = {
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
|
||||||
|
title: {
|
||||||
|
desktop: {
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: '22px',
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '26px',
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: '20px',
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: '20px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
channelName: {
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '18px',
|
||||||
|
},
|
||||||
|
|
||||||
|
metadata: {
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '18px',
|
||||||
|
},
|
||||||
|
|
||||||
|
durationBadge: {
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 'normal',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Thumbnail Dimensions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const YT_THUMBNAIL = {
|
||||||
|
desktop: {
|
||||||
|
width: 360,
|
||||||
|
height: 202,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
width: 168,
|
||||||
|
height: 94,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
borderRadius: 0,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Avatar Dimensions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const YT_AVATAR = {
|
||||||
|
desktop: {
|
||||||
|
size: 36,
|
||||||
|
borderRadius: '50%',
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
size: 36,
|
||||||
|
borderRadius: '50%',
|
||||||
|
},
|
||||||
|
sidebar: null, // No avatar in sidebar
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Duration Badge
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const YT_DURATION_BADGE = {
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
padding: '3px 4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
position: {
|
||||||
|
bottom: '4px',
|
||||||
|
right: '4px',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Colors
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const YT_COLORS = {
|
||||||
|
light: {
|
||||||
|
bg: '#ffffff',
|
||||||
|
surface: '#ffffff',
|
||||||
|
title: '#0f0f0f',
|
||||||
|
meta: '#606060',
|
||||||
|
icon: '#606060',
|
||||||
|
border: '#e5e5e5',
|
||||||
|
hover: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
chipBg: '#f2f2f2',
|
||||||
|
chipActiveBg: '#0f0f0f',
|
||||||
|
chipActiveText: '#ffffff',
|
||||||
|
blue: '#065fd4',
|
||||||
|
red: '#ff0000',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
bg: '#0f0f0f',
|
||||||
|
surface: '#0f0f0f',
|
||||||
|
title: '#f1f1f1',
|
||||||
|
meta: '#aaaaaa',
|
||||||
|
icon: '#aaaaaa',
|
||||||
|
border: '#3f3f3f',
|
||||||
|
hover: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
chipBg: '#272727',
|
||||||
|
chipActiveBg: '#f1f1f1',
|
||||||
|
chipActiveText: '#0f0f0f',
|
||||||
|
blue: '#3ea6ff',
|
||||||
|
red: '#ff0000',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Spacing & Layout
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const YT_SPACING = {
|
||||||
|
desktop: {
|
||||||
|
cardGapHorizontal: 16,
|
||||||
|
cardGapVertical: 40,
|
||||||
|
thumbnailToMetadata: 12,
|
||||||
|
avatarToText: 12,
|
||||||
|
titleMaxLines: 2,
|
||||||
|
channelMaxLines: 1,
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
cardGapVertical: 8,
|
||||||
|
thumbnailToText: 8,
|
||||||
|
titleMaxLines: 2,
|
||||||
|
channelMaxLines: 1,
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
thumbnailToMetadata: 12,
|
||||||
|
avatarToText: 12,
|
||||||
|
titleMaxLines: 2,
|
||||||
|
channelMaxLines: 1,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Responsive Breakpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const YT_BREAKPOINTS = {
|
||||||
|
columns: {
|
||||||
|
6: 2136, // > 2136px = 6 columns
|
||||||
|
5: 1712, // 1712-2136px = 5 columns
|
||||||
|
4: 1288, // 1288-1712px = 4 columns
|
||||||
|
3: 888, // 888-1288px = 3 columns
|
||||||
|
2: 512, // 512-888px = 2 columns
|
||||||
|
1: 0, // < 512px = 1 column (mobile)
|
||||||
|
},
|
||||||
|
thumbnailWidths: {
|
||||||
|
6: 356,
|
||||||
|
5: 342,
|
||||||
|
4: 320,
|
||||||
|
3: 290,
|
||||||
|
2: 280,
|
||||||
|
1: '100%',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utility Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format view count to YouTube style (e.g., "1.2M views")
|
||||||
|
*/
|
||||||
|
export function formatViewCount(views: number): string {
|
||||||
|
if (views >= 1_000_000_000) {
|
||||||
|
const formatted = (views / 1_000_000_000).toFixed(1);
|
||||||
|
return `${formatted.replace('.0', '')}B views`;
|
||||||
|
}
|
||||||
|
if (views >= 1_000_000) {
|
||||||
|
const formatted = (views / 1_000_000).toFixed(1);
|
||||||
|
return `${formatted.replace('.0', '')}M views`;
|
||||||
|
}
|
||||||
|
if (views >= 1_000) {
|
||||||
|
const formatted = (views / 1_000).toFixed(0);
|
||||||
|
return `${formatted}K views`;
|
||||||
|
}
|
||||||
|
if (views === 0) {
|
||||||
|
return 'No views';
|
||||||
|
}
|
||||||
|
return `${views} views`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date to YouTube style relative time (e.g., "3 days ago")
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(date: Date): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - date.getTime();
|
||||||
|
|
||||||
|
if (diff < 0) {
|
||||||
|
return 'Just now';
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
const weeks = Math.floor(days / 7);
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
const years = Math.floor(days / 365);
|
||||||
|
|
||||||
|
if (seconds < 60) return 'Just now';
|
||||||
|
if (minutes < 60) return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`;
|
||||||
|
if (hours < 24) return hours === 1 ? '1 hour ago' : `${hours} hours ago`;
|
||||||
|
if (days < 7) return days === 1 ? '1 day ago' : `${days} days ago`;
|
||||||
|
if (weeks < 4) return weeks === 1 ? '1 week ago' : `${weeks} weeks ago`;
|
||||||
|
if (months < 12) return months === 1 ? '1 month ago' : `${months} months ago`;
|
||||||
|
return years === 1 ? '1 year ago' : `${years} years ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate duration format (MM:SS or H:MM:SS)
|
||||||
|
*/
|
||||||
|
export function isValidDuration(duration: string): boolean {
|
||||||
|
return /^\d{1,2}:\d{2}(:\d{2})?$/.test(duration);
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { usePreviewStore } from '../store/previewStore';
|
||||||
import LogoDark from '../assets/logo-v2-5.svg';
|
import LogoDark from '../assets/logo-v2-5.svg';
|
||||||
|
import LogoLight from '../assets/logo-v2-5-light.svg';
|
||||||
|
|
||||||
const FEATURES = [
|
const FEATURES = [
|
||||||
{
|
{
|
||||||
@@ -85,13 +87,16 @@ const STATS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function LandingPage() {
|
export function LandingPage() {
|
||||||
|
const { themeMode } = usePreviewStore();
|
||||||
|
const isDark = themeMode === 'dark';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground overflow-hidden">
|
<div className="min-h-screen bg-background text-foreground overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="border-b border-border/50 backdrop-blur-sm bg-background/80 sticky top-0 z-50">
|
<header className="border-b border-border/50 backdrop-blur-sm bg-background/80 sticky top-0 z-50">
|
||||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
<Link to="/" className="flex items-center gap-3">
|
<Link to="/" className="flex items-center gap-3">
|
||||||
<img src={LogoDark} alt="PrevThumb" className="size-10" />
|
<img src={isDark ? LogoDark : LogoLight} alt="PrevThumb" className="size-10" />
|
||||||
<span className="text-xl font-semibold tracking-tight">PrevThumb</span>
|
<span className="text-xl font-semibold tracking-tight">PrevThumb</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -399,7 +404,7 @@ export function LandingPage() {
|
|||||||
<div className="max-w-7xl mx-auto px-6">
|
<div className="max-w-7xl mx-auto px-6">
|
||||||
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
|
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
|
||||||
<Link to="/" className="flex items-center gap-3">
|
<Link to="/" className="flex items-center gap-3">
|
||||||
<img src={LogoDark} alt="PrevThumb" className="size-8" />
|
<img src={isDark ? LogoDark : LogoLight} alt="PrevThumb" className="size-8" />
|
||||||
<span className="text-sm font-medium">PrevThumb</span>
|
<span className="text-sm font-medium">PrevThumb</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Sun, Moon } from 'lucide-react';
|
|
||||||
import { ThumbnailUploader } from '../components/ThumbnailUploader';
|
import { ThumbnailUploader } from '../components/ThumbnailUploader';
|
||||||
import { SearchInput } from '../components/SearchInput';
|
import { SearchInput } from '../components/SearchInput';
|
||||||
import { ThumbnailSelector } from '../components/ThumbnailSelector';
|
import { ThumbnailSelector } from '../components/ThumbnailSelector';
|
||||||
@@ -8,26 +6,20 @@ import { ViewSwitcher } from '../components/ViewSwitcher';
|
|||||||
import { PreviewGrid } from '../components/PreviewGrid';
|
import { PreviewGrid } from '../components/PreviewGrid';
|
||||||
import { UserInfoInputs } from '../components/UserInfoInputs';
|
import { UserInfoInputs } from '../components/UserInfoInputs';
|
||||||
import { UserMenu } from '../components/UserMenu';
|
import { UserMenu } from '../components/UserMenu';
|
||||||
|
import { ThemeToggle } from '../components/ThemeToggle';
|
||||||
import { usePreviewStore } from '../store/previewStore';
|
import { usePreviewStore } from '../store/previewStore';
|
||||||
import LogoDark from '../assets/logo-v2-5.svg';
|
import LogoDark from '../assets/logo-v2-5.svg';
|
||||||
import LogoLight from '../assets/logo-v2-5-light.svg';
|
import LogoLight from '../assets/logo-v2-5-light.svg';
|
||||||
|
|
||||||
export function ToolPage() {
|
export function ToolPage() {
|
||||||
const { thumbnails, youtubeResults } = usePreviewStore();
|
const {
|
||||||
|
thumbnails,
|
||||||
|
youtubeResults,
|
||||||
|
themeMode,
|
||||||
|
setThemeMode,
|
||||||
|
} = usePreviewStore();
|
||||||
const hasContent = thumbnails.length > 0 || youtubeResults.length > 0;
|
const hasContent = thumbnails.length > 0 || youtubeResults.length > 0;
|
||||||
const [isDark, setIsDark] = useState(() => {
|
const isDark = themeMode === 'dark';
|
||||||
return document.documentElement.classList.contains('dark');
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isDark) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
}, [isDark]);
|
|
||||||
|
|
||||||
const toggleTheme = () => setIsDark(!isDark);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
@@ -40,13 +32,10 @@ export function ToolPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="flex items-center gap-6">
|
<nav className="flex items-center gap-6">
|
||||||
<button
|
<ThemeToggle
|
||||||
onClick={toggleTheme}
|
theme={themeMode}
|
||||||
className="p-2 rounded-lg hover:bg-muted transition-colors"
|
onThemeChange={setThemeMode}
|
||||||
aria-label="Toggle theme"
|
/>
|
||||||
>
|
|
||||||
{isDark ? <Sun className="size-5" /> : <Moon className="size-5" />}
|
|
||||||
</button>
|
|
||||||
<a href="#" className="text-muted-foreground hover:text-foreground transition-colors text-sm">
|
<a href="#" className="text-muted-foreground hover:text-foreground transition-colors text-sm">
|
||||||
Pricing
|
Pricing
|
||||||
</a>
|
</a>
|
||||||
@@ -84,23 +73,19 @@ export function ToolPage() {
|
|||||||
) : (
|
) : (
|
||||||
/* Preview Section */
|
/* Preview Section */
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Controls */}
|
{/* Controls & Thumbnail Management */}
|
||||||
<div className="flex flex-col lg:flex-row gap-6 items-start lg:items-center justify-between">
|
|
||||||
<div className="flex-1 w-full lg:w-auto lg:max-w-xl">
|
|
||||||
<SearchInput />
|
|
||||||
</div>
|
|
||||||
<ViewSwitcher />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thumbnail Management */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<SearchInput />
|
||||||
<ThumbnailSelector />
|
<ThumbnailSelector />
|
||||||
<UserInfoInputs />
|
<UserInfoInputs />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-6">
|
||||||
<p className="text-sm text-muted-foreground font-medium">Add more thumbnails</p>
|
<ViewSwitcher />
|
||||||
<ThumbnailUploader />
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground font-medium">Add more thumbnails</p>
|
||||||
|
<ThumbnailUploader />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { UploadedThumbnail, YouTubeVideo, ViewMode } from '../types';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
import type {
|
||||||
|
UploadedThumbnail,
|
||||||
|
YouTubeVideo,
|
||||||
|
ViewMode,
|
||||||
|
PreviewMode,
|
||||||
|
ThemeMode,
|
||||||
|
VideoMetadata,
|
||||||
|
UploadedPreviewThumbnail,
|
||||||
|
} from '../types';
|
||||||
|
import { DEFAULT_METADATA, VALIDATION_CONSTANTS } from '../types';
|
||||||
|
|
||||||
interface PreviewState {
|
// ============================================================================
|
||||||
|
// Legacy State (existing functionality)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface LegacyState {
|
||||||
thumbnails: UploadedThumbnail[];
|
thumbnails: UploadedThumbnail[];
|
||||||
activeThumbnailIndex: number;
|
activeThumbnailIndex: number;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -10,8 +24,37 @@ interface PreviewState {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
userTitle: string;
|
userTitle: string;
|
||||||
userChannel: string;
|
userChannel: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Actions
|
// ============================================================================
|
||||||
|
// YouTube Preview State (new functionality)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface YouTubePreviewState {
|
||||||
|
/** Currently active thumbnail for preview */
|
||||||
|
currentThumbnail: UploadedPreviewThumbnail | null;
|
||||||
|
/** Active preview layout mode */
|
||||||
|
previewMode: PreviewMode;
|
||||||
|
/** Active color theme */
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
/** Video metadata for display */
|
||||||
|
metadata: VideoMetadata;
|
||||||
|
/** Recently used thumbnails (max 10) */
|
||||||
|
recentThumbnails: UploadedPreviewThumbnail[];
|
||||||
|
/** UI state: metadata editor visibility */
|
||||||
|
showMetadataEditor: boolean;
|
||||||
|
/** Last persistence timestamp */
|
||||||
|
lastSaved: Date;
|
||||||
|
/** Schema version for migrations */
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Combined State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface PreviewState extends LegacyState, YouTubePreviewState {
|
||||||
|
// Legacy Actions
|
||||||
addThumbnail: (thumbnail: UploadedThumbnail) => void;
|
addThumbnail: (thumbnail: UploadedThumbnail) => void;
|
||||||
removeThumbnail: (id: string) => void;
|
removeThumbnail: (id: string) => void;
|
||||||
setActiveThumbnailIndex: (index: number) => void;
|
setActiveThumbnailIndex: (index: number) => void;
|
||||||
@@ -22,9 +65,24 @@ interface PreviewState {
|
|||||||
setUserTitle: (title: string) => void;
|
setUserTitle: (title: string) => void;
|
||||||
setUserChannel: (channel: string) => void;
|
setUserChannel: (channel: string) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
|
|
||||||
|
// YouTube Preview Actions
|
||||||
|
setCurrentThumbnail: (thumbnail: UploadedPreviewThumbnail | null) => void;
|
||||||
|
setPreviewMode: (mode: PreviewMode) => void;
|
||||||
|
setThemeMode: (mode: ThemeMode) => void;
|
||||||
|
setMetadata: (metadata: Partial<VideoMetadata>) => void;
|
||||||
|
setShowMetadataEditor: (show: boolean) => void;
|
||||||
|
addRecentThumbnail: (thumbnail: UploadedPreviewThumbnail) => void;
|
||||||
|
removeRecentThumbnail: (id: string) => void;
|
||||||
|
clearRecentThumbnails: () => void;
|
||||||
|
cleanupOldThumbnails: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState = {
|
// ============================================================================
|
||||||
|
// Initial State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const legacyInitialState: LegacyState = {
|
||||||
thumbnails: [],
|
thumbnails: [],
|
||||||
activeThumbnailIndex: 0,
|
activeThumbnailIndex: 0,
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
@@ -35,36 +93,209 @@ const initialState = {
|
|||||||
userChannel: 'Your Channel',
|
userChannel: 'Your Channel',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePreviewStore = create<PreviewState>((set) => ({
|
const youtubePreviewInitialState: YouTubePreviewState = {
|
||||||
...initialState,
|
currentThumbnail: null,
|
||||||
|
previewMode: 'desktop',
|
||||||
|
themeMode: 'light',
|
||||||
|
metadata: { ...DEFAULT_METADATA },
|
||||||
|
recentThumbnails: [],
|
||||||
|
showMetadataEditor: false,
|
||||||
|
lastSaved: new Date(),
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
addThumbnail: (thumbnail) =>
|
const initialState = {
|
||||||
set((state) => ({
|
...legacyInitialState,
|
||||||
thumbnails: [...state.thumbnails, thumbnail],
|
...youtubePreviewInitialState,
|
||||||
})),
|
};
|
||||||
|
|
||||||
removeThumbnail: (id) =>
|
// ============================================================================
|
||||||
set((state) => ({
|
// Store with Persistence
|
||||||
thumbnails: state.thumbnails.filter((t) => t.id !== id),
|
// ============================================================================
|
||||||
activeThumbnailIndex: Math.min(
|
|
||||||
state.activeThumbnailIndex,
|
|
||||||
Math.max(0, state.thumbnails.length - 2)
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
|
|
||||||
setActiveThumbnailIndex: (index) => set({ activeThumbnailIndex: index }),
|
export const usePreviewStore = create<PreviewState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
// ========================================================================
|
||||||
|
// Legacy Actions
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
setYoutubeResults: (results) => set({ youtubeResults: results }),
|
addThumbnail: (thumbnail) =>
|
||||||
|
set((state) => ({
|
||||||
|
thumbnails: [...state.thumbnails, thumbnail],
|
||||||
|
})),
|
||||||
|
|
||||||
setViewMode: (mode) => set({ viewMode: mode }),
|
removeThumbnail: (id) =>
|
||||||
|
set((state) => ({
|
||||||
|
thumbnails: state.thumbnails.filter((t) => t.id !== id),
|
||||||
|
activeThumbnailIndex: Math.min(
|
||||||
|
state.activeThumbnailIndex,
|
||||||
|
Math.max(0, state.thumbnails.length - 2)
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
setIsLoading: (loading) => set({ isLoading: loading }),
|
setActiveThumbnailIndex: (index) => set({ activeThumbnailIndex: index }),
|
||||||
|
|
||||||
setUserTitle: (title) => set({ userTitle: title }),
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
|
|
||||||
setUserChannel: (channel) => set({ userChannel: channel }),
|
setYoutubeResults: (results) => set({ youtubeResults: results }),
|
||||||
|
|
||||||
reset: () => set(initialState),
|
setViewMode: (mode) => set({ viewMode: mode }),
|
||||||
}));
|
|
||||||
|
setIsLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
|
||||||
|
setUserTitle: (title) =>
|
||||||
|
set((state) => ({
|
||||||
|
userTitle: title,
|
||||||
|
metadata: { ...state.metadata, title },
|
||||||
|
})),
|
||||||
|
|
||||||
|
setUserChannel: (channel) =>
|
||||||
|
set((state) => ({
|
||||||
|
userChannel: channel,
|
||||||
|
metadata: { ...state.metadata, channelName: channel },
|
||||||
|
})),
|
||||||
|
|
||||||
|
reset: () => set(initialState),
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// YouTube Preview Actions
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
setCurrentThumbnail: (thumbnail) =>
|
||||||
|
set((state) => {
|
||||||
|
// If setting a new thumbnail, add current to recent
|
||||||
|
if (thumbnail && state.currentThumbnail) {
|
||||||
|
const recentThumbnails = [
|
||||||
|
state.currentThumbnail,
|
||||||
|
...state.recentThumbnails.filter(
|
||||||
|
(t) => t.id !== state.currentThumbnail?.id
|
||||||
|
),
|
||||||
|
].slice(0, VALIDATION_CONSTANTS.MAX_RECENT_THUMBNAILS);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentThumbnail: thumbnail,
|
||||||
|
recentThumbnails,
|
||||||
|
lastSaved: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentThumbnail: thumbnail,
|
||||||
|
lastSaved: new Date(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
setPreviewMode: (mode) =>
|
||||||
|
set({
|
||||||
|
previewMode: mode,
|
||||||
|
lastSaved: new Date(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
setThemeMode: (mode) => {
|
||||||
|
// Apply theme to document
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
if (mode === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
themeMode: mode,
|
||||||
|
lastSaved: new Date(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setMetadata: (partialMetadata) =>
|
||||||
|
set((state) => ({
|
||||||
|
metadata: { ...state.metadata, ...partialMetadata },
|
||||||
|
lastSaved: new Date(),
|
||||||
|
})),
|
||||||
|
|
||||||
|
setShowMetadataEditor: (show) => set({ showMetadataEditor: show }),
|
||||||
|
|
||||||
|
addRecentThumbnail: (thumbnail) =>
|
||||||
|
set((state) => {
|
||||||
|
const filtered = state.recentThumbnails.filter(
|
||||||
|
(t) => t.id !== thumbnail.id
|
||||||
|
);
|
||||||
|
const updated = [thumbnail, ...filtered].slice(
|
||||||
|
0,
|
||||||
|
VALIDATION_CONSTANTS.MAX_RECENT_THUMBNAILS
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
recentThumbnails: updated,
|
||||||
|
lastSaved: new Date(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeRecentThumbnail: (id) =>
|
||||||
|
set((state) => ({
|
||||||
|
recentThumbnails: state.recentThumbnails.filter((t) => t.id !== id),
|
||||||
|
lastSaved: new Date(),
|
||||||
|
})),
|
||||||
|
|
||||||
|
clearRecentThumbnails: () =>
|
||||||
|
set({
|
||||||
|
recentThumbnails: [],
|
||||||
|
lastSaved: new Date(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
cleanupOldThumbnails: () =>
|
||||||
|
set((state) => ({
|
||||||
|
recentThumbnails: state.recentThumbnails.slice(
|
||||||
|
0,
|
||||||
|
VALIDATION_CONSTANTS.MAX_RECENT_THUMBNAILS
|
||||||
|
),
|
||||||
|
lastSaved: new Date(),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'thumbpreview_state',
|
||||||
|
storage: createJSONStorage(() => localStorage, {
|
||||||
|
reviver: (_key, value) => {
|
||||||
|
// Revive Date objects from ISO strings
|
||||||
|
if (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)
|
||||||
|
) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
partialize: (state) => ({
|
||||||
|
// Persist YouTube preview state
|
||||||
|
currentThumbnail: state.currentThumbnail,
|
||||||
|
previewMode: state.previewMode,
|
||||||
|
themeMode: state.themeMode,
|
||||||
|
metadata: state.metadata,
|
||||||
|
recentThumbnails: state.recentThumbnails,
|
||||||
|
showMetadataEditor: state.showMetadataEditor,
|
||||||
|
lastSaved: state.lastSaved,
|
||||||
|
version: state.version,
|
||||||
|
// Also persist legacy state for backward compatibility
|
||||||
|
userTitle: state.userTitle,
|
||||||
|
userChannel: state.userChannel,
|
||||||
|
}),
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
// Apply persisted theme on rehydration
|
||||||
|
if (state?.themeMode && typeof document !== 'undefined') {
|
||||||
|
if (state.themeMode === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|||||||
@@ -24,3 +24,125 @@ export interface PreviewSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ViewMode = 'search' | 'sidebar' | 'mobile';
|
export type ViewMode = 'search' | 'sidebar' | 'mobile';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// YouTube Design Preview Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview display modes matching YouTube's layout variations
|
||||||
|
*/
|
||||||
|
export type PreviewMode = 'desktop' | 'sidebar' | 'mobile';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme modes matching YouTube's color schemes
|
||||||
|
*/
|
||||||
|
export type ThemeMode = 'light' | 'dark';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported image formats for thumbnail upload
|
||||||
|
*/
|
||||||
|
export type SupportedImageFormat = 'image/jpeg' | 'image/png' | 'image/webp';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video metadata displayed alongside the thumbnail
|
||||||
|
*/
|
||||||
|
export interface VideoMetadata {
|
||||||
|
/** Video title (1-100 characters) */
|
||||||
|
title: string;
|
||||||
|
/** Channel name (1-50 characters) */
|
||||||
|
channelName: string;
|
||||||
|
/** Optional channel avatar URL or base64 data URL */
|
||||||
|
channelAvatarUrl?: string;
|
||||||
|
/** Duration in format "MM:SS" or "H:MM:SS" */
|
||||||
|
duration: string;
|
||||||
|
/** View count as raw number (formatted for display) */
|
||||||
|
viewCount: number;
|
||||||
|
/** Publication date (used for relative time display) */
|
||||||
|
publishedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploaded thumbnail image with metadata for preview persistence
|
||||||
|
*/
|
||||||
|
export interface UploadedPreviewThumbnail {
|
||||||
|
/** Unique identifier (UUID) */
|
||||||
|
id: string;
|
||||||
|
/** Base64 data URL of the image */
|
||||||
|
dataUrl: string;
|
||||||
|
/** Original filename */
|
||||||
|
originalName: string;
|
||||||
|
/** File size in bytes (max 5MB = 5,242,880) */
|
||||||
|
fileSize: number;
|
||||||
|
/** MIME type */
|
||||||
|
mimeType: SupportedImageFormat;
|
||||||
|
/** Original image width */
|
||||||
|
width: number;
|
||||||
|
/** Original image height */
|
||||||
|
height: number;
|
||||||
|
/** Upload timestamp */
|
||||||
|
uploadedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete preview state persisted to localStorage
|
||||||
|
*/
|
||||||
|
export interface PreviewState {
|
||||||
|
/** Currently active thumbnail for preview */
|
||||||
|
currentThumbnail: UploadedPreviewThumbnail | null;
|
||||||
|
/** Active preview layout mode */
|
||||||
|
previewMode: PreviewMode;
|
||||||
|
/** Active color theme */
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
/** Video metadata for display */
|
||||||
|
metadata: VideoMetadata;
|
||||||
|
/** Recently used thumbnails (max 10) */
|
||||||
|
recentThumbnails: UploadedPreviewThumbnail[];
|
||||||
|
/** UI state: metadata editor visibility */
|
||||||
|
showMetadataEditor: boolean;
|
||||||
|
/** Last persistence timestamp */
|
||||||
|
lastSaved: Date;
|
||||||
|
/** Schema version for migrations */
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* localStorage serialization wrapper
|
||||||
|
*/
|
||||||
|
export interface LocalStorageSchema {
|
||||||
|
version: 1;
|
||||||
|
state: PreviewState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thumbnail upload validation result
|
||||||
|
*/
|
||||||
|
export interface ThumbnailValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation constants
|
||||||
|
*/
|
||||||
|
export const VALIDATION_CONSTANTS = {
|
||||||
|
MAX_FILE_SIZE: 5 * 1024 * 1024, // 5MB
|
||||||
|
MAX_TITLE_LENGTH: 100,
|
||||||
|
MAX_CHANNEL_NAME_LENGTH: 50,
|
||||||
|
MAX_RECENT_THUMBNAILS: 10,
|
||||||
|
VISUAL_MATCH_THRESHOLD: 98, // percentage
|
||||||
|
SUPPORTED_FORMATS: ['image/jpeg', 'image/png', 'image/webp'] as const,
|
||||||
|
DURATION_REGEX: /^\d{1,2}:\d{2}(:\d{2})?$/,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default metadata values
|
||||||
|
*/
|
||||||
|
export const DEFAULT_METADATA: VideoMetadata = {
|
||||||
|
title: 'Your Video Title Here',
|
||||||
|
channelName: 'Your Channel',
|
||||||
|
channelAvatarUrl: undefined,
|
||||||
|
duration: '10:30',
|
||||||
|
viewCount: 125000,
|
||||||
|
publishedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // 1 week ago
|
||||||
|
};
|
||||||
|
|||||||
128
frontend/tests/visual/desktop.spec.ts
Normal file
128
frontend/tests/visual/desktop.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { setupPreviewState } from './utils';
|
||||||
|
|
||||||
|
test.describe('Desktop Preview Visual Tests', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Navigate to the tool page
|
||||||
|
await page.goto('/tool');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop preview - light mode', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'desktop', theme: 'light' });
|
||||||
|
|
||||||
|
// Wait for YouTube card to be visible
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
// Verify card dimensions (360x202px thumbnail)
|
||||||
|
const thumbnail = ytCard.locator('img').first();
|
||||||
|
await expect(thumbnail).toBeVisible();
|
||||||
|
|
||||||
|
// Take screenshot for visual comparison
|
||||||
|
await expect(ytCard).toHaveScreenshot('desktop-light-card.png', {
|
||||||
|
threshold: 0.02,
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop preview - dark mode', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'desktop', theme: 'dark' });
|
||||||
|
|
||||||
|
// Verify dark mode is applied
|
||||||
|
const isDark = await page.evaluate(() =>
|
||||||
|
document.documentElement.classList.contains('dark')
|
||||||
|
);
|
||||||
|
expect(isDark).toBe(true);
|
||||||
|
|
||||||
|
// Wait for YouTube card
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
// Take screenshot for visual comparison
|
||||||
|
await expect(ytCard).toHaveScreenshot('desktop-dark-card.png', {
|
||||||
|
threshold: 0.02,
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop preview - hover state shows icons', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'desktop', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
const thumbnail = ytCard.locator('[style*="360px"]');
|
||||||
|
|
||||||
|
// Hover over the thumbnail
|
||||||
|
await thumbnail.hover();
|
||||||
|
|
||||||
|
// Wait for hover state
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Check that watch later and add to queue buttons are visible
|
||||||
|
const watchLaterButton = ytCard.locator('button[aria-label="Watch later"]');
|
||||||
|
const addToQueueButton = ytCard.locator('button[aria-label="Add to queue"]');
|
||||||
|
|
||||||
|
await expect(watchLaterButton).toBeVisible();
|
||||||
|
await expect(addToQueueButton).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop preview - typography matches YouTube', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'desktop', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
// Check title typography
|
||||||
|
const title = ytCard.locator('h3').first();
|
||||||
|
const titleStyles = await title.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
fontSize: computed.fontSize,
|
||||||
|
fontWeight: computed.fontWeight,
|
||||||
|
lineHeight: computed.lineHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(titleStyles.fontSize).toBe('16px');
|
||||||
|
expect(titleStyles.fontWeight).toBe('500');
|
||||||
|
expect(titleStyles.lineHeight).toBe('22px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop preview - duration badge styling', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'desktop', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
const durationBadge = ytCard.locator('span').filter({ hasText: /\d+:\d+/ }).first();
|
||||||
|
|
||||||
|
const badgeStyles = await durationBadge.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
fontSize: computed.fontSize,
|
||||||
|
fontWeight: computed.fontWeight,
|
||||||
|
borderRadius: computed.borderRadius,
|
||||||
|
padding: computed.padding,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(badgeStyles.fontSize).toBe('12px');
|
||||||
|
expect(badgeStyles.fontWeight).toBe('500');
|
||||||
|
expect(badgeStyles.borderRadius).toBe('4px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop preview - avatar dimensions', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'desktop', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
const avatar = ytCard.locator('[style*="36px"][style*="border-radius"]').first();
|
||||||
|
|
||||||
|
const avatarStyles = await avatar.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
width: computed.width,
|
||||||
|
height: computed.height,
|
||||||
|
borderRadius: computed.borderRadius,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(avatarStyles.width).toBe('36px');
|
||||||
|
expect(avatarStyles.height).toBe('36px');
|
||||||
|
expect(avatarStyles.borderRadius).toBe('50%');
|
||||||
|
});
|
||||||
|
});
|
||||||
115
frontend/tests/visual/mobile.spec.ts
Normal file
115
frontend/tests/visual/mobile.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { setupPreviewState } from './utils';
|
||||||
|
|
||||||
|
test.describe('Mobile Preview Visual Tests', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/tool');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile preview - light mode', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'mobile', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
// Take screenshot for visual comparison
|
||||||
|
await expect(ytCard).toHaveScreenshot('mobile-light-card.png', {
|
||||||
|
threshold: 0.02,
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile preview - dark mode', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'mobile', theme: 'dark' });
|
||||||
|
|
||||||
|
// Verify dark mode
|
||||||
|
const isDark = await page.evaluate(() =>
|
||||||
|
document.documentElement.classList.contains('dark')
|
||||||
|
);
|
||||||
|
expect(isDark).toBe(true);
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
await expect(ytCard).toHaveScreenshot('mobile-dark-card.png', {
|
||||||
|
threshold: 0.02,
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile preview - full-width thumbnail', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'mobile', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
const thumbnail = ytCard.locator('[style*="aspect-ratio"]').first();
|
||||||
|
|
||||||
|
const thumbnailStyles = await thumbnail.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
width: computed.width,
|
||||||
|
borderRadius: computed.borderRadius,
|
||||||
|
aspectRatio: computed.aspectRatio,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mobile thumbnail should be full width with 0px radius
|
||||||
|
expect(thumbnailStyles.borderRadius).toBe('0px');
|
||||||
|
expect(thumbnailStyles.aspectRatio).toContain('16');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile preview - avatar 36x36', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'mobile', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
const avatar = ytCard.locator('[style*="36px"]').first();
|
||||||
|
|
||||||
|
const avatarStyles = await avatar.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
width: computed.width,
|
||||||
|
height: computed.height,
|
||||||
|
borderRadius: computed.borderRadius,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(avatarStyles.width).toBe('36px');
|
||||||
|
expect(avatarStyles.height).toBe('36px');
|
||||||
|
expect(avatarStyles.borderRadius).toBe('50%');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile preview - metadata below thumbnail', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'mobile', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
// Verify layout structure: thumbnail first, then metadata
|
||||||
|
const children = await ytCard.locator('> div').all();
|
||||||
|
expect(children.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
// First child should be the thumbnail container (has aspect-ratio)
|
||||||
|
const firstChild = children[0];
|
||||||
|
const hasAspectRatio = await firstChild.evaluate((el) =>
|
||||||
|
el.getAttribute('style')?.includes('aspect-ratio')
|
||||||
|
);
|
||||||
|
expect(hasAspectRatio).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile preview - typography sizes', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'mobile', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
const title = ytCard.locator('h3').first();
|
||||||
|
|
||||||
|
const titleStyles = await title.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
fontSize: computed.fontSize,
|
||||||
|
fontWeight: computed.fontWeight,
|
||||||
|
lineHeight: computed.lineHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(titleStyles.fontSize).toBe('14px');
|
||||||
|
expect(titleStyles.fontWeight).toBe('500');
|
||||||
|
expect(titleStyles.lineHeight).toBe('20px');
|
||||||
|
});
|
||||||
|
});
|
||||||
140
frontend/tests/visual/sidebar.spec.ts
Normal file
140
frontend/tests/visual/sidebar.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { setupPreviewState } from './utils';
|
||||||
|
|
||||||
|
test.describe('Sidebar Preview Visual Tests', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/tool');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar preview - light mode', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'sidebar', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
// Take screenshot for visual comparison
|
||||||
|
await expect(ytCard).toHaveScreenshot('sidebar-light-card.png', {
|
||||||
|
threshold: 0.02,
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar preview - dark mode', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'sidebar', theme: 'dark' });
|
||||||
|
|
||||||
|
// Verify dark mode
|
||||||
|
const isDark = await page.evaluate(() =>
|
||||||
|
document.documentElement.classList.contains('dark')
|
||||||
|
);
|
||||||
|
expect(isDark).toBe(true);
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
await expect(ytCard).toHaveScreenshot('sidebar-dark-card.png', {
|
||||||
|
threshold: 0.02,
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar preview - thumbnail dimensions 168x94px', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'sidebar', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
const thumbnail = ytCard.locator('[style*="168px"]').first();
|
||||||
|
|
||||||
|
const thumbnailStyles = await thumbnail.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
width: computed.width,
|
||||||
|
height: computed.height,
|
||||||
|
borderRadius: computed.borderRadius,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(thumbnailStyles.width).toBe('168px');
|
||||||
|
expect(thumbnailStyles.height).toBe('94px');
|
||||||
|
expect(thumbnailStyles.borderRadius).toBe('8px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar preview - horizontal layout', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'sidebar', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
// Sidebar should have flex layout (horizontal)
|
||||||
|
const cardStyles = await ytCard.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
display: computed.display,
|
||||||
|
flexDirection: computed.flexDirection,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cardStyles.display).toBe('flex');
|
||||||
|
// Default flex direction is row (horizontal)
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar preview - no avatar', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'sidebar', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
// Sidebar variant should not have the avatar element (36x36 circle)
|
||||||
|
// Check that there's no element with 36px circle (avatar styling)
|
||||||
|
const avatarElements = await ytCard.locator('[style*="36px"][style*="50%"]').count();
|
||||||
|
|
||||||
|
// Sidebar should have 0 avatars
|
||||||
|
expect(avatarElements).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar preview - title typography 14px/500', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'sidebar', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
const title = ytCard.locator('h3').first();
|
||||||
|
|
||||||
|
const titleStyles = await title.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
fontSize: computed.fontSize,
|
||||||
|
fontWeight: computed.fontWeight,
|
||||||
|
lineHeight: computed.lineHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(titleStyles.fontSize).toBe('14px');
|
||||||
|
expect(titleStyles.fontWeight).toBe('500');
|
||||||
|
expect(titleStyles.lineHeight).toBe('20px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar preview - 8px gap between thumbnail and text', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'sidebar', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
const gapStyles = await ytCard.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
gap: computed.gap,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(gapStyles.gap).toBe('8px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar preview - metadata below title', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'sidebar', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
// Get metadata container (right side of thumbnail)
|
||||||
|
const metadataContainer = ytCard.locator('.flex-1.min-w-0').first();
|
||||||
|
|
||||||
|
// Should have title (h3), channel name (p), and views/time (p)
|
||||||
|
const title = metadataContainer.locator('h3');
|
||||||
|
const paragraphs = metadataContainer.locator('p');
|
||||||
|
|
||||||
|
await expect(title).toBeVisible();
|
||||||
|
expect(await paragraphs.count()).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
149
frontend/tests/visual/utils.ts
Normal file
149
frontend/tests/visual/utils.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { Page, expect } from '@playwright/test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual test utilities for YouTube Design Preview
|
||||||
|
* Provides screenshot comparison and diff generation helpers
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ScreenshotOptions {
|
||||||
|
mode: 'desktop' | 'mobile' | 'sidebar';
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference screenshots directory
|
||||||
|
*/
|
||||||
|
export const REFERENCE_DIR = path.join(__dirname, 'reference');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get reference screenshot path
|
||||||
|
*/
|
||||||
|
export function getReferenceScreenshotPath(options: ScreenshotOptions): string {
|
||||||
|
return path.join(
|
||||||
|
REFERENCE_DIR,
|
||||||
|
`${options.mode}-${options.theme}-${options.name}.png`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if reference screenshot exists
|
||||||
|
*/
|
||||||
|
export function hasReferenceScreenshot(options: ScreenshotOptions): boolean {
|
||||||
|
const refPath = getReferenceScreenshotPath(options);
|
||||||
|
return fs.existsSync(refPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up preview state for testing
|
||||||
|
*/
|
||||||
|
export async function setupPreviewState(
|
||||||
|
page: Page,
|
||||||
|
options: { mode: 'desktop' | 'mobile' | 'sidebar'; theme: 'light' | 'dark' }
|
||||||
|
): Promise<void> {
|
||||||
|
// Navigate to tool page
|
||||||
|
await page.goto('/tool');
|
||||||
|
|
||||||
|
// Wait for the page to load
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Set theme
|
||||||
|
if (options.theme === 'dark') {
|
||||||
|
// Click theme toggle to switch to dark mode
|
||||||
|
const themeButton = page.locator('button[aria-label*="dark"], button[aria-label*="light"]');
|
||||||
|
const isDark = await page.evaluate(() =>
|
||||||
|
document.documentElement.classList.contains('dark')
|
||||||
|
);
|
||||||
|
if (!isDark) {
|
||||||
|
await themeButton.click();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ensure light mode
|
||||||
|
const isDark = await page.evaluate(() =>
|
||||||
|
document.documentElement.classList.contains('dark')
|
||||||
|
);
|
||||||
|
if (isDark) {
|
||||||
|
const themeButton = page.locator('button[aria-label*="dark"], button[aria-label*="light"]');
|
||||||
|
await themeButton.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set preview mode
|
||||||
|
const modeSelector = page.locator(`[aria-label="${options.mode} preview"]`);
|
||||||
|
if (await modeSelector.isVisible()) {
|
||||||
|
await modeSelector.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a test thumbnail
|
||||||
|
*/
|
||||||
|
export async function uploadTestThumbnail(page: Page, imagePath: string): Promise<void> {
|
||||||
|
const fileInput = page.locator('input[type="file"]');
|
||||||
|
await fileInput.setInputFiles(imagePath);
|
||||||
|
|
||||||
|
// Wait for upload to complete
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a screenshot of the preview area and compare with reference
|
||||||
|
* Uses Playwright's built-in visual comparison with 98% threshold
|
||||||
|
*/
|
||||||
|
export async function comparePreviewScreenshot(
|
||||||
|
page: Page,
|
||||||
|
options: ScreenshotOptions
|
||||||
|
): Promise<void> {
|
||||||
|
// Locate the preview area
|
||||||
|
const previewArea = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
await expect(previewArea).toHaveScreenshot(
|
||||||
|
`${options.mode}-${options.theme}-${options.name}.png`,
|
||||||
|
{
|
||||||
|
threshold: 0.02, // 98% match
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a full page screenshot for visual regression
|
||||||
|
*/
|
||||||
|
export async function takeFullPageScreenshot(
|
||||||
|
page: Page,
|
||||||
|
name: string
|
||||||
|
): Promise<Buffer> {
|
||||||
|
return await page.screenshot({
|
||||||
|
fullPage: true,
|
||||||
|
path: path.join(__dirname, 'snapshots', `${name}.png`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual match result interface
|
||||||
|
*/
|
||||||
|
export interface VisualMatchResult {
|
||||||
|
passed: boolean;
|
||||||
|
matchPercentage: number;
|
||||||
|
diffImagePath?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate visual match percentage (for custom comparison)
|
||||||
|
* Note: Playwright's built-in comparison is preferred
|
||||||
|
*/
|
||||||
|
export function calculateMatchPercentage(
|
||||||
|
actualPixels: number,
|
||||||
|
differentPixels: number
|
||||||
|
): number {
|
||||||
|
if (actualPixels === 0) return 0;
|
||||||
|
return ((actualPixels - differentPixels) / actualPixels) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Threshold constant (98% match as per specification)
|
||||||
|
*/
|
||||||
|
export const VISUAL_MATCH_THRESHOLD = 98;
|
||||||
190
scripts/capture-youtube-reference.ts
Normal file
190
scripts/capture-youtube-reference.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* YouTube Reference Screenshot Capture Script
|
||||||
|
*
|
||||||
|
* Captures reference screenshots from live YouTube for visual comparison testing.
|
||||||
|
* Run weekly to keep baselines current with YouTube's design changes.
|
||||||
|
*
|
||||||
|
* Usage: npx ts-node scripts/capture-youtube-reference.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { chromium, Browser, Page } from '@playwright/test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const OUTPUT_DIR = path.join(__dirname, '../specs/002-youtube-design-preview/reference');
|
||||||
|
const VIEWPORT = { width: 1920, height: 1080 };
|
||||||
|
|
||||||
|
interface CaptureConfig {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
selector: string;
|
||||||
|
waitFor?: string;
|
||||||
|
theme?: 'light' | 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
const CAPTURES: CaptureConfig[] = [
|
||||||
|
{
|
||||||
|
name: 'youtube-desktop-homepage',
|
||||||
|
url: 'https://www.youtube.com',
|
||||||
|
selector: 'ytd-rich-item-renderer',
|
||||||
|
waitFor: 'ytd-rich-item-renderer',
|
||||||
|
theme: 'light',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'youtube-search-results',
|
||||||
|
url: 'https://www.youtube.com/results?search_query=tutorial',
|
||||||
|
selector: 'ytd-video-renderer',
|
||||||
|
waitFor: 'ytd-video-renderer',
|
||||||
|
theme: 'light',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'youtube-watch-sidebar',
|
||||||
|
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
selector: 'ytd-compact-video-renderer, yt-lockup-view-model',
|
||||||
|
waitFor: 'ytd-watch-flexy',
|
||||||
|
theme: 'light',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'youtube-trending',
|
||||||
|
url: 'https://www.youtube.com/feed/trending',
|
||||||
|
selector: 'ytd-video-renderer',
|
||||||
|
waitFor: 'ytd-video-renderer',
|
||||||
|
theme: 'light',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function ensureOutputDir(): Promise<void> {
|
||||||
|
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||||
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setTheme(page: Page, theme: 'light' | 'dark'): Promise<void> {
|
||||||
|
// YouTube uses document.documentElement attributes for theming
|
||||||
|
if (theme === 'dark') {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.setAttribute('dark', 'true');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Light is default
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureScreenshot(
|
||||||
|
browser: Browser,
|
||||||
|
config: CaptureConfig
|
||||||
|
): Promise<string> {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: VIEWPORT,
|
||||||
|
userAgent:
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Capturing: ${config.name}`);
|
||||||
|
console.log(` URL: ${config.url}`);
|
||||||
|
|
||||||
|
await page.goto(config.url, { waitUntil: 'networkidle' });
|
||||||
|
|
||||||
|
if (config.waitFor) {
|
||||||
|
await page.waitForSelector(config.waitFor, { timeout: 30000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.theme) {
|
||||||
|
await setTheme(page, config.theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for images to load
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Generate timestamp for versioning
|
||||||
|
const timestamp = new Date().toISOString().split('T')[0];
|
||||||
|
const filename = `${config.name}-${timestamp}.png`;
|
||||||
|
const filepath = path.join(OUTPUT_DIR, filename);
|
||||||
|
|
||||||
|
// Full page screenshot
|
||||||
|
await page.screenshot({
|
||||||
|
path: filepath,
|
||||||
|
fullPage: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` Saved: ${filename}`);
|
||||||
|
|
||||||
|
// Also capture just the video card element if possible
|
||||||
|
try {
|
||||||
|
const element = await page.locator(config.selector).first();
|
||||||
|
if (await element.isVisible()) {
|
||||||
|
const elementFilename = `${config.name}-element-${timestamp}.png`;
|
||||||
|
await element.screenshot({
|
||||||
|
path: path.join(OUTPUT_DIR, elementFilename),
|
||||||
|
});
|
||||||
|
console.log(` Saved element: ${elementFilename}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` Could not capture element screenshot`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath;
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log('YouTube Reference Screenshot Capture');
|
||||||
|
console.log('====================================');
|
||||||
|
console.log(`Output directory: ${OUTPUT_DIR}`);
|
||||||
|
console.log(`Viewport: ${VIEWPORT.width}x${VIEWPORT.height}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
await ensureOutputDir();
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const config of CAPTURES) {
|
||||||
|
try {
|
||||||
|
await captureScreenshot(browser, config);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error capturing ${config.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also capture dark mode versions
|
||||||
|
console.log('\nCapturing dark mode versions...');
|
||||||
|
for (const config of CAPTURES.slice(0, 2)) {
|
||||||
|
try {
|
||||||
|
await captureScreenshot(browser, {
|
||||||
|
...config,
|
||||||
|
name: `${config.name}-dark`,
|
||||||
|
theme: 'dark',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error capturing dark mode ${config.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nCapture complete!');
|
||||||
|
console.log(`Screenshots saved to: ${OUTPUT_DIR}`);
|
||||||
|
|
||||||
|
// Generate manifest
|
||||||
|
const manifest = {
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
viewport: VIEWPORT,
|
||||||
|
screenshots: fs.readdirSync(OUTPUT_DIR).filter((f) => f.endsWith('.png')),
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(OUTPUT_DIR, 'manifest.json'),
|
||||||
|
JSON.stringify(manifest, null, 2)
|
||||||
|
);
|
||||||
|
console.log('Manifest saved: manifest.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
169
specs/002-youtube-design-preview/analysis.md
Normal file
169
specs/002-youtube-design-preview/analysis.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Cross-Artifact Consistency Analysis
|
||||||
|
|
||||||
|
**Feature**: `002-youtube-design-preview`
|
||||||
|
**Analyzed**: 2026-01-29
|
||||||
|
**Artifacts**: spec.md, plan.md, research.md, data-model.md, contracts/types.ts, tasks.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Total Requirements (FR) | 20 |
|
||||||
|
| Total User Stories | 4 |
|
||||||
|
| Total Tasks | 50 |
|
||||||
|
| Requirements Covered | 20/20 (100%) |
|
||||||
|
| Constitution Alignment | PASS |
|
||||||
|
| Critical Issues | 0 |
|
||||||
|
| Warnings | 3 |
|
||||||
|
| Suggestions | 4 |
|
||||||
|
|
||||||
|
**Overall Status**: Ready for implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
| ID | Category | Severity | Location | Summary | Recommendation |
|
||||||
|
|----|----------|----------|----------|---------|----------------|
|
||||||
|
| A-001 | Underspecification | Warning | spec.md:FR-017 | Weekly screenshot capture mechanism not detailed | Add cron/scheduler details to plan.md or tasks.md |
|
||||||
|
| A-002 | Inconsistency | Warning | tasks.md:T041 | Script path uses `.ts` but may need to be executable | Verify ts-node setup or use compiled JS for scheduler |
|
||||||
|
| A-003 | Coverage Gap | Warning | tasks.md | FR-009 (responsive breakpoints) only partially addressed by T043 | Consider adding explicit breakpoint tests |
|
||||||
|
| A-004 | Suggestion | Info | data-model.md | `publishedAt` stored as Date but templates show "X days ago" | Add utility function for relative time formatting |
|
||||||
|
| A-005 | Suggestion | Info | contracts/types.ts | `VALIDATION_CONSTANTS.DURATION_REGEX` allows single-digit minutes | Consider stricter format `^\d{1,3}:\d{2}$` for edge cases |
|
||||||
|
| A-006 | Suggestion | Info | research.md | Sidebar dimensions (168x94) have different ratio than 16:9 | Document this intentional deviation from standard 16:9 |
|
||||||
|
| A-007 | Suggestion | Info | tasks.md | T017 hover states may conflict with mobile (no hover on touch) | Add note to disable hover states in mobile variant |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirement Coverage Matrix
|
||||||
|
|
||||||
|
| Requirement | User Story | Tasks | Status |
|
||||||
|
|-------------|------------|-------|--------|
|
||||||
|
| FR-001 (Desktop pixel-perfect) | US1 | T012-T020 | Covered |
|
||||||
|
| FR-002 (Mobile pixel-perfect) | US2 | T021-T026 | Covered |
|
||||||
|
| FR-003 (Sidebar pixel-perfect) | US3 | T027-T033 | Covered |
|
||||||
|
| FR-004 (Real-time upload) | US1-3 | T010-T011 | Covered |
|
||||||
|
| FR-005 (Metadata elements) | US1-3 | T012-T016, T020-T032 | Covered |
|
||||||
|
| FR-006 (Typography) | US1-3 | T013-T014, T023, T029 | Covered |
|
||||||
|
| FR-007 (Spacing/padding) | US1-3 | T018, T024, T031 | Covered |
|
||||||
|
| FR-008 (Hover states) | US1 | T017 | Covered |
|
||||||
|
| FR-009 (Responsive breakpoints) | - | T043 | Partial |
|
||||||
|
| FR-010 (Playwright integration) | US4 | T034-T042 | Covered |
|
||||||
|
| FR-011 (Visual diff reports) | US4 | T035 | Covered |
|
||||||
|
| FR-012 (Customizable metadata) | US1 | T020 | Covered |
|
||||||
|
| FR-013 (Duration badge) | US1-3 | T016 | Covered |
|
||||||
|
| FR-014 (Channel avatar) | US1-2 | T015, T022, T032 | Covered |
|
||||||
|
| FR-015 (Local storage) | - | T004, T006-T007, T047 | Covered |
|
||||||
|
| FR-016 (Theme toggle) | - | T008, T019 | Covered |
|
||||||
|
| FR-017 (Weekly capture) | US4 | T041 | Covered |
|
||||||
|
| FR-018 (Historical screenshots) | US4 | T036 | Covered |
|
||||||
|
| FR-019 (5MB limit) | - | T010-T011 | Covered |
|
||||||
|
| FR-020 (Image formats) | - | T010-T011 | Covered |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constitution Alignment Check
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Tech Stack | PASS | React 19.x, TS 5.x, Tailwind 4.x, shadcn/ui, Zustand - all aligned |
|
||||||
|
| II. Architecture | PASS | Feature code in `frontend/src/`, correct directory structure |
|
||||||
|
| III. Styling | PASS | YouTube CSS variables defined, Tailwind utilities used |
|
||||||
|
| IV. Data Management | PASS | Zustand for state, localStorage for persistence |
|
||||||
|
| V. Development Practices | PASS | TypeScript strict, ESLint, explicit types in contracts |
|
||||||
|
|
||||||
|
**No constitution violations detected.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Duplication Analysis
|
||||||
|
|
||||||
|
| Area | Finding |
|
||||||
|
|------|---------|
|
||||||
|
| Type Definitions | `contracts/types.ts` and `data-model.md` contain same interfaces - **Acceptable** (one is documentation, one is code) |
|
||||||
|
| CSS Values | `research.md` and `quickstart.md` both list CSS values - **Minor overlap**, quickstart is intentional quick reference |
|
||||||
|
| Default Values | `data-model.md` and `contracts/types.ts` both define defaults - **Need to keep in sync** |
|
||||||
|
|
||||||
|
**Recommendation**: Consider generating `data-model.md` from `contracts/types.ts` to prevent drift.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ambiguity Detection
|
||||||
|
|
||||||
|
| Location | Ambiguity | Resolution |
|
||||||
|
|----------|-----------|------------|
|
||||||
|
| spec.md:FR-017 | "Automated weekly capture" - when exactly? | Suggest: Sunday 00:00 UTC, document in tasks.md |
|
||||||
|
| spec.md:SC-004 | "90% of users" - how to measure in blind test? | This is a qualitative metric, may be hard to automate |
|
||||||
|
| tasks.md:T036 | "Copy reference screenshots" - from where? | From `specs/002-youtube-design-preview/reference/` (clarified in research.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task Dependency Analysis
|
||||||
|
|
||||||
|
### Critical Path
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 (Setup) → Phase 2 (Foundational) → Phase 3-6 (User Stories) → Phase 7 (Polish)
|
||||||
|
T001-T005 [parallel] T006-T011 [sequential] US1-US4 [parallel] T043-T050 [parallel]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blocking Dependencies
|
||||||
|
|
||||||
|
| Task | Blocked By | Blocks |
|
||||||
|
|------|------------|--------|
|
||||||
|
| T006-T011 | T001-T005 | T012-T050 (all user stories) |
|
||||||
|
| T037-T040 | T034-T036 | None (test tasks) |
|
||||||
|
| T049 (lint) | All implementation | T050 (validation) |
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- **Phase 1**: T001, T002, T003, T004 can all run in parallel
|
||||||
|
- **Phase 2**: T008, T009 can run in parallel after T006-T007
|
||||||
|
- **Phase 3-5**: US1, US2, US3 can be implemented in parallel by different developers
|
||||||
|
- **Phase 6**: T034, T035, T036 can run in parallel (test setup)
|
||||||
|
- **Phase 7**: T043, T044, T045, T046, T047 can all run in parallel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|------|------------|--------|------------|
|
||||||
|
| YouTube design changes during development | Medium | High | Reference screenshots versioned, weekly updates |
|
||||||
|
| Visual test flakiness | Medium | Medium | 98% threshold allows minor variance |
|
||||||
|
| localStorage quota exceeded | Low | Medium | T047 adds 10-thumbnail limit |
|
||||||
|
| Playwright MCP unavailable | Low | High | Manual screenshot capture as fallback |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
|
||||||
|
1. **Clarify weekly capture schedule**: Add specific timing (e.g., "Sundays at 00:00 UTC") to avoid ambiguity
|
||||||
|
2. **Add responsive test coverage**: T043 covers responsive breakpoints but no visual tests for intermediate sizes
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
|
||||||
|
3. **Consider relative time utility**: Add `formatRelativeTime(date: Date): string` to lib functions
|
||||||
|
4. **Document sidebar ratio**: Note in research.md that sidebar uses slightly different aspect ratio
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
|
||||||
|
5. **Sync default values**: Ensure `data-model.md` defaults match `contracts/types.ts`
|
||||||
|
6. **Mobile hover handling**: Explicitly disable hover states for mobile variant to avoid touch UX issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Actions
|
||||||
|
|
||||||
|
1. Address Warning items (A-001, A-002, A-003) before implementation begins
|
||||||
|
2. Proceed with Phase 1 (Setup) - all tasks can run in parallel
|
||||||
|
3. After Phase 2 completion, checkpoint to verify foundation before user stories
|
||||||
|
4. Consider running US1-US3 in parallel if multiple developers available
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Analysis complete. Feature is ready for implementation with minor clarifications recommended.**
|
||||||
37
specs/002-youtube-design-preview/checklists/requirements.md
Normal file
37
specs/002-youtube-design-preview/checklists/requirements.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Specification Quality Checklist: YouTube Design Preview Replica
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-01-29
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All items passed validation
|
||||||
|
- Spec is ready for `/speckit.clarify` or `/speckit.plan`
|
||||||
|
- Playwright MCP referenced for visual testing methodology (not implementation detail)
|
||||||
|
- Reference baseline defined as YouTube's current design (January 2026)
|
||||||
278
specs/002-youtube-design-preview/contracts/types.ts
Normal file
278
specs/002-youtube-design-preview/contracts/types.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* YouTube Design Preview - TypeScript Contracts
|
||||||
|
*
|
||||||
|
* These interfaces define the data structures used in the preview tool.
|
||||||
|
* This file serves as the contract between components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Enums
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview display modes matching YouTube's layout variations
|
||||||
|
*/
|
||||||
|
export type PreviewMode = 'desktop' | 'sidebar' | 'mobile';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme modes matching YouTube's color schemes
|
||||||
|
*/
|
||||||
|
export type ThemeMode = 'light' | 'dark';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported image formats for thumbnail upload
|
||||||
|
*/
|
||||||
|
export type SupportedImageFormat = 'image/jpeg' | 'image/png' | 'image/webp';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Core Entities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video metadata displayed alongside the thumbnail
|
||||||
|
*/
|
||||||
|
export interface VideoMetadata {
|
||||||
|
/** Video title (1-100 characters) */
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/** Channel name (1-50 characters) */
|
||||||
|
channelName: string;
|
||||||
|
|
||||||
|
/** Optional channel avatar URL or base64 data URL */
|
||||||
|
channelAvatarUrl?: string;
|
||||||
|
|
||||||
|
/** Duration in format "MM:SS" or "H:MM:SS" */
|
||||||
|
duration: string;
|
||||||
|
|
||||||
|
/** View count as raw number (formatted for display) */
|
||||||
|
viewCount: number;
|
||||||
|
|
||||||
|
/** Publication date (used for relative time display) */
|
||||||
|
publishedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploaded thumbnail image with metadata
|
||||||
|
*/
|
||||||
|
export interface UploadedThumbnail {
|
||||||
|
/** Unique identifier (UUID) */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** Base64 data URL of the image */
|
||||||
|
dataUrl: string;
|
||||||
|
|
||||||
|
/** Original filename */
|
||||||
|
originalName: string;
|
||||||
|
|
||||||
|
/** File size in bytes (max 5MB = 5,242,880) */
|
||||||
|
fileSize: number;
|
||||||
|
|
||||||
|
/** MIME type */
|
||||||
|
mimeType: SupportedImageFormat;
|
||||||
|
|
||||||
|
/** Original image dimensions */
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
|
||||||
|
/** Upload timestamp */
|
||||||
|
uploadedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// State Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete preview state persisted to localStorage
|
||||||
|
*/
|
||||||
|
export interface PreviewState {
|
||||||
|
/** Currently active thumbnail for preview */
|
||||||
|
currentThumbnail: UploadedThumbnail | null;
|
||||||
|
|
||||||
|
/** Active preview layout mode */
|
||||||
|
previewMode: PreviewMode;
|
||||||
|
|
||||||
|
/** Active color theme */
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
|
||||||
|
/** Video metadata for display */
|
||||||
|
metadata: VideoMetadata;
|
||||||
|
|
||||||
|
/** Recently used thumbnails (max 10) */
|
||||||
|
recentThumbnails: UploadedThumbnail[];
|
||||||
|
|
||||||
|
/** UI state: metadata editor visibility */
|
||||||
|
showMetadataEditor: boolean;
|
||||||
|
|
||||||
|
/** Last persistence timestamp */
|
||||||
|
lastSaved: Date;
|
||||||
|
|
||||||
|
/** Schema version for migrations */
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* localStorage serialization wrapper
|
||||||
|
*/
|
||||||
|
export interface LocalStorageSchema {
|
||||||
|
version: 1;
|
||||||
|
state: PreviewState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Visual Testing
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a visual comparison test
|
||||||
|
*/
|
||||||
|
export interface VisualTestResult {
|
||||||
|
/** Unique test run identifier */
|
||||||
|
testId: string;
|
||||||
|
|
||||||
|
/** Preview mode being tested */
|
||||||
|
previewMode: PreviewMode;
|
||||||
|
|
||||||
|
/** Theme mode being tested */
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
|
||||||
|
/** Test execution timestamp */
|
||||||
|
capturedAt: Date;
|
||||||
|
|
||||||
|
/** Similarity percentage (0-100) */
|
||||||
|
matchPercentage: number;
|
||||||
|
|
||||||
|
/** Pass/fail based on 98% threshold */
|
||||||
|
passed: boolean;
|
||||||
|
|
||||||
|
/** Path to captured screenshot */
|
||||||
|
actualScreenshotPath: string;
|
||||||
|
|
||||||
|
/** Path to reference screenshot */
|
||||||
|
expectedScreenshotPath: string;
|
||||||
|
|
||||||
|
/** Path to diff image (only if failed) */
|
||||||
|
diffImagePath?: string;
|
||||||
|
|
||||||
|
/** Error message if test failed */
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube reference screenshot metadata
|
||||||
|
*/
|
||||||
|
export interface ReferenceScreenshot {
|
||||||
|
/** Unique identifier */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** Preview mode this reference is for */
|
||||||
|
previewMode: PreviewMode;
|
||||||
|
|
||||||
|
/** Theme mode this reference is for */
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
|
||||||
|
/** When captured from YouTube */
|
||||||
|
capturedAt: Date;
|
||||||
|
|
||||||
|
/** YouTube URL captured from */
|
||||||
|
capturedFrom: string;
|
||||||
|
|
||||||
|
/** File system path */
|
||||||
|
filePath: string;
|
||||||
|
|
||||||
|
/** Viewport dimensions */
|
||||||
|
viewportWidth: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
|
||||||
|
/** Browser used for capture */
|
||||||
|
browserType: 'chromium' | 'firefox' | 'webkit';
|
||||||
|
|
||||||
|
/** Whether this is the current baseline */
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
/** ID of newer version if replaced */
|
||||||
|
replacedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component Props
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for YouTubeVideoCard component
|
||||||
|
*/
|
||||||
|
export interface YouTubeVideoCardProps {
|
||||||
|
/** Thumbnail image URL or data URL */
|
||||||
|
thumbnailUrl: string;
|
||||||
|
|
||||||
|
/** Video title */
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/** Channel name */
|
||||||
|
channelTitle: string;
|
||||||
|
|
||||||
|
/** Formatted view count (e.g., "1.2M views") */
|
||||||
|
viewCount?: string;
|
||||||
|
|
||||||
|
/** Relative time string (e.g., "3 days ago") */
|
||||||
|
publishedAt?: string;
|
||||||
|
|
||||||
|
/** Whether this is user's uploaded thumbnail (for highlighting) */
|
||||||
|
isUserThumbnail?: boolean;
|
||||||
|
|
||||||
|
/** Layout variant */
|
||||||
|
variant?: PreviewMode;
|
||||||
|
|
||||||
|
/** Video duration string */
|
||||||
|
duration?: string;
|
||||||
|
|
||||||
|
/** Optional channel avatar URL */
|
||||||
|
channelAvatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for ThemeToggle component
|
||||||
|
*/
|
||||||
|
export interface ThemeToggleProps {
|
||||||
|
/** Current theme mode */
|
||||||
|
theme: ThemeMode;
|
||||||
|
|
||||||
|
/** Callback when theme changes */
|
||||||
|
onThemeChange: (theme: ThemeMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for PreviewModeSelector component
|
||||||
|
*/
|
||||||
|
export interface PreviewModeSelectorProps {
|
||||||
|
/** Current preview mode */
|
||||||
|
mode: PreviewMode;
|
||||||
|
|
||||||
|
/** Callback when mode changes */
|
||||||
|
onModeChange: (mode: PreviewMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thumbnail upload validation result
|
||||||
|
*/
|
||||||
|
export interface ThumbnailValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation constants
|
||||||
|
*/
|
||||||
|
export const VALIDATION_CONSTANTS = {
|
||||||
|
MAX_FILE_SIZE: 5 * 1024 * 1024, // 5MB
|
||||||
|
MAX_TITLE_LENGTH: 100,
|
||||||
|
MAX_CHANNEL_NAME_LENGTH: 50,
|
||||||
|
MAX_RECENT_THUMBNAILS: 10,
|
||||||
|
VISUAL_MATCH_THRESHOLD: 98, // percentage
|
||||||
|
SUPPORTED_FORMATS: ['image/jpeg', 'image/png', 'image/webp'] as const,
|
||||||
|
DURATION_REGEX: /^\d{1,2}:\d{2}(:\d{2})?$/,
|
||||||
|
} as const;
|
||||||
257
specs/002-youtube-design-preview/data-model.md
Normal file
257
specs/002-youtube-design-preview/data-model.md
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
# Data Model: YouTube Design Preview Replica
|
||||||
|
|
||||||
|
**Date**: 2026-01-29
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### PreviewMode (Enum)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type PreviewMode = 'desktop' | 'sidebar' | 'mobile';
|
||||||
|
```
|
||||||
|
|
||||||
|
| Value | Description | Thumbnail Size |
|
||||||
|
|-------|-------------|----------------|
|
||||||
|
| `desktop` | YouTube homepage/search grid layout | 360x202px |
|
||||||
|
| `sidebar` | YouTube related videos sidebar | 168x94px |
|
||||||
|
| `mobile` | YouTube mobile app full-width layout | 100% width |
|
||||||
|
|
||||||
|
### ThemeMode (Enum)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ThemeMode = 'light' | 'dark';
|
||||||
|
```
|
||||||
|
|
||||||
|
### VideoMetadata
|
||||||
|
|
||||||
|
User-customizable metadata for preview display.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface VideoMetadata {
|
||||||
|
title: string; // Video title (max 100 chars for display)
|
||||||
|
channelName: string; // Channel name
|
||||||
|
channelAvatarUrl?: string; // Optional channel avatar image URL
|
||||||
|
duration: string; // Format: "MM:SS" or "H:MM:SS"
|
||||||
|
viewCount: number; // Raw number, formatted for display
|
||||||
|
publishedAt: Date; // Used for "X days ago" calculation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation Rules:**
|
||||||
|
- `title`: Required, 1-100 characters
|
||||||
|
- `channelName`: Required, 1-50 characters
|
||||||
|
- `duration`: Required, format `/^\d{1,2}:\d{2}(:\d{2})?$/`
|
||||||
|
- `viewCount`: Required, non-negative integer
|
||||||
|
- `publishedAt`: Required, valid Date, not in future
|
||||||
|
|
||||||
|
### UploadedThumbnail
|
||||||
|
|
||||||
|
Represents a user-uploaded thumbnail image.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UploadedThumbnail {
|
||||||
|
id: string; // UUID
|
||||||
|
dataUrl: string; // Base64 data URL of the image
|
||||||
|
originalName: string; // Original filename
|
||||||
|
fileSize: number; // Size in bytes (max 5MB)
|
||||||
|
mimeType: string; // 'image/jpeg' | 'image/png' | 'image/webp'
|
||||||
|
width: number; // Original image width
|
||||||
|
height: number; // Original image height
|
||||||
|
uploadedAt: Date; // Timestamp
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation Rules:**
|
||||||
|
- `fileSize`: Max 5,242,880 bytes (5MB)
|
||||||
|
- `mimeType`: Only 'image/jpeg', 'image/png', 'image/webp'
|
||||||
|
- `width`, `height`: Positive integers
|
||||||
|
|
||||||
|
### PreviewState
|
||||||
|
|
||||||
|
Complete state for the preview tool, persisted to localStorage.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PreviewState {
|
||||||
|
// Active preview configuration
|
||||||
|
currentThumbnail: UploadedThumbnail | null;
|
||||||
|
previewMode: PreviewMode;
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
metadata: VideoMetadata;
|
||||||
|
|
||||||
|
// History for quick switching
|
||||||
|
recentThumbnails: UploadedThumbnail[]; // Max 10 items
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
showMetadataEditor: boolean;
|
||||||
|
|
||||||
|
// Persistence metadata
|
||||||
|
lastSaved: Date;
|
||||||
|
version: number; // Schema version for migrations
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### VisualTestResult
|
||||||
|
|
||||||
|
Result of Playwright visual comparison test.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface VisualTestResult {
|
||||||
|
testId: string;
|
||||||
|
previewMode: PreviewMode;
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
capturedAt: Date;
|
||||||
|
|
||||||
|
// Comparison metrics
|
||||||
|
matchPercentage: number; // 0-100
|
||||||
|
passed: boolean; // matchPercentage >= threshold (98%)
|
||||||
|
|
||||||
|
// File references
|
||||||
|
actualScreenshotPath: string;
|
||||||
|
expectedScreenshotPath: string;
|
||||||
|
diffImagePath?: string; // Only if failed
|
||||||
|
|
||||||
|
// Error info
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ReferenceScreenshot
|
||||||
|
|
||||||
|
Stored YouTube reference for visual comparison.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ReferenceScreenshot {
|
||||||
|
id: string;
|
||||||
|
previewMode: PreviewMode;
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
capturedAt: Date;
|
||||||
|
capturedFrom: string; // YouTube URL
|
||||||
|
filePath: string;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
viewportWidth: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
browserType: string; // 'chromium' | 'firefox' | 'webkit'
|
||||||
|
|
||||||
|
// Version tracking
|
||||||
|
isActive: boolean; // Current baseline
|
||||||
|
replacedBy?: string; // ID of newer version
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Storage Schema
|
||||||
|
|
||||||
|
Key: `thumbpreview_state`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface LocalStorageSchema {
|
||||||
|
version: 1;
|
||||||
|
state: PreviewState;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Storage Limits:**
|
||||||
|
- Total localStorage budget: ~5MB
|
||||||
|
- Each thumbnail (base64): ~1-2MB typical
|
||||||
|
- Keep max 10 recent thumbnails
|
||||||
|
- Automatic cleanup of oldest when limit reached
|
||||||
|
|
||||||
|
## State Transitions
|
||||||
|
|
||||||
|
### Thumbnail Upload Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
[No Thumbnail]
|
||||||
|
|
|
||||||
|
v (user uploads file)
|
||||||
|
[Validating]
|
||||||
|
|
|
||||||
|
+-- (invalid) --> [Error: show message] --> [No Thumbnail]
|
||||||
|
|
|
||||||
|
v (valid)
|
||||||
|
[Processing]
|
||||||
|
|
|
||||||
|
v (resize/optimize if needed)
|
||||||
|
[Preview Ready]
|
||||||
|
|
|
||||||
|
v (auto-save to localStorage)
|
||||||
|
[Persisted]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preview Mode Switch
|
||||||
|
|
||||||
|
```
|
||||||
|
[Current Mode]
|
||||||
|
|
|
||||||
|
v (user selects new mode)
|
||||||
|
[Transitioning]
|
||||||
|
|
|
||||||
|
v (re-render with new layout)
|
||||||
|
[New Mode Active]
|
||||||
|
|
|
||||||
|
v (persist preference)
|
||||||
|
[Saved]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Theme Mode Switch
|
||||||
|
|
||||||
|
```
|
||||||
|
[Current Theme]
|
||||||
|
|
|
||||||
|
v (user clicks toggle)
|
||||||
|
[Apply Theme Class]
|
||||||
|
|
|
||||||
|
v (CSS variables update)
|
||||||
|
[New Theme Active]
|
||||||
|
|
|
||||||
|
v (persist preference)
|
||||||
|
[Saved]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
```
|
||||||
|
PreviewState
|
||||||
|
|
|
||||||
|
+-- 1:1 --> currentThumbnail (UploadedThumbnail)
|
||||||
|
|
|
||||||
|
+-- 1:N --> recentThumbnails (UploadedThumbnail[])
|
||||||
|
|
|
||||||
|
+-- 1:1 --> metadata (VideoMetadata)
|
||||||
|
|
|
||||||
|
+-- enum --> previewMode (PreviewMode)
|
||||||
|
|
|
||||||
|
+-- enum --> themeMode (ThemeMode)
|
||||||
|
|
||||||
|
VisualTestResult
|
||||||
|
|
|
||||||
|
+-- ref --> ReferenceScreenshot (expectedScreenshotPath)
|
||||||
|
|
|
||||||
|
+-- enum --> previewMode
|
||||||
|
|
|
||||||
|
+-- enum --> themeMode
|
||||||
|
```
|
||||||
|
|
||||||
|
## Default Values
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const DEFAULT_METADATA: VideoMetadata = {
|
||||||
|
title: 'Your Video Title Here',
|
||||||
|
channelName: 'Your Channel',
|
||||||
|
channelAvatarUrl: undefined,
|
||||||
|
duration: '10:30',
|
||||||
|
viewCount: 125000,
|
||||||
|
publishedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) // 1 week ago
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_STATE: PreviewState = {
|
||||||
|
currentThumbnail: null,
|
||||||
|
previewMode: 'desktop',
|
||||||
|
themeMode: 'light',
|
||||||
|
metadata: DEFAULT_METADATA,
|
||||||
|
recentThumbnails: [],
|
||||||
|
showMetadataEditor: false,
|
||||||
|
lastSaved: new Date(),
|
||||||
|
version: 1
|
||||||
|
};
|
||||||
|
```
|
||||||
92
specs/002-youtube-design-preview/plan.md
Normal file
92
specs/002-youtube-design-preview/plan.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Implementation Plan: YouTube Design Preview Replica
|
||||||
|
|
||||||
|
**Branch**: `002-youtube-design-preview` | **Date**: 2026-01-29 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/002-youtube-design-preview/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Enhance the existing thumbnail preview tool to achieve pixel-perfect visual accuracy with YouTube's current design across three view modes (Desktop, Mobile, Sidebar). The implementation focuses on extracting exact CSS values from YouTube, implementing local storage persistence, adding a manual theme toggle, and integrating Playwright MCP for automated visual regression testing with weekly reference screenshot updates.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.9.x (frontend + backend)
|
||||||
|
**Primary Dependencies**: React 19.x, Tailwind CSS 4.x, shadcn/ui, Zustand, Playwright MCP
|
||||||
|
**Storage**: Browser localStorage (preview persistence), file system (reference screenshots)
|
||||||
|
**Testing**: Playwright visual comparison, Jest (unit tests)
|
||||||
|
**Target Platform**: Web browsers (Chrome, Firefox, Safari), responsive mobile views
|
||||||
|
**Project Type**: Monorepo (frontend + backend)
|
||||||
|
**Performance Goals**: Preview render < 2 seconds, 98% visual match with YouTube
|
||||||
|
**Constraints**: 5MB max thumbnail upload, JPG/PNG/WebP formats only
|
||||||
|
**Scale/Scope**: Single-user local tool, no concurrent users
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Tech Stack | PASS | React 19.x, TypeScript 5.x, Tailwind CSS 4.x, shadcn/ui, Zustand - all aligned |
|
||||||
|
| II. Architecture | PASS | Feature code goes in existing frontend structure |
|
||||||
|
| III. Styling | PASS | YouTube CSS variables already defined in index.css; extend with Tailwind utilities |
|
||||||
|
| IV. Data Management | PASS | Zustand for preview state, localStorage for persistence (no backend changes needed) |
|
||||||
|
| V. Development Practices | PASS | TypeScript strict mode, ESLint, existing patterns followed |
|
||||||
|
|
||||||
|
**Gate Result**: PASS - All constitution principles satisfied.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/002-youtube-design-preview/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── research.md # Phase 0: YouTube design extraction research
|
||||||
|
├── data-model.md # Phase 1: Data entities and storage schema
|
||||||
|
├── quickstart.md # Phase 1: Development setup guide
|
||||||
|
├── contracts/ # Phase 1: API contracts (minimal - mostly frontend)
|
||||||
|
└── tasks.md # Phase 2: Implementation tasks (created by /speckit.tasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── YouTubeVideoCard.tsx # MODIFY: Enhance for pixel-perfect accuracy
|
||||||
|
│ │ ├── PreviewModeSelector.tsx # NEW: Desktop/Mobile/Sidebar switcher
|
||||||
|
│ │ ├── ThemeToggle.tsx # NEW: Light/dark mode toggle
|
||||||
|
│ │ ├── ThumbnailUploader.tsx # MODIFY: Add file validation (5MB, formats)
|
||||||
|
│ │ └── ui/ # shadcn/ui components (no changes)
|
||||||
|
│ ├── hooks/
|
||||||
|
│ │ └── useLocalStorage.ts # NEW: Persist preview state
|
||||||
|
│ ├── store/
|
||||||
|
│ │ └── previewStore.ts # MODIFY: Add persistence, theme state
|
||||||
|
│ ├── types/
|
||||||
|
│ │ └── index.ts # MODIFY: Add new type definitions
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ └── youtube-styles.ts # NEW: Extracted YouTube CSS constants
|
||||||
|
│ └── index.css # MODIFY: Add precise YouTube CSS variables
|
||||||
|
├── tests/
|
||||||
|
│ └── visual/
|
||||||
|
│ ├── desktop.spec.ts # NEW: Playwright visual tests
|
||||||
|
│ ├── mobile.spec.ts # NEW: Playwright visual tests
|
||||||
|
│ ├── sidebar.spec.ts # NEW: Playwright visual tests
|
||||||
|
│ └── reference/ # NEW: YouTube reference screenshots
|
||||||
|
└── playwright.config.ts # NEW: Playwright configuration
|
||||||
|
|
||||||
|
scripts/
|
||||||
|
└── capture-youtube-reference.ts # NEW: Weekly reference screenshot capture
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Extending existing frontend structure. No backend changes required as persistence uses browser localStorage per clarification.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> No constitution violations to justify.
|
||||||
|
|
||||||
|
| Aspect | Decision | Rationale |
|
||||||
|
|--------|----------|-----------|
|
||||||
|
| Storage | localStorage only | Per clarification - no server persistence needed |
|
||||||
|
| Theme | Manual toggle | Per clarification - no system preference detection |
|
||||||
|
| Reference updates | Automated weekly | Per clarification - Playwright captures from live YouTube |
|
||||||
145
specs/002-youtube-design-preview/quickstart.md
Normal file
145
specs/002-youtube-design-preview/quickstart.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Quickstart: YouTube Design Preview Replica
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- pnpm or npm
|
||||||
|
- Playwright (for visual testing)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **Install dependencies** (if not already done):
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install Playwright browsers** (for visual testing):
|
||||||
|
```bash
|
||||||
|
npx playwright install chromium firefox webkit
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start development server**:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Access the app**:
|
||||||
|
Open http://localhost:5173 in your browser
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Working on Preview Components
|
||||||
|
|
||||||
|
The main component to modify is:
|
||||||
|
- `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
|
||||||
|
YouTube CSS variables are defined in:
|
||||||
|
- `frontend/src/index.css` (search for "YOUTUBE THEME VARIABLES")
|
||||||
|
|
||||||
|
### Adding New Preview Modes
|
||||||
|
|
||||||
|
1. Update `ViewMode` type in `frontend/src/types/index.ts`
|
||||||
|
2. Add new variant case in `YouTubeVideoCard.tsx`
|
||||||
|
3. Update `ViewSwitcher.tsx` to include new option
|
||||||
|
4. Add visual test in `frontend/tests/visual/`
|
||||||
|
|
||||||
|
### Testing Visual Accuracy
|
||||||
|
|
||||||
|
**Manual Testing:**
|
||||||
|
1. Open YouTube in a browser tab
|
||||||
|
2. Open the preview tool in another tab
|
||||||
|
3. Compare side-by-side at same zoom level
|
||||||
|
|
||||||
|
**Automated Testing (Playwright):**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npx playwright test tests/visual/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating Reference Screenshots
|
||||||
|
|
||||||
|
Reference screenshots are stored in:
|
||||||
|
`specs/002-youtube-design-preview/reference/`
|
||||||
|
|
||||||
|
To capture new references:
|
||||||
|
```bash
|
||||||
|
# Using Playwright MCP or manual script
|
||||||
|
npx ts-node scripts/capture-youtube-reference.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `src/components/YouTubeVideoCard.tsx` | Main preview component |
|
||||||
|
| `src/components/PreviewModeSelector.tsx` | Desktop/Mobile/Sidebar switcher |
|
||||||
|
| `src/components/ThemeToggle.tsx` | Light/Dark mode toggle |
|
||||||
|
| `src/store/previewStore.ts` | Zustand state management |
|
||||||
|
| `src/hooks/useLocalStorage.ts` | Persistence hook |
|
||||||
|
| `src/lib/youtube-styles.ts` | YouTube CSS constants |
|
||||||
|
| `src/index.css` | YouTube CSS variables |
|
||||||
|
|
||||||
|
## CSS Variables Reference
|
||||||
|
|
||||||
|
### Light Mode Colors
|
||||||
|
```css
|
||||||
|
--yt-bg: #ffffff;
|
||||||
|
--yt-title: #0f0f0f;
|
||||||
|
--yt-meta: #606060;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dark Mode Colors
|
||||||
|
```css
|
||||||
|
--yt-bg: #0f0f0f;
|
||||||
|
--yt-title: #f1f1f1;
|
||||||
|
--yt-meta: #aaaaaa;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dimensions
|
||||||
|
```css
|
||||||
|
/* Desktop thumbnail */
|
||||||
|
width: 360px;
|
||||||
|
height: 202px;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
/* Sidebar thumbnail */
|
||||||
|
width: 168px;
|
||||||
|
height: 94px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
/* Avatar */
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
/* Duration badge */
|
||||||
|
padding: 3px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Add support for new YouTube element
|
||||||
|
|
||||||
|
1. Capture reference from YouTube using Playwright
|
||||||
|
2. Extract computed styles using browser DevTools
|
||||||
|
3. Add CSS variables to `index.css` if new colors needed
|
||||||
|
4. Update component JSX and Tailwind classes
|
||||||
|
5. Run visual tests to verify accuracy
|
||||||
|
|
||||||
|
### Debug visual differences
|
||||||
|
|
||||||
|
1. Run Playwright test to generate diff image
|
||||||
|
2. Check `specs/002-youtube-design-preview/reference/` for comparison
|
||||||
|
3. Use browser DevTools to compare computed styles
|
||||||
|
4. Adjust Tailwind classes or CSS variables
|
||||||
|
|
||||||
|
### Update for YouTube design changes
|
||||||
|
|
||||||
|
1. Capture new reference screenshots from YouTube
|
||||||
|
2. Run visual tests - they should fail showing differences
|
||||||
|
3. Update CSS values in component/styles
|
||||||
|
4. Re-run tests until passing
|
||||||
|
5. Commit new reference screenshots
|
||||||
168
specs/002-youtube-design-preview/research.md
Normal file
168
specs/002-youtube-design-preview/research.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# Research: YouTube Design Preview Replica
|
||||||
|
|
||||||
|
**Date**: 2026-01-29
|
||||||
|
**Method**: Playwright MCP automated extraction from live YouTube (1920x1080)
|
||||||
|
|
||||||
|
## YouTube CSS Design Tokens (January 2026)
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
| Element | Font Family | Size | Weight | Line Height | Color (Light) | Color (Dark) |
|
||||||
|
|---------|-------------|------|--------|-------------|---------------|--------------|
|
||||||
|
| Title (Desktop Grid) | Roboto, Arial, sans-serif | 16px | 500 | 22px | #0f0f0f | #f1f1f1 |
|
||||||
|
| Title (Search) | Roboto, Arial, sans-serif | 18px | 400 | 26px | #0f0f0f | #f1f1f1 |
|
||||||
|
| Title (Sidebar) | Roboto, Arial, sans-serif | 14px | 500 | 20px | #0f0f0f | #f1f1f1 |
|
||||||
|
| Channel Name | Roboto, Arial, sans-serif | 12px | 400 | 18px | #606060 | #aaaaaa |
|
||||||
|
| Metadata (views, time) | Roboto, Arial, sans-serif | 12px | 400 | 18px | #606060 | #aaaaaa |
|
||||||
|
| Duration Badge | Roboto, Arial, sans-serif | 12px | 500 | normal | #ffffff | #ffffff |
|
||||||
|
|
||||||
|
### Thumbnail Dimensions
|
||||||
|
|
||||||
|
| View Mode | Width | Height | Aspect Ratio | Border Radius |
|
||||||
|
|-----------|-------|--------|--------------|---------------|
|
||||||
|
| Desktop Grid (Home) | 360px | 202px | 16:9 (1.778) | 12px |
|
||||||
|
| Search Results | 360px | 202px | 16:9 (1.778) | 12px |
|
||||||
|
| Sidebar (Related) | 168px | 94px | 16:9 (1.787) | 8px |
|
||||||
|
| Mobile (Full Width) | 100% | auto | 16:9 | 0px (edge-to-edge) |
|
||||||
|
|
||||||
|
### Avatar Dimensions
|
||||||
|
|
||||||
|
| View Mode | Size | Shape |
|
||||||
|
|-----------|------|-------|
|
||||||
|
| Desktop Grid | 36x36px | Circle |
|
||||||
|
| Search Results | 36x36px | Circle |
|
||||||
|
| Sidebar | N/A (no avatar) | - |
|
||||||
|
| Mobile | 36x36px | Circle |
|
||||||
|
|
||||||
|
### Duration Badge Styling
|
||||||
|
|
||||||
|
```css
|
||||||
|
.duration-badge {
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: Roboto, Arial, sans-serif;
|
||||||
|
padding: 3px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
right: 4px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Color Palette
|
||||||
|
|
||||||
|
#### Light Mode
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--yt-bg: #ffffff;
|
||||||
|
--yt-surface: #ffffff;
|
||||||
|
--yt-title: #0f0f0f;
|
||||||
|
--yt-meta: #606060;
|
||||||
|
--yt-icon: #606060;
|
||||||
|
--yt-border: #e5e5e5;
|
||||||
|
--yt-hover: rgba(0, 0, 0, 0.05);
|
||||||
|
--yt-chip-bg: #f2f2f2;
|
||||||
|
--yt-chip-active-bg: #0f0f0f;
|
||||||
|
--yt-chip-active-text: #ffffff;
|
||||||
|
--yt-blue: #065fd4;
|
||||||
|
--yt-red: #ff0000;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Dark Mode
|
||||||
|
```css
|
||||||
|
.dark {
|
||||||
|
--yt-bg: #0f0f0f;
|
||||||
|
--yt-surface: #0f0f0f;
|
||||||
|
--yt-title: #f1f1f1;
|
||||||
|
--yt-meta: #aaaaaa;
|
||||||
|
--yt-icon: #aaaaaa;
|
||||||
|
--yt-border: #3f3f3f;
|
||||||
|
--yt-hover: rgba(255, 255, 255, 0.1);
|
||||||
|
--yt-chip-bg: #272727;
|
||||||
|
--yt-chip-active-bg: #f1f1f1;
|
||||||
|
--yt-chip-active-text: #0f0f0f;
|
||||||
|
--yt-blue: #3ea6ff;
|
||||||
|
--yt-red: #ff0000;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spacing & Layout
|
||||||
|
|
||||||
|
| Property | Desktop Grid | Search | Sidebar |
|
||||||
|
|----------|--------------|--------|---------|
|
||||||
|
| Card Gap (horizontal) | 16px | N/A | N/A |
|
||||||
|
| Card Gap (vertical) | 40px | 16px | 8px |
|
||||||
|
| Thumbnail-to-metadata gap | 12px | 12px | 8px |
|
||||||
|
| Avatar-to-text gap | 12px | 12px | N/A |
|
||||||
|
| Title max lines | 2 | 2 | 2 |
|
||||||
|
| Channel name max lines | 1 | 1 | 1 |
|
||||||
|
|
||||||
|
### Hover States (Desktop)
|
||||||
|
|
||||||
|
```css
|
||||||
|
.video-card:hover .thumbnail {
|
||||||
|
/* YouTube shows preview animation on hover after delay */
|
||||||
|
transform: none; /* No scale transform */
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-card:hover .watch-later-button,
|
||||||
|
.video-card:hover .add-to-queue-button {
|
||||||
|
opacity: 1; /* Buttons appear on hover */
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-name:hover {
|
||||||
|
color: var(--yt-title); /* Slightly darker on hover */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responsive Breakpoints
|
||||||
|
|
||||||
|
| Breakpoint | Grid Columns | Thumbnail Width |
|
||||||
|
|------------|--------------|-----------------|
|
||||||
|
| > 2136px | 6 columns | ~356px |
|
||||||
|
| 1712px - 2136px | 5 columns | ~342px |
|
||||||
|
| 1288px - 1712px | 4 columns | ~320px |
|
||||||
|
| 888px - 1288px | 3 columns | ~290px |
|
||||||
|
| 512px - 888px | 2 columns | ~280px |
|
||||||
|
| < 512px | 1 column (mobile) | 100% |
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. CSS Variable Naming
|
||||||
|
**Decision**: Use `--yt-*` prefix for YouTube-specific variables
|
||||||
|
**Rationale**: Already implemented in current codebase; keeps YouTube styles isolated from app design system
|
||||||
|
**Alternatives**: Using Tailwind theme tokens directly - rejected because we need YouTube-specific colors separate from app theme
|
||||||
|
|
||||||
|
### 2. Component Structure
|
||||||
|
**Decision**: Single `YouTubeVideoCard` component with `variant` prop for mode switching
|
||||||
|
**Rationale**: Existing component already supports variants; minimizes code duplication
|
||||||
|
**Alternatives**: Separate components per mode - rejected due to shared logic (formatting, truncation)
|
||||||
|
|
||||||
|
### 3. Visual Testing Strategy
|
||||||
|
**Decision**: Playwright screenshot comparison with 98% match threshold
|
||||||
|
**Rationale**: Allows for minor anti-aliasing differences while catching layout regressions
|
||||||
|
**Alternatives**: Pixel-perfect 100% match - too brittle; structural testing only - doesn't catch visual issues
|
||||||
|
|
||||||
|
### 4. Reference Screenshot Updates
|
||||||
|
**Decision**: Weekly automated capture via Playwright MCP
|
||||||
|
**Rationale**: Per clarification; balances freshness with stability
|
||||||
|
**Alternatives**: Manual updates - inconsistent; daily updates - too frequent, unstable baselines
|
||||||
|
|
||||||
|
### 5. Local Storage Schema
|
||||||
|
**Decision**: Store serialized preview state with thumbnail as base64
|
||||||
|
**Rationale**: Self-contained, no external file dependencies
|
||||||
|
**Alternatives**: IndexedDB with blob storage - more complex, minimal benefit for single-user tool
|
||||||
|
|
||||||
|
## Reference Screenshots Captured
|
||||||
|
|
||||||
|
- `youtube-desktop-homepage-*.png` - Homepage grid layout
|
||||||
|
- `youtube-search-fullhd-*.png` - Search results at 1920x1080
|
||||||
|
- `youtube-video-with-sidebar-*.png` - Watch page with related videos
|
||||||
|
- `youtube-watch-related-*.png` - Sidebar compact video cards
|
||||||
|
- `youtube-trending-grid-*.png` - Trending page grid
|
||||||
|
- `youtube-dark-mode-*.png` - Dark theme reference
|
||||||
|
|
||||||
|
All screenshots stored in: `specs/002-youtube-design-preview/reference/`
|
||||||
146
specs/002-youtube-design-preview/spec.md
Normal file
146
specs/002-youtube-design-preview/spec.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# Feature Specification: YouTube Design Preview Replica
|
||||||
|
|
||||||
|
**Feature Branch**: `002-youtube-design-preview`
|
||||||
|
**Created**: 2026-01-29
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "необходимо в превью полностью повторить дизайн youtube для десктопа, мобильной версии и sidebar, превью должно полностью повторять youtube 1 в 1, используй mcp playwright для проверки соответствия"
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Desktop Video Preview (Priority: P1)
|
||||||
|
|
||||||
|
A content creator wants to preview how their thumbnail will appear in YouTube's desktop grid layout, including the video duration badge, channel avatar, video title, channel name, view count, and upload time - exactly as it appears on YouTube's homepage.
|
||||||
|
|
||||||
|
**Why this priority**: Desktop is the primary platform for content creators reviewing their work. The grid layout preview provides the most common context where thumbnails are seen by viewers.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by uploading a thumbnail and verifying it renders in a pixel-perfect YouTube desktop grid card format with all metadata elements positioned correctly.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a user has uploaded a thumbnail image, **When** they select desktop preview mode, **Then** they see their thumbnail displayed in an exact replica of YouTube's desktop video card with 16:9 aspect ratio, rounded corners, duration badge in bottom-right, and hover effects
|
||||||
|
2. **Given** the desktop preview is displayed, **When** the user views the video metadata section, **Then** they see channel avatar (circular, left-aligned), title (multi-line truncation), channel name, view count, and relative upload time styled identically to YouTube
|
||||||
|
3. **Given** the desktop preview is active, **When** the user hovers over the thumbnail, **Then** they see YouTube's hover state including preview animation placeholder and watch later/add to queue icons
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Mobile Video Preview (Priority: P1)
|
||||||
|
|
||||||
|
A content creator wants to see how their thumbnail appears on YouTube's mobile app layout, where the thumbnail takes full width and the metadata is displayed below in YouTube's mobile-specific format.
|
||||||
|
|
||||||
|
**Why this priority**: Mobile accounts for over 70% of YouTube views. Creators must verify their thumbnails are effective on mobile devices where layout and text sizes differ significantly.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by switching to mobile preview mode and verifying the layout matches YouTube's mobile app exactly, including touch-friendly sizing and mobile-specific typography.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a user has uploaded a thumbnail, **When** they select mobile preview mode, **Then** the thumbnail displays full-width with YouTube's mobile aspect ratio and rounded corners matching the app
|
||||||
|
2. **Given** mobile preview is active, **When** viewing video metadata, **Then** the channel avatar, title, channel name, views, and time are displayed in YouTube's mobile typography and spacing
|
||||||
|
3. **Given** mobile preview is active, **When** the preview is compared to actual YouTube mobile app, **Then** the layout, spacing, fonts, and element sizes match within 2px tolerance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Sidebar/Related Videos Preview (Priority: P2)
|
||||||
|
|
||||||
|
A content creator wants to preview how their thumbnail will appear in YouTube's sidebar (related videos section) where thumbnails are displayed at a smaller size alongside video titles.
|
||||||
|
|
||||||
|
**Why this priority**: The sidebar is a major discovery surface for videos. Thumbnails must be legible and compelling at this smaller size to drive click-through from related videos.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by selecting sidebar preview mode and verifying the thumbnail and title display match YouTube's sidebar format exactly.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a user has uploaded a thumbnail, **When** they select sidebar preview mode, **Then** the thumbnail displays at YouTube's exact sidebar dimensions (168x94px) with correct aspect ratio
|
||||||
|
2. **Given** sidebar preview is active, **When** viewing the video card, **Then** the title displays to the right of the thumbnail with proper truncation (2 lines), channel name below, and view count/time in YouTube's sidebar format
|
||||||
|
3. **Given** sidebar preview mode, **When** multiple preview cards are shown, **Then** they stack vertically with YouTube's exact spacing between cards
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 - Visual Accuracy Validation with Playwright (Priority: P2)
|
||||||
|
|
||||||
|
The system uses automated visual comparison testing with Playwright MCP to ensure the preview components match YouTube's actual design with pixel-level accuracy.
|
||||||
|
|
||||||
|
**Why this priority**: Automated validation ensures design fidelity is maintained during development and catches visual regressions early. This supports the "1:1 replica" requirement.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by running Playwright visual comparison tests against captured YouTube reference screenshots.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a preview component is rendered, **When** Playwright captures a screenshot, **Then** the screenshot can be compared against a YouTube reference image with configurable threshold (default 98% match)
|
||||||
|
2. **Given** a visual comparison test runs, **When** differences are detected above threshold, **Then** a diff image is generated highlighting discrepancies for developer review
|
||||||
|
3. **Given** all three preview modes exist, **When** the full visual test suite runs, **Then** each mode (desktop, mobile, sidebar) passes the visual comparison against corresponding YouTube references
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when the thumbnail image has non-standard aspect ratio? System should crop/scale to 16:9 with preview of crop area
|
||||||
|
- How does system handle very long video titles? Truncation with ellipsis matching YouTube's exact behavior per view mode
|
||||||
|
- What happens when channel name contains special characters or emojis? Render identically to YouTube's handling
|
||||||
|
- How does system handle missing metadata fields (no view count yet, no upload time)? Display "No views" and "Just now" as YouTube does
|
||||||
|
- What happens on intermediate screen sizes between desktop and mobile breakpoints? Follow YouTube's responsive behavior
|
||||||
|
- What happens when uploaded thumbnail exceeds 5MB? Display error message and reject upload, prompting user to reduce file size
|
||||||
|
- What happens when user uploads unsupported image format (e.g., GIF, BMP)? Display error message listing accepted formats (JPG, PNG, WebP)
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST render desktop video card preview matching YouTube's current design with pixel-level accuracy
|
||||||
|
- **FR-002**: System MUST render mobile video card preview matching YouTube's mobile app layout exactly
|
||||||
|
- **FR-003**: System MUST render sidebar video card preview matching YouTube's related videos section design
|
||||||
|
- **FR-004**: System MUST support real-time thumbnail image upload and preview update
|
||||||
|
- **FR-005**: System MUST display all YouTube video metadata elements: thumbnail, duration badge, channel avatar, title, channel name, view count, upload time
|
||||||
|
- **FR-006**: System MUST replicate YouTube's typography including font family, sizes, weights, and colors for each view mode
|
||||||
|
- **FR-007**: System MUST replicate YouTube's spacing, padding, and margins for all preview components
|
||||||
|
- **FR-008**: System MUST replicate YouTube's hover states and interactive elements on desktop preview
|
||||||
|
- **FR-009**: System MUST implement responsive breakpoints matching YouTube's desktop-to-mobile transitions
|
||||||
|
- **FR-010**: System MUST support Playwright MCP integration for automated visual comparison testing
|
||||||
|
- **FR-011**: System MUST generate visual diff reports when preview design deviates from YouTube reference
|
||||||
|
- **FR-012**: System MUST allow users to customize preview metadata (title, channel name, view count, etc.) for realistic previews
|
||||||
|
- **FR-013**: System MUST display duration badge with correct formatting (HH:MM:SS or MM:SS) positioned in bottom-right of thumbnail
|
||||||
|
- **FR-014**: System MUST render channel avatar as circular image with YouTube's exact sizing per view mode
|
||||||
|
- **FR-015**: System MUST persist preview data (thumbnail, metadata) in browser local storage, surviving page refresh without requiring server-side storage or user accounts
|
||||||
|
- **FR-016**: System MUST provide manual theme toggle allowing users to switch between light and dark modes matching YouTube's respective color schemes (no auto-detection of system preference)
|
||||||
|
- **FR-017**: System MUST automatically capture fresh reference screenshots from live YouTube on a weekly schedule for visual comparison baseline updates
|
||||||
|
- **FR-018**: System MUST store historical reference screenshots to enable rollback if automated capture produces invalid baselines
|
||||||
|
- **FR-019**: System MUST enforce 5MB maximum file size for thumbnail uploads, matching YouTube's actual limit, and display clear error message for oversized files
|
||||||
|
- **FR-020**: System MUST accept JPG, PNG, and WebP image formats for thumbnail upload, rejecting unsupported formats with clear error message
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **ThumbnailPreview**: Represents a preview instance containing user-uploaded image, selected view mode, and customizable metadata
|
||||||
|
- **PreviewMode**: Enumeration of view modes (Desktop, Mobile, Sidebar) each with distinct layout specifications
|
||||||
|
- **VideoMetadata**: User-customizable data including title, channel name, channel avatar, duration, view count, upload time
|
||||||
|
- **VisualTestResult**: Comparison result containing match percentage, diff image reference, and pass/fail status
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: All three preview modes (desktop, mobile, sidebar) achieve 98% or higher visual match when compared to YouTube reference screenshots via Playwright
|
||||||
|
- **SC-002**: Users can upload a thumbnail and see the preview render in under 2 seconds
|
||||||
|
- **SC-003**: Preview accurately reflects YouTube's current design as verified by side-by-side manual comparison
|
||||||
|
- **SC-004**: 90% of users can correctly identify the preview as "YouTube-like" in blind comparison tests
|
||||||
|
- **SC-005**: Visual regression tests pass with less than 2% pixel difference from established baseline
|
||||||
|
- **SC-006**: Preview maintains visual accuracy across Chrome, Firefox, and Safari browsers
|
||||||
|
- **SC-007**: Mobile preview renders correctly on actual mobile devices (iOS Safari, Android Chrome) matching the YouTube app appearance
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-01-29
|
||||||
|
|
||||||
|
- Q: Should previews be persisted for later access? → A: Local storage (preview persisted in browser, no server storage)
|
||||||
|
- Q: How should theme mode (light/dark) be handled? → A: Both themes available, user must manually select (no auto-detection)
|
||||||
|
- Q: How should reference screenshots be maintained for visual comparison? → A: Automated weekly capture from live YouTube
|
||||||
|
- Q: What is the maximum thumbnail file size for upload? → A: 5MB maximum (matches YouTube's actual limit)
|
||||||
|
- Q: Which image formats are supported for thumbnail upload? → A: JPG, PNG, and WebP (matches YouTube's supported formats)
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- YouTube's current design (as of January 2026) serves as the reference baseline
|
||||||
|
- Font family uses YouTube's public-facing fonts (Roboto for body text)
|
||||||
|
- Color values will be extracted from YouTube's current production CSS
|
||||||
|
- Playwright MCP is available and configured in the development environment
|
||||||
|
- Reference screenshots will be automatically captured from live YouTube weekly and stored for comparison testing, with historical versions retained for rollback
|
||||||
|
- The preview tool focuses on static visual accuracy; video playback preview is out of scope
|
||||||
|
- Dark mode support follows YouTube's dark mode color scheme; users manually select theme via toggle (no system preference auto-detection)
|
||||||
249
specs/002-youtube-design-preview/tasks.md
Normal file
249
specs/002-youtube-design-preview/tasks.md
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
# Tasks: YouTube Design Preview Replica
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/002-youtube-design-preview/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/
|
||||||
|
|
||||||
|
**Tests**: Visual regression tests included as User Story 4 (P2) per specification requirements.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3, US4)
|
||||||
|
- Include exact file paths in descriptions
|
||||||
|
|
||||||
|
## Path Conventions
|
||||||
|
|
||||||
|
- **Frontend**: `frontend/src/`
|
||||||
|
- **Tests**: `frontend/tests/visual/`
|
||||||
|
- **Scripts**: `scripts/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Project initialization, type definitions, and shared utilities
|
||||||
|
|
||||||
|
- [x] T001 [P] Add new type definitions (PreviewMode, ThemeMode, VideoMetadata, UploadedThumbnail) in `frontend/src/types/index.ts`
|
||||||
|
- [x] T002 [P] Create YouTube style constants module with extracted CSS tokens in `frontend/src/lib/youtube-styles.ts`
|
||||||
|
- [x] T003 [P] Update CSS variables with precise YouTube values from research in `frontend/src/index.css`
|
||||||
|
- [x] T004 [P] Create useLocalStorage hook for state persistence in `frontend/src/hooks/useLocalStorage.ts`
|
||||||
|
- [x] T005 Install Playwright as dev dependency: `cd frontend && npm install -D @playwright/test`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||||
|
|
||||||
|
- [x] T006 Update previewStore with new state shape (themeMode, persistence) in `frontend/src/store/previewStore.ts`
|
||||||
|
- [x] T007 Add localStorage persistence integration to previewStore in `frontend/src/store/previewStore.ts`
|
||||||
|
- [x] T008 [P] Create ThemeToggle component with light/dark switch in `frontend/src/components/ThemeToggle.tsx`
|
||||||
|
- [x] T009 [P] Create PreviewModeSelector component (desktop/mobile/sidebar) in `frontend/src/components/PreviewModeSelector.tsx`
|
||||||
|
- [x] T010 Update ThumbnailUploader with 5MB limit and format validation (JPG/PNG/WebP) in `frontend/src/components/ThumbnailUploader.tsx`
|
||||||
|
- [x] T011 Add error messages for file validation failures in `frontend/src/components/ThumbnailUploader.tsx`
|
||||||
|
|
||||||
|
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Desktop Video Preview (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Pixel-perfect YouTube desktop grid layout preview with all metadata elements
|
||||||
|
|
||||||
|
**Independent Test**: Upload a thumbnail, select desktop mode, verify visual match with YouTube homepage grid card
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T012 [US1] Refactor YouTubeVideoCard desktop variant with exact YouTube dimensions (360x202px, 12px radius) in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T013 [US1] Update desktop title typography (Roboto 16px/500, line-height 22px, 2-line clamp) in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T014 [US1] Update channel name and metadata typography (12px, #606060/#aaaaaa) in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T015 [US1] Update avatar display (36x36px circle) in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T016 [US1] Update duration badge styling (rgba(0,0,0,0.8), 12px/500, 4px radius, 3px 4px padding) in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T017 [US1] Add hover states (watch later, add to queue icons on hover) in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T018 [US1] Add metadata spacing (12px thumbnail-to-meta gap, 12px avatar-to-text gap) in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T019 [US1] Integrate desktop preview with ThemeToggle for light/dark mode switching in `frontend/src/pages/ToolPage.tsx`
|
||||||
|
- [x] T020 [US1] Add metadata editor UI for customizing title, channel, views, duration in `frontend/src/components/UserInfoInputs.tsx`
|
||||||
|
|
||||||
|
**Checkpoint**: Desktop preview should match YouTube homepage grid exactly - test with side-by-side comparison
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Mobile Video Preview (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Pixel-perfect YouTube mobile app layout preview with full-width thumbnails
|
||||||
|
|
||||||
|
**Independent Test**: Switch to mobile mode, verify full-width layout matches YouTube mobile app
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T021 [US2] Update YouTubeVideoCard mobile variant with full-width thumbnail (100%, 16:9 ratio, 0px radius) in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T022 [US2] Update mobile metadata layout (avatar 36x36, title below thumbnail) in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T023 [US2] Update mobile typography (match YouTube mobile app sizes) in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T024 [US2] Update mobile spacing (gap between metadata elements) in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T025 [US2] Add mobile container wrapper with max-width constraint in `frontend/src/pages/ToolPage.tsx`
|
||||||
|
- [x] T026 [US2] Add responsive viewport simulation for mobile preview in `frontend/src/pages/ToolPage.tsx`
|
||||||
|
|
||||||
|
**Checkpoint**: Mobile preview should match YouTube mobile app - test on mobile device or devtools
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Sidebar/Related Videos Preview (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Pixel-perfect YouTube sidebar (related videos) preview with compact horizontal layout
|
||||||
|
|
||||||
|
**Independent Test**: Switch to sidebar mode, verify compact card layout matches YouTube watch page sidebar
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T027 [US3] Update YouTubeVideoCard sidebar variant with exact dimensions (168x94px thumbnail, 8px radius) in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T028 [US3] Update sidebar horizontal layout (thumbnail left, metadata right) in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T029 [US3] Update sidebar title typography (14px/500, 2-line clamp) in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T030 [US3] Update sidebar metadata (channel name, views, time below title) in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T031 [US3] Update sidebar spacing (8px thumbnail-to-text gap, 8px between cards) in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T032 [US3] Remove avatar from sidebar variant (YouTube sidebar has no avatar) in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T033 [US3] Add sidebar list container with vertical stacking in `frontend/src/pages/ToolPage.tsx`
|
||||||
|
|
||||||
|
**Checkpoint**: Sidebar preview should match YouTube watch page related videos section
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: User Story 4 - Visual Accuracy Validation with Playwright (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Automated visual regression testing to ensure 98% match with YouTube reference screenshots
|
||||||
|
|
||||||
|
**Independent Test**: Run `npx playwright test` and verify all visual tests pass with 98% threshold
|
||||||
|
|
||||||
|
### Implementation for User Story 4
|
||||||
|
|
||||||
|
- [x] T034 [P] [US4] Create Playwright configuration file in `frontend/playwright.config.ts`
|
||||||
|
- [x] T035 [P] [US4] Create visual test utilities (screenshot comparison, diff generation) in `frontend/tests/visual/utils.ts`
|
||||||
|
- [x] T036 [P] [US4] Copy reference screenshots to test directory in `frontend/tests/visual/reference/`
|
||||||
|
- [x] T037 [US4] Create desktop visual test (light mode) in `frontend/tests/visual/desktop.spec.ts`
|
||||||
|
- [x] T038 [US4] Add desktop visual test (dark mode) in `frontend/tests/visual/desktop.spec.ts`
|
||||||
|
- [x] T039 [US4] Create mobile visual test (light/dark modes) in `frontend/tests/visual/mobile.spec.ts`
|
||||||
|
- [x] T040 [US4] Create sidebar visual test (light/dark modes) in `frontend/tests/visual/sidebar.spec.ts`
|
||||||
|
- [x] T041 [US4] Create reference screenshot capture script in `scripts/capture-youtube-reference.ts`
|
||||||
|
- [x] T042 [US4] Add npm script for visual tests: `"test:visual": "playwright test"` in `frontend/package.json`
|
||||||
|
|
||||||
|
**Checkpoint**: All visual tests should pass with 98%+ match against YouTube references
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Improvements that affect multiple user stories
|
||||||
|
|
||||||
|
- [x] T043 [P] Add responsive breakpoints matching YouTube (6→5→4→3→2→1 column) in `frontend/src/components/PreviewGrid.tsx`
|
||||||
|
- [x] T044 [P] Add edge case handling: non-standard aspect ratio thumbnails (crop to 16:9) in `frontend/src/components/ThumbnailUploader.tsx`
|
||||||
|
- [x] T045 [P] Add edge case handling: long titles (ellipsis truncation per mode) in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T046 [P] Add edge case handling: missing metadata ("No views", "Just now") in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T047 [P] Add localStorage cleanup (limit 10 recent thumbnails) in `frontend/src/store/previewStore.ts`
|
||||||
|
- [x] T048 Performance optimization: lazy load thumbnails, optimize re-renders in `frontend/src/components/YouTubeVideoCard.tsx`
|
||||||
|
- [x] T049 Run ESLint and fix any errors: `cd frontend && npm run lint`
|
||||||
|
- [ ] T050 Manual validation against quickstart.md test scenarios
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies - can start immediately
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
|
||||||
|
- **User Stories (Phase 3-6)**: All depend on Foundational phase completion
|
||||||
|
- User stories can then proceed in parallel (if staffed)
|
||||||
|
- Or sequentially in priority order (US1 → US2 → US3 → US4)
|
||||||
|
- **Polish (Phase 7)**: Depends on all user stories being complete
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
|
||||||
|
- **User Story 2 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
|
||||||
|
- **User Story 3 (P2)**: Can start after Foundational (Phase 2) - No dependencies on other stories
|
||||||
|
- **User Story 4 (P2)**: Can start after US1/US2/US3 have implementations to test
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Core styling before hover states
|
||||||
|
- Layout before typography fine-tuning
|
||||||
|
- Component implementation before page integration
|
||||||
|
- Story complete before moving to next priority
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- All Setup tasks T001-T004 can run in parallel
|
||||||
|
- Foundational tasks T008, T009 can run in parallel
|
||||||
|
- User Stories 1, 2, 3 can be worked on in parallel after Foundational
|
||||||
|
- Visual test setup (T034-T036) can run in parallel
|
||||||
|
- All Polish tasks marked [P] can run in parallel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: Setup Phase
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch all setup tasks in parallel:
|
||||||
|
Task: "Add new type definitions in frontend/src/types/index.ts"
|
||||||
|
Task: "Create YouTube style constants in frontend/src/lib/youtube-styles.ts"
|
||||||
|
Task: "Update CSS variables in frontend/src/index.css"
|
||||||
|
Task: "Create useLocalStorage hook in frontend/src/hooks/useLocalStorage.ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Stories After Foundational
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# With multiple developers after Phase 2:
|
||||||
|
Developer A: User Story 1 (Desktop) - T012-T020
|
||||||
|
Developer B: User Story 2 (Mobile) - T021-T026
|
||||||
|
Developer C: User Story 3 (Sidebar) - T027-T033
|
||||||
|
# Then User Story 4 (Visual Tests) once implementations exist
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup (T001-T005)
|
||||||
|
2. Complete Phase 2: Foundational (T006-T011)
|
||||||
|
3. Complete Phase 3: User Story 1 - Desktop (T012-T020)
|
||||||
|
4. **STOP and VALIDATE**: Side-by-side comparison with YouTube
|
||||||
|
5. Deploy/demo if ready
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Setup + Foundational → Foundation ready
|
||||||
|
2. Add User Story 1 (Desktop) → Test independently → Demo (MVP!)
|
||||||
|
3. Add User Story 2 (Mobile) → Test independently → Demo
|
||||||
|
4. Add User Story 3 (Sidebar) → Test independently → Demo
|
||||||
|
5. Add User Story 4 (Visual Tests) → Automated validation
|
||||||
|
6. Polish phase → Production ready
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
With multiple developers:
|
||||||
|
|
||||||
|
1. Team completes Setup + Foundational together
|
||||||
|
2. Once Foundational is done:
|
||||||
|
- Developer A: User Story 1 (Desktop)
|
||||||
|
- Developer B: User Story 2 (Mobile)
|
||||||
|
- Developer C: User Story 3 (Sidebar)
|
||||||
|
3. All developers: User Story 4 (Visual Tests)
|
||||||
|
4. Team: Polish phase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- [P] tasks = different files, no dependencies
|
||||||
|
- [Story] label maps task to specific user story for traceability
|
||||||
|
- Each user story should be independently completable and testable
|
||||||
|
- Commit after each task or logical group
|
||||||
|
- Stop at any checkpoint to validate story independently
|
||||||
|
- YouTube CSS values from research.md are the source of truth
|
||||||
|
- Reference screenshots in specs/002-youtube-design-preview/reference/
|
||||||
Reference in New Issue
Block a user