feat: frontend shadcn

This commit is contained in:
2026-01-29 13:38:29 -03:00
parent 7627a00303
commit 5125bbf4d4
54 changed files with 6990 additions and 329 deletions

View File

@@ -0,0 +1,22 @@
{
"permissions": {
"allow": [
"Bash(tree:*)",
"Bash(find:*)",
"mcp__firecrawl-mcp__firecrawl_search",
"WebFetch(domain:www.awwwards.com)",
"WebFetch(domain:dribbble.com)",
"mcp__playwright__playwright_navigate",
"mcp__playwright__playwright_screenshot",
"mcp__playwright__playwright_get_visible_text"
]
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"youtube",
"shadcn"
],
"disabledMcpjsonServers": [
"youtube"
]
}

11
.mcp.json Normal file
View File

@@ -0,0 +1,11 @@
{
"mcpServers": {
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
}
}
}

View File

@@ -1,50 +1,255 @@
# [PROJECT_NAME] Constitution
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
<!--
=====================================================================
SYNC IMPACT REPORT
=====================================================================
Version change: 1.0.0 → 1.1.0 (MINOR: Added shadcn/ui component library)
Modified principles:
- III. Styling → III. Styling & UI Components (expanded scope)
Added sections:
- shadcn/ui configuration and usage guidelines
- UI component library in Tech Stack
- components/ui/ directory in Project Structure
Removed sections: None
Templates requiring updates:
- .specify/templates/plan-template.md ✅ (compatible - no changes needed)
- .specify/templates/spec-template.md ✅ (compatible - no changes needed)
- .specify/templates/tasks-template.md ✅ (compatible - no changes needed)
Follow-up TODOs: None
=====================================================================
-->
# ThumbPreview Constitution
## Core Principles
### [PRINCIPLE_1_NAME]
<!-- Example: I. Library-First -->
[PRINCIPLE_1_DESCRIPTION]
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
### I. Tech Stack
### [PRINCIPLE_2_NAME]
<!-- Example: II. CLI Interface -->
[PRINCIPLE_2_DESCRIPTION]
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
All new code MUST use the established technology stack:
### [PRINCIPLE_3_NAME]
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
[PRINCIPLE_3_DESCRIPTION]
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
**Frontend (React SPA):**
- React 19.x with TypeScript 5.x
- Vite as build tool with HMR
- Tailwind CSS 4.x for styling
- shadcn/ui for UI components (Radix UI primitives)
- Zustand for client state management
- @tanstack/react-query for server state
- Axios for HTTP requests
- lucide-react for icons
### [PRINCIPLE_4_NAME]
<!-- Example: IV. Integration Testing -->
[PRINCIPLE_4_DESCRIPTION]
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
**Backend (NestJS API):**
- NestJS 11.x with TypeScript 5.x
- Express via @nestjs/platform-express
- PostgreSQL with TypeORM
- class-validator and class-transformer for DTOs
- Multer for file uploads
- Sharp for image processing
### [PRINCIPLE_5_NAME]
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
[PRINCIPLE_5_DESCRIPTION]
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
**Rationale:** Consistent tooling reduces onboarding friction, ensures compatibility,
and enables shared patterns across the codebase.
## [SECTION_2_NAME]
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
### II. Architecture & Project Structure
[SECTION_2_CONTENT]
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
This is a **monorepo** with separate frontend and backend packages. New code MUST
follow the established directory organization:
## [SECTION_3_NAME]
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
**Frontend (`/frontend/src/`):**
- `components/` - Application React UI components
- `components/ui/` - shadcn/ui primitive components (auto-generated)
- `hooks/` - Custom React hooks (useX naming)
- `store/` - Zustand state stores
- `api/` - Axios client and API methods
- `types/` - TypeScript interfaces and types
- `lib/` - Utility functions (includes `utils.ts` for cn() helper)
- `assets/` - Static images and resources
[SECTION_3_CONTENT]
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
**Backend (`/backend/src/`):**
- `modules/` - Feature modules (NestJS module pattern)
- Each module contains: `*.module.ts`, `*.controller.ts`, `*.service.ts`
- `entities/` - TypeORM entity definitions
**Configuration Files:**
- `components.json` - shadcn/ui configuration (registry, aliases, styling)
- `tsconfig.json` - Must include `@/*` path alias for shadcn imports
**Rules:**
- One component/hook/service per file
- Feature-based module organization in backend
- Clear separation between API, state, and presentation layers
- All imports MUST use `@/` alias for shadcn components and lib utilities
- Application components use relative imports within packages
**Rationale:** Predictable structure enables faster navigation and reduces
cognitive load when adding features.
### III. Styling & UI Components
All styling MUST use Tailwind CSS utility classes combined with shadcn/ui:
**shadcn/ui Usage:**
- Use shadcn/ui components for standard UI elements (Button, Card, Input, etc.)
- Install components via `npx shadcn@latest add <component>`
- Components installed to `src/components/ui/` - DO NOT modify generated files
- Use shadcn MCP server for component discovery and installation commands
- Compose shadcn components with application-specific styling via className prop
**Tailwind Integration:**
- Import Tailwind via `@import "tailwindcss"` in index.css
- CSS variables defined in index.css for theming (--primary, --background, etc.)
- Use `cn()` utility from `@/lib/utils` for conditional class merging
- Dark mode via `class="dark"` on html element
- Follow established color scheme via CSS variables
**Styling Rules:**
- Prefer shadcn/ui components over custom implementations
- Use Tailwind utilities for layout, spacing, and custom styling
- Use CSS variables (--primary, --muted-foreground, etc.) for colors
- Use Tailwind's responsive breakpoints (sm, md, lg, xl) for layouts
**Forbidden:**
- Inline style objects except for dynamic values (e.g., computed widths)
- Creating new CSS files for component-specific styles
- Using `!important` overrides
- CSS Modules, styled-components, or CSS-in-JS libraries
- Modifying files in `components/ui/` directory
**Icon Usage:**
- Use lucide-react for all icons
- Import icons individually: `import { IconName } from 'lucide-react'`
- Use `size-X` or `className="size-X"` for icon sizing
**Rationale:** shadcn/ui provides accessible, well-designed primitives while
Tailwind enables customization. This combination ensures consistency and
reduces custom CSS maintenance.
### IV. Data Management
**Frontend State:**
- Use Zustand for UI/client state (view mode, selections, form data)
- Use React Query for server state (API data, caching, mutations)
- Define all store actions in the store file, not in components
- React Query stale time: 1 hour for search results
**Backend Storage:**
- PostgreSQL for persistent data (via TypeORM)
- UUID primary keys for all entities
- File uploads stored in `/backend/uploads/` directory
- YouTube API responses cached in database with 24-hour TTL
**Environment Configuration:**
- Backend: Use @nestjs/config with .env files
- Frontend: Use Vite proxy for API calls (no .env needed)
- Never commit .env files or API keys
**Rationale:** Clear boundaries between client and server state prevent
synchronization bugs and simplify debugging.
### V. Development Practices
**TypeScript:**
- Strict mode enabled for frontend application code
- All new code MUST have explicit type annotations for function parameters
- Use interfaces for object shapes, types for unions/aliases
- No `any` type except in exceptional cases with justification
- Path aliases MUST be configured: `@/*``./src/*`
**Linting & Formatting:**
- Frontend: ESLint with React hooks and React Refresh plugins
- Backend: ESLint + Prettier (singleQuote, trailingComma: all)
- Run `npm run lint` before committing
- Run `npm run format` (backend) for consistent formatting
**Testing (Backend):**
- Jest for unit and integration tests
- Test files: `*.spec.ts` adjacent to source files
- Run `npm run test` for test suite
**Validation:**
- Backend: Use class-validator decorators on all DTOs
- Global validation pipe enabled (whitelist + transform)
- File uploads: Validate MIME types (JPEG, PNG, WebP only, 5MB max)
**Rationale:** Consistent quality standards prevent regressions and
maintain code health over time.
## Technology Stack Reference
| Layer | Technology | Version | Purpose |
|-------|------------|---------|---------|
| Frontend Runtime | React | 19.x | UI library |
| Frontend Build | Vite | 7.x | Build + HMR |
| Frontend Styling | Tailwind CSS | 4.x | Utility CSS |
| Frontend UI | shadcn/ui | 3.x | Component library |
| Frontend Icons | lucide-react | latest | Icon library |
| Frontend State | Zustand | 5.x | Client state |
| Frontend Data | React Query | 5.x | Server state |
| Backend Framework | NestJS | 11.x | API server |
| Backend Database | PostgreSQL | 16 | Data persistence |
| Backend ORM | TypeORM | 0.3.x | DB abstraction |
| Runtime | Node.js | 20+ | Server runtime |
New dependencies MUST be justified and reviewed for:
- Compatibility with existing versions
- Bundle size impact (frontend)
- Maintenance status (active development, security updates)
- License compatibility (MIT preferred)
**shadcn/ui Component Installation:**
```bash
# List available components
npx shadcn@latest add --help
# Add a component (use shadcn MCP for discovery)
npx shadcn@latest add button card input --yes
# Components are added to src/components/ui/
```
## Code Quality Standards
**Pull Request Checklist:**
1. Code follows architecture patterns (correct directory placement)
2. Styling uses Tailwind utilities and/or shadcn components
3. TypeScript types are explicit (no implicit any)
4. Linter passes with no errors
5. New backend endpoints include validation
6. Environment variables documented if added
7. shadcn components used for standard UI elements
**Commit Guidelines:**
- Use conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`
- Reference issue numbers when applicable
- Keep commits focused (one logical change per commit)
**Code Review Focus Areas:**
- Security: No hardcoded credentials, proper input validation
- Performance: Avoid unnecessary re-renders, efficient queries
- Maintainability: Clear naming, appropriate abstractions
- UI Consistency: Use shadcn components, follow established patterns
## Governance
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
[GOVERNANCE_RULES]
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
This constitution establishes non-negotiable development standards for the
ThumbPreview project. All contributors MUST adhere to these principles.
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
**Amendment Process:**
1. Propose changes via pull request to this file
2. Document rationale for changes
3. Increment version according to semver rules
4. Update dependent templates if principle changes affect them
**Version Policy:**
- MAJOR: Backward-incompatible changes (removing principles, changing stack)
- MINOR: New principles or significant expansions
- PATCH: Clarifications, typo fixes, non-semantic refinements
**Compliance:**
- All PRs MUST pass constitution checks before merge
- Violations require explicit justification in PR description
- Repeated violations should trigger constitution review
**Version**: 1.1.0 | **Ratified**: 2026-01-29 | **Last Amended**: 2026-01-29

29
CLAUDE.md Normal file
View File

@@ -0,0 +1,29 @@
# thumbnail-preview-tool Development Guidelines
Auto-generated from all feature plans. Last updated: 2026-01-29
## Active Technologies
- TypeScript 5.x (frontend + backend) (001-google-oauth-auth)
## Project Structure
```text
src/
tests/
```
## Commands
npm test && npm run lint
## Code Style
TypeScript 5.x (frontend + backend): Follow standard conventions
## Recent Changes
- 001-google-oauth-auth: Added TypeScript 5.x (frontend + backend)
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

31
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Dependencies
node_modules/
# Build output
dist/
# Environment
.env
.env.local
# Logs
*.log
npm-debug.log*
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Uploads (user content)
uploads/*
!uploads/.gitkeep
# Database
*.sqlite
*.db

View File

@@ -6,10 +6,7 @@ import { YouTubeService } from './youtube.service';
import { YouTubeCache } from '../../entities/youtube-cache.entity';
@Module({
imports: [
TypeOrmModule.forFeature([YouTubeCache]),
HttpModule,
],
imports: [TypeOrmModule.forFeature([YouTubeCache]), HttpModule],
controllers: [YouTubeController],
providers: [YouTubeService],
})

View File

@@ -55,7 +55,10 @@ export class YouTubeService {
this.apiKey = this.configService.get<string>('YOUTUBE_API_KEY', '');
}
async search(query: string, maxResults = 10): Promise<YouTubeVideoResponse[]> {
async search(
query: string,
maxResults = 10,
): Promise<YouTubeVideoResponse[]> {
// Check cache first
const cached = await this.getFromCache(query);
if (cached) {
@@ -76,7 +79,9 @@ export class YouTubeService {
return results;
}
private async getFromCache(query: string): Promise<YouTubeVideoResponse[] | null> {
private async getFromCache(
query: string,
): Promise<YouTubeVideoResponse[] | null> {
const cached = await this.cacheRepository.findOne({
where: {
searchQuery: query.toLowerCase(),
@@ -87,7 +92,10 @@ export class YouTubeService {
return cached ? cached.results : null;
}
private async saveToCache(query: string, results: YouTubeVideoResponse[]): Promise<void> {
private async saveToCache(
query: string,
results: YouTubeVideoResponse[],
): Promise<void> {
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + this.cacheHours);
@@ -100,7 +108,10 @@ export class YouTubeService {
await this.cacheRepository.save(cache);
}
private async fetchFromYouTube(query: string, maxResults: number): Promise<YouTubeVideoResponse[]> {
private async fetchFromYouTube(
query: string,
maxResults: number,
): Promise<YouTubeVideoResponse[]> {
const searchUrl = 'https://www.googleapis.com/youtube/v3/search';
const { data } = await firstValueFrom(
@@ -115,7 +126,9 @@ export class YouTubeService {
}),
);
const videoIds = data.items.map((item: YouTubeSearchItem) => item.id.videoId).join(',');
const videoIds = data.items
.map((item: YouTubeSearchItem) => item.id.videoId)
.join(',');
// Get view counts
const statsUrl = 'https://www.googleapis.com/youtube/v3/videos';
@@ -138,20 +151,26 @@ export class YouTubeService {
videoId: item.id.videoId,
title: item.snippet.title,
channelTitle: item.snippet.channelTitle,
thumbnailUrl: item.snippet.thumbnails.high?.url || item.snippet.thumbnails.medium.url,
thumbnailUrl:
item.snippet.thumbnails.high?.url || item.snippet.thumbnails.medium.url,
publishedAt: item.snippet.publishedAt,
viewCount: viewCounts.get(item.id.videoId),
}));
}
private getMockResults(query: string, maxResults: number): YouTubeVideoResponse[] {
private getMockResults(
query: string,
maxResults: number,
): YouTubeVideoResponse[] {
const mockVideos: YouTubeVideoResponse[] = [
{
videoId: 'mock1',
title: `${query} - Complete Tutorial for Beginners`,
channelTitle: 'Tech Academy',
thumbnailUrl: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg',
publishedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
publishedAt: new Date(
Date.now() - 7 * 24 * 60 * 60 * 1000,
).toISOString(),
viewCount: '1250000',
},
{
@@ -159,7 +178,9 @@ export class YouTubeService {
title: `Learn ${query} in 30 Minutes`,
channelTitle: 'Code Master',
thumbnailUrl: 'https://i.ytimg.com/vi/9bZkp7q19f0/hqdefault.jpg',
publishedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
publishedAt: new Date(
Date.now() - 30 * 24 * 60 * 60 * 1000,
).toISOString(),
viewCount: '890000',
},
{
@@ -167,7 +188,9 @@ export class YouTubeService {
title: `${query} Crash Course 2025`,
channelTitle: 'Dev Tutorial',
thumbnailUrl: 'https://i.ytimg.com/vi/kJQP7kiw5Fk/hqdefault.jpg',
publishedAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(),
publishedAt: new Date(
Date.now() - 14 * 24 * 60 * 60 * 1000,
).toISOString(),
viewCount: '2100000',
},
{
@@ -175,7 +198,9 @@ export class YouTubeService {
title: `Why ${query} is Amazing`,
channelTitle: 'Tech Reviews',
thumbnailUrl: 'https://i.ytimg.com/vi/RgKAFK5djSk/hqdefault.jpg',
publishedAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(),
publishedAt: new Date(
Date.now() - 60 * 24 * 60 * 60 * 1000,
).toISOString(),
viewCount: '450000',
},
{
@@ -183,7 +208,9 @@ export class YouTubeService {
title: `${query} Tips and Tricks`,
channelTitle: 'Pro Tips',
thumbnailUrl: 'https://i.ytimg.com/vi/fJ9rUzIMcZQ/hqdefault.jpg',
publishedAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
publishedAt: new Date(
Date.now() - 3 * 24 * 60 * 60 * 1000,
).toISOString(),
viewCount: '320000',
},
{
@@ -191,7 +218,9 @@ export class YouTubeService {
title: `${query} for Professionals`,
channelTitle: 'Advanced Learning',
thumbnailUrl: 'https://i.ytimg.com/vi/09R8_2nJtjg/hqdefault.jpg',
publishedAt: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000).toISOString(),
publishedAt: new Date(
Date.now() - 45 * 24 * 60 * 60 * 1000,
).toISOString(),
viewCount: '780000',
},
];

View File

@@ -9,7 +9,7 @@ services:
POSTGRES_PASSWORD: thumbpreview123
POSTGRES_DB: thumbpreview
ports:
- "5432:5432"
- "5435:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped

View File

@@ -0,0 +1,52 @@
{
"permissions": {
"allow": [
"mcp__shadcn__get_project_registries",
"mcp__shadcn__list_items_in_registries",
"mcp__shadcn__get_add_command_for_items",
"Bash(npx shadcn@latest add:*)",
"Bash(npx shadcn@latest init --defaults)",
"Bash(npx shadcn@latest init:*)",
"Bash(npm install:*)",
"Bash(npm run build:*)",
"mcp__firecrawl-mcp__firecrawl_search",
"mcp__firecrawl-mcp__firecrawl_scrape",
"mcp__playwright__playwright_navigate",
"mcp__playwright__playwright_screenshot",
"Bash(npm run dev:*)",
"mcp__gitea__gitea_repo_create",
"mcp__gitea__gitea_user_current",
"mcp__gitea__gitea_repo_list",
"Bash(env)",
"Bash(npm config:*)",
"Bash(ls:*)",
"Bash(python3:*)",
"mcp__gitea__gitea_context_set",
"mcp__gitea__gitea_org_create",
"mcp__gitea__gitea_team_list",
"mcp__gitea__gitea_context_get",
"mcp__gitea__gitea_init",
"Bash(git commit:*)",
"Bash(git push)",
"Bash(git remote:*)",
"Bash(git branch:*)",
"Bash(git push:*)",
"Bash(git init:*)",
"Bash(git add:*)",
"Bash(git fetch:*)",
"Bash(.specify/scripts/bash/create-new-feature.sh:*)",
"Bash(.specify/scripts/bash/check-prerequisites.sh:*)",
"Bash(.specify/scripts/bash/setup-plan.sh:*)",
"Bash(.specify/scripts/bash/update-agent-context.sh:*)",
"Bash(npm run lint:*)",
"mcp__gitea__gitea_collaborator_list",
"mcp__gitea__gitea_collaborator_add",
"mcp__gitea__gitea_collaborator_permission",
"Bash(git config:*)"
]
},
"enabledMcpjsonServers": [
"youtube",
"shadcn"
]
}

11
frontend/.mcp.json Normal file
View File

@@ -0,0 +1,11 @@
{
"mcpServers": {
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
}
}
}

22
frontend/components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -1,10 +1,10 @@
<!doctype html>
<html lang="en">
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>PrevThumb</title>
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -11,11 +11,20 @@
},
"dependencies": {
"@nestjs/axios": "^4.0.1",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@tanstack/react-query": "^5.90.20",
"axios": "^1.13.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-router-dom": "^7.13.0",
"tailwind-merge": "^3.4.0",
"uuid": "^13.0.0",
"zustand": "^5.0.10"
},
@@ -31,7 +40,9 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"shadcn": "^3.7.0",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"

View File

@@ -0,0 +1,17 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="gradient1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8B5CF6"/>
<stop offset="100%" style="stop-color:#EC4899"/>
</linearGradient>
</defs>
<!-- Outer thumbnail frame -->
<rect x="2" y="6" width="36" height="28" rx="4" fill="url(#gradient1)"/>
<!-- Inner dark area (screen) -->
<rect x="5" y="9" width="30" height="22" rx="2" fill="#1a1a2e"/>
<!-- Play triangle -->
<path d="M16 14L26 20L16 26V14Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 613 B

View File

@@ -0,0 +1,17 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="gradient1b" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8B5CF6"/>
<stop offset="100%" style="stop-color:#EC4899"/>
</linearGradient>
</defs>
<!-- Stylized thumbnail frame with notch -->
<path d="M4 10C4 7.79086 5.79086 6 8 6H32C34.2091 6 36 7.79086 36 10V30C36 32.2091 34.2091 34 32 34H8C5.79086 34 4 32.2091 4 30V10Z" fill="url(#gradient1b)"/>
<!-- Play button circle -->
<circle cx="20" cy="20" r="10" fill="rgba(0,0,0,0.3)"/>
<!-- Play triangle -->
<path d="M17 14L27 20L17 26V14Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 699 B

View File

@@ -0,0 +1,18 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="gradient1c" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8B5CF6"/>
<stop offset="50%" style="stop-color:#A855F7"/>
<stop offset="100%" style="stop-color:#EC4899"/>
</linearGradient>
</defs>
<!-- Rounded square base -->
<rect x="2" y="2" width="36" height="36" rx="8" fill="url(#gradient1c)"/>
<!-- Thumbnail frame outline -->
<rect x="7" y="10" width="26" height="20" rx="3" stroke="white" stroke-width="2" fill="none"/>
<!-- Play triangle -->
<path d="M17 15L26 20L17 25V15Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 696 B

View File

@@ -0,0 +1,17 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="gradient5" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8B5CF6"/>
<stop offset="100%" style="stop-color:#EC4899"/>
</linearGradient>
</defs>
<!-- Background -->
<rect x="2" y="2" width="36" height="36" rx="8" fill="url(#gradient5)"/>
<!-- Thumbnail frame -->
<rect x="6" y="9" width="22" height="16" rx="2" stroke="white" stroke-width="2" fill="rgba(255,255,255,0.1)"/>
<!-- Cursor pointer -->
<path d="M26 22L26 34L30 30L33 36L35 35L32 29L37 28L26 22Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 667 B

View File

@@ -0,0 +1,19 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="gradient5b" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8B5CF6"/>
<stop offset="100%" style="stop-color:#EC4899"/>
</linearGradient>
</defs>
<!-- Background -->
<rect x="2" y="2" width="36" height="36" rx="8" fill="url(#gradient5b)"/>
<!-- Thumbnail with play icon inside -->
<rect x="6" y="10" width="20" height="14" rx="2" fill="rgba(0,0,0,0.3)"/>
<path d="M14 14L20 17L14 20V14Z" fill="white"/>
<!-- Click target circle -->
<circle cx="28" cy="28" r="8" stroke="white" stroke-width="2" fill="none"/>
<circle cx="28" cy="28" r="3" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 751 B

View File

@@ -0,0 +1,17 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="gradient5c" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8B5CF6"/>
<stop offset="100%" style="stop-color:#EC4899"/>
</linearGradient>
</defs>
<!-- Background -->
<rect x="2" y="2" width="36" height="36" rx="8" fill="url(#gradient5c)"/>
<!-- Thumbnail frame -->
<rect x="8" y="11" width="24" height="18" rx="2" stroke="white" stroke-width="2.5" fill="none"/>
<!-- Cursor arrow -->
<path d="M22 18V30L25.5 26.5L28.5 32L31 30.5L28 25L32.5 24L22 18Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 660 B

View File

@@ -0,0 +1,19 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad-v2-1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8B5CF6"/>
<stop offset="100%" style="stop-color:#EC4899"/>
</linearGradient>
</defs>
<!-- Rounded square background -->
<rect x="2" y="2" width="36" height="36" rx="10" fill="url(#grad-v2-1)"/>
<!-- Letter P with integrated play button -->
<!-- P stem -->
<path d="M12 8H16V32H12V8Z" fill="white"/>
<!-- P bowl that becomes play button -->
<path d="M16 8H22C26.4183 8 30 11.5817 30 16C30 20.4183 26.4183 24 22 24H16V8Z" fill="white"/>
<!-- Play triangle cutout in the bowl -->
<path d="M20 12L26 16L20 20V12Z" fill="url(#grad-v2-1)"/>
</svg>

After

Width:  |  Height:  |  Size: 794 B

View File

@@ -0,0 +1,19 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad-v2-2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8B5CF6"/>
<stop offset="100%" style="stop-color:#EC4899"/>
</linearGradient>
</defs>
<!-- Rounded square background -->
<rect x="2" y="2" width="36" height="36" rx="10" fill="url(#grad-v2-2)"/>
<!-- Stylized P made from play button shape -->
<!-- The P is formed by a play triangle + vertical bar -->
<rect x="10" y="7" width="5" height="26" rx="2" fill="white"/>
<path d="M15 7L32 18L15 29V7Z" fill="white" fill-opacity="0.9"/>
<!-- Inner detail - small accent -->
<circle cx="28" cy="18" r="3" fill="url(#grad-v2-2)" fill-opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 798 B

View File

@@ -0,0 +1,23 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad-v2-3" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#7C3AED"/>
<stop offset="50%" style="stop-color:#A855F7"/>
<stop offset="100%" style="stop-color:#EC4899"/>
</linearGradient>
<linearGradient id="grad-v2-3b" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#EC4899"/>
<stop offset="100%" style="stop-color:#8B5CF6"/>
</linearGradient>
</defs>
<!-- Abstract P shape - modern geometric -->
<!-- Main P body -->
<path d="M6 6C6 3.79086 7.79086 2 10 2H24C31.732 2 38 8.26801 38 16C38 23.732 31.732 30 24 30H14V38H6V6Z" fill="url(#grad-v2-3)"/>
<!-- Inner cutout to form P -->
<path d="M14 10H24C27.3137 10 30 12.6863 30 16C30 19.3137 27.3137 22 24 22H14V10Z" fill="#0a0a0f"/>
<!-- Play triangle inside -->
<path d="M18 13L26 16L18 19V13Z" fill="url(#grad-v2-3b)"/>
</svg>

After

Width:  |  Height:  |  Size: 1016 B

View File

@@ -0,0 +1,18 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad-v2-4" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8B5CF6"/>
<stop offset="100%" style="stop-color:#EC4899"/>
</linearGradient>
</defs>
<!-- Rounded square background -->
<rect x="2" y="2" width="36" height="36" rx="10" fill="url(#grad-v2-4)"/>
<!-- Thumbnail frame -->
<rect x="7" y="10" width="26" height="20" rx="3" stroke="white" stroke-width="3" fill="none"/>
<!-- "P" formed by play button + line -->
<line x1="14" y1="14" x2="14" y2="26" stroke="white" stroke-width="3" stroke-linecap="round"/>
<path d="M14 14L26 20L14 20" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 829 B

View File

@@ -0,0 +1,25 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad-v2-5-light" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8B5CF6"/>
<stop offset="100%" style="stop-color:#EC4899"/>
</linearGradient>
<linearGradient id="grad-v2-5-light-mid" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#C4B5FD"/>
<stop offset="100%" style="stop-color:#FBCFE8"/>
</linearGradient>
</defs>
<!-- Inverted 3D-style layered play buttons for light backgrounds -->
<!-- Back layer - lightest -->
<path d="M8 8L32 20L8 32V8Z" fill="#E9E3FF" opacity="0.7"/>
<!-- Middle layer - medium -->
<path d="M6 10L30 22L6 34V10Z" fill="url(#grad-v2-5-light-mid)" opacity="0.8"/>
<!-- Front layer - gradient (instead of white) -->
<path d="M4 12L28 24L4 36V12Z" fill="url(#grad-v2-5-light)"/>
<!-- Small accent -->
<circle cx="32" cy="8" r="4" fill="url(#grad-v2-5-light)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,25 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad-v2-5" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8B5CF6"/>
<stop offset="100%" style="stop-color:#EC4899"/>
</linearGradient>
<linearGradient id="grad-v2-5-dark" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6D28D9"/>
<stop offset="100%" style="stop-color:#BE185D"/>
</linearGradient>
</defs>
<!-- 3D-style layered play buttons forming abstract shape -->
<!-- Back layer -->
<path d="M8 8L32 20L8 32V8Z" fill="url(#grad-v2-5-dark)" opacity="0.5"/>
<!-- Middle layer - offset -->
<path d="M6 10L30 22L6 34V10Z" fill="url(#grad-v2-5)" opacity="0.7"/>
<!-- Front layer -->
<path d="M4 12L28 24L4 36V12Z" fill="white"/>
<!-- Small accent -->
<circle cx="32" cy="8" r="4" fill="url(#grad-v2-5)"/>
</svg>

After

Width:  |  Height:  |  Size: 952 B

View File

@@ -0,0 +1,25 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad-v2-5a" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8B5CF6"/>
<stop offset="100%" style="stop-color:#EC4899"/>
</linearGradient>
<linearGradient id="grad-v2-5a-light" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#A78BFA"/>
<stop offset="100%" style="stop-color:#F472B6"/>
</linearGradient>
</defs>
<!-- Rounded square background -->
<rect x="2" y="2" width="36" height="36" rx="10" fill="url(#grad-v2-5a)"/>
<!-- 3D-style layered play buttons -->
<!-- Back layer - darkest -->
<path d="M12 10L30 20L12 30V10Z" fill="rgba(0,0,0,0.3)"/>
<!-- Middle layer -->
<path d="M10 12L28 22L10 32V12Z" fill="rgba(255,255,255,0.4)"/>
<!-- Front layer - white -->
<path d="M8 14L26 24L8 34V14Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 954 B

View File

@@ -0,0 +1,26 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad-v2-5b" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8B5CF6"/>
<stop offset="100%" style="stop-color:#EC4899"/>
</linearGradient>
<linearGradient id="grad-v2-5b-mid" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#A78BFA"/>
<stop offset="100%" style="stop-color:#F472B6"/>
</linearGradient>
<linearGradient id="grad-v2-5b-light" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#C4B5FD"/>
<stop offset="100%" style="stop-color:#FBCFE8"/>
</linearGradient>
</defs>
<!-- 3D-style layered play buttons - all gradient -->
<!-- Back layer - darkest gradient -->
<path d="M10 6L38 20L10 34V6Z" fill="url(#grad-v2-5b)"/>
<!-- Middle layer - medium gradient -->
<path d="M6 9L34 23L6 37V9Z" fill="url(#grad-v2-5b-mid)"/>
<!-- Front layer - lightest gradient -->
<path d="M2 12L30 26L2 40V12Z" fill="url(#grad-v2-5b-light)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,25 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad-v2-5c" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8B5CF6"/>
<stop offset="100%" style="stop-color:#EC4899"/>
</linearGradient>
<linearGradient id="grad-v2-5c-dark" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6D28D9"/>
<stop offset="100%" style="stop-color:#BE185D"/>
</linearGradient>
</defs>
<!-- Rounded square background -->
<rect x="2" y="2" width="36" height="36" rx="10" fill="#0f0f1a"/>
<!-- 3D-style layered play buttons -->
<!-- Back layer -->
<path d="M14 9L34 20L14 31V9Z" fill="url(#grad-v2-5c-dark)"/>
<!-- Middle layer -->
<path d="M11 11L31 22L11 33V11Z" fill="url(#grad-v2-5c)"/>
<!-- Front layer - white -->
<path d="M8 13L28 24L8 35V13Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 933 B

View File

@@ -0,0 +1,25 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad-v2-5d" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8B5CF6"/>
<stop offset="100%" style="stop-color:#EC4899"/>
</linearGradient>
<linearGradient id="grad-v2-5d-dark" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#5B21B6"/>
<stop offset="100%" style="stop-color:#9D174D"/>
</linearGradient>
</defs>
<!-- Rounded square background with gradient -->
<rect x="2" y="2" width="36" height="36" rx="10" fill="url(#grad-v2-5d)"/>
<!-- 3D-style layered play buttons -->
<!-- Shadow/depth layer -->
<path d="M14 10L32 20L14 30V10Z" fill="url(#grad-v2-5d-dark)"/>
<!-- Middle layer - semi-transparent -->
<path d="M11 12L29 22L11 32V12Z" fill="white" fill-opacity="0.5"/>
<!-- Front layer - white -->
<path d="M8 14L26 24L8 34V14Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 993 B

View File

@@ -0,0 +1,21 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad-v2-6" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8B5CF6"/>
<stop offset="100%" style="stop-color:#EC4899"/>
</linearGradient>
</defs>
<!-- Rounded square background -->
<rect x="2" y="2" width="36" height="36" rx="10" fill="url(#grad-v2-6)"/>
<!-- PT monogram - P and T combined -->
<!-- T horizontal -->
<rect x="8" y="10" width="24" height="5" rx="2" fill="white"/>
<!-- T vertical / P stem -->
<rect x="11" y="10" width="6" height="22" rx="2" fill="white"/>
<!-- P bowl -->
<path d="M17 10H24C28 10 31 13 31 17C31 21 28 24 24 24H17V10Z" fill="white"/>
<!-- Cutout for P -->
<ellipse cx="23" cy="17" rx="5" ry="4" fill="url(#grad-v2-6)"/>
</svg>

After

Width:  |  Height:  |  Size: 855 B

View File

@@ -15,8 +15,8 @@ export const PreviewGrid = () => {
if (!activeThumbnail || youtubeResults.length === 0) {
return (
<div className="bg-gray-900 rounded-xl p-8 text-center">
<p className="text-gray-500">
<div className="yt-surface rounded-xl border border-yt-border p-12 text-center">
<p className="yt-meta text-sm">
{!activeThumbnail
? 'Upload a thumbnail to see preview'
: 'Search for competitors to see preview'
@@ -26,7 +26,6 @@ export const PreviewGrid = () => {
);
}
// Insert user thumbnail at position 0 (top of results)
const combinedResults = [
{
videoId: 'user',
@@ -40,45 +39,58 @@ export const PreviewGrid = () => {
...youtubeResults.map(v => ({ ...v, isUser: false })),
];
// Mobile view - YouTube mobile app style
if (viewMode === 'mobile') {
return (
<div className="bg-gray-900 rounded-xl p-4 max-w-sm mx-auto">
<div className="yt-surface max-w-[400px] mx-auto rounded-xl overflow-hidden border border-yt-border">
{/* Mobile header */}
<div className="flex items-center justify-between mb-4 pb-3 border-b border-gray-800">
<svg className="w-24 h-6 text-white" viewBox="0 0 90 20" fill="currentColor">
<text x="0" y="16" className="text-lg font-bold">YouTube</text>
<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">
<g className="fill-yt-title">
<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"/>
</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>
<div className="flex gap-4">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
<div className="flex items-center gap-4">
<svg className="w-5 h-5 yt-icon" viewBox="0 0 24 24" fill="currentColor">
<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"/>
</svg>
<svg className="w-5 h-5 yt-icon" viewBox="0 0 24 24" 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"/>
</svg>
</div>
</div>
<div className="space-y-4">
{/* Mobile content */}
<div className="divide-y divide-yt-border">
{combinedResults.slice(0, 6).map((video) => (
<div key={video.videoId} className="p-3">
<YouTubeVideoCard
key={video.videoId}
thumbnailUrl={video.thumbnailUrl}
title={video.title}
channelTitle={video.channelTitle}
viewCount={video.viewCount}
publishedAt={video.publishedAt}
isUserThumbnail={video.isUser}
variant="search"
variant="mobile"
/>
</div>
))}
</div>
</div>
);
}
// Sidebar view - YouTube "Up next" style
if (viewMode === 'sidebar') {
return (
<div className="bg-gray-900 rounded-xl p-4">
<h3 className="text-white text-sm font-medium mb-4">Up next</h3>
<div className="space-y-3">
{combinedResults.slice(0, 8).map((video) => (
<div className="yt-surface max-w-[400px] rounded-xl border border-yt-border overflow-hidden">
<div className="px-4 py-3 border-b border-yt-border">
<h2 className="yt-title font-medium">Up next</h2>
</div>
<div className="p-2 space-y-2">
{combinedResults.slice(0, 10).map((video) => (
<YouTubeVideoCard
key={video.videoId}
thumbnailUrl={video.thumbnailUrl}
@@ -95,33 +107,78 @@ export const PreviewGrid = () => {
);
}
// Default: Search view
// Search view - YouTube search results page style (default)
return (
<div className="bg-gray-900 rounded-xl p-4">
{/* YouTube-like header */}
<div className="flex items-center gap-4 mb-6 pb-4 border-b border-gray-800">
<svg className="w-8 h-8 text-red-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
<div className="yt-surface rounded-xl border border-yt-border overflow-hidden">
{/* YouTube header */}
<div className="flex items-center gap-4 px-4 py-3 border-b border-yt-border">
<svg className="w-[90px] h-5 flex-shrink-0" viewBox="0 0 90 20" fill="none">
<g className="fill-yt-title">
<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"/>
</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>
<div className="flex-1 max-w-2xl">
<div className="flex bg-gray-800 rounded-full overflow-hidden">
<div className="flex">
<input
type="text"
value={usePreviewStore.getState().searchQuery}
readOnly
className="flex-1 bg-transparent px-4 py-2 text-white outline-none"
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"
placeholder="Search"
/>
<button className="px-6 bg-gray-700">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
<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">
<svg className="w-5 h-5 yt-icon" viewBox="0 0 24 24" 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"/>
</svg>
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button className="w-10 h-10 rounded-full hover:bg-yt-hover flex items-center justify-center transition-colors">
<svg className="w-6 h-6 yt-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 13h-3v3H9v-3H6v-2h3V8h2v3h3v2zm3-7H3v12h14v-6.39l4 1.83V8.56l-4 1.83V6m1-1v3.83L22 7v8l-4-1.83V19H2V5h16z"/>
</svg>
</button>
<button className="w-10 h-10 rounded-full hover:bg-yt-hover flex items-center justify-center transition-colors">
<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"/>
</svg>
</button>
<div className="w-8 h-8 rounded-full bg-purple-600 flex items-center justify-center text-white text-sm font-medium">
U
</div>
</div>
</div>
{/* Results grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{/* Filter chips */}
<div className="flex gap-3 px-4 py-3 border-b border-yt-border overflow-x-auto">
<button className="px-3 py-1.5 bg-yt-title text-yt-bg rounded-lg text-sm font-medium whitespace-nowrap">
All
</button>
<button className="px-3 py-1.5 bg-yt-chip-bg text-yt-title rounded-lg text-sm font-medium whitespace-nowrap hover:bg-yt-hover transition-colors">
Videos
</button>
<button className="px-3 py-1.5 bg-yt-chip-bg text-yt-title rounded-lg text-sm font-medium whitespace-nowrap hover:bg-yt-hover transition-colors">
Channels
</button>
<button className="px-3 py-1.5 bg-yt-chip-bg text-yt-title rounded-lg text-sm font-medium whitespace-nowrap hover:bg-yt-hover transition-colors">
Playlists
</button>
<button className="px-3 py-1.5 bg-yt-chip-bg text-yt-title rounded-lg text-sm font-medium whitespace-nowrap hover:bg-yt-hover transition-colors">
Recently uploaded
</button>
<button className="px-3 py-1.5 bg-yt-chip-bg text-yt-title rounded-lg text-sm font-medium whitespace-nowrap hover:bg-yt-hover transition-colors">
Watched
</button>
</div>
{/* Video grid */}
<div className="p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{combinedResults.map((video) => (
<YouTubeVideoCard
key={video.videoId}
@@ -136,5 +193,6 @@ export const PreviewGrid = () => {
))}
</div>
</div>
</div>
);
};

View File

@@ -1,83 +1,63 @@
import { useState, type FormEvent } from 'react';
import { Search, ArrowRight } from 'lucide-react';
import { usePreviewStore } from '../store/previewStore';
import { useYouTubeSearch } from '../hooks/useYouTubeSearch';
import { searchYouTube } from '../api/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
export const SearchInput = () => {
const [inputValue, setInputValue] = useState('');
const { searchQuery, setSearchQuery, setYoutubeResults } = usePreviewStore();
const { isLoading, refetch } = useYouTubeSearch(searchQuery, !!searchQuery);
const [isLoading, setIsLoading] = useState(false);
const { setSearchQuery, setYoutubeResults } = usePreviewStore();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!inputValue.trim()) return;
if (!inputValue.trim() || isLoading) return;
setSearchQuery(inputValue.trim());
const result = await refetch();
if (result.data) {
setYoutubeResults(result.data);
const query = inputValue.trim();
setIsLoading(true);
try {
setSearchQuery(query);
const results = await searchYouTube(query);
setYoutubeResults(results);
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="flex gap-3">
<div className="relative flex-1">
<svg
className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
<div className="relative flex-1 group">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 size-5 text-muted-foreground transition-colors group-focus-within:text-foreground" />
<Input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Enter search query (e.g., react tutorial)"
className="w-full pl-12 pr-4 py-3 bg-gray-800 border border-gray-700 rounded-lg
text-white placeholder-gray-500
focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500
transition-colors"
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"
/>
</div>
<button
<Button
type="submit"
disabled={isLoading || !inputValue.trim()}
className="px-6 py-3 bg-red-600 hover:bg-red-700 disabled:bg-gray-700
disabled:cursor-not-allowed text-white font-medium rounded-lg
transition-colors flex items-center gap-2"
size="lg"
className="h-12 px-6 gap-2"
>
{isLoading ? (
<>
<svg className="animate-spin w-5 h-5" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Searching...
<Spinner className="size-4" />
<span>Searching</span>
</>
) : (
'Search'
<>
<span>Search</span>
<ArrowRight className="size-4" />
</>
)}
</button>
</Button>
</form>
);
};

View File

@@ -1,4 +1,8 @@
import { X, Plus } from 'lucide-react';
import { usePreviewStore } from '../store/previewStore';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export const ThumbnailSelector = () => {
const { thumbnails, activeThumbnailIndex, setActiveThumbnailIndex, removeThumbnail } =
@@ -7,77 +11,63 @@ export const ThumbnailSelector = () => {
if (thumbnails.length === 0) return null;
return (
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-400">Your Thumbnails</h3>
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">Your Thumbnails</h3>
<div className="flex gap-3 flex-wrap">
<div className="flex gap-4 flex-wrap">
{thumbnails.map((thumbnail, index) => (
<div
key={thumbnail.id}
className={`
relative group cursor-pointer rounded-lg overflow-hidden
border-2 transition-all
${index === activeThumbnailIndex
? 'border-red-500 ring-2 ring-red-500/30'
: 'border-transparent hover:border-gray-600'
}
`}
className={cn(
'relative group cursor-pointer rounded-xl overflow-hidden transition-all duration-200',
index === activeThumbnailIndex
? 'ring-2 ring-primary glow-sm scale-[1.02]'
: 'ring-1 ring-border hover:ring-muted-foreground'
)}
onClick={() => setActiveThumbnailIndex(index)}
>
<img
src={thumbnail.url}
alt={thumbnail.originalName}
className="w-32 h-18 object-cover"
className="w-36 h-20 object-cover"
/>
{/* Index badge */}
<div className="absolute top-1 left-1 w-6 h-6 rounded bg-black/70
flex items-center justify-center text-xs font-bold text-white">
<Badge
variant="secondary"
className="absolute top-1.5 left-1.5 size-6 p-0 justify-center rounded-md bg-black/70 text-white border-0 text-xs font-semibold"
>
{String.fromCharCode(65 + index)}
</div>
</Badge>
{/* Delete button */}
<button
<Button
variant="ghost"
size="icon-xs"
onClick={(e) => {
e.stopPropagation();
removeThumbnail(thumbnail.id);
}}
className="absolute top-1 right-1 w-6 h-6 rounded bg-black/70
flex items-center justify-center text-white
opacity-0 group-hover:opacity-100 transition-opacity
hover:bg-red-600"
className="absolute top-1.5 right-1.5 bg-black/70 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<X className="size-3" />
</Button>
{/* Active indicator */}
{index === activeThumbnailIndex && (
<div className="absolute bottom-0 left-0 right-0 bg-red-500 text-white
text-xs text-center py-0.5 font-medium">
<div className="absolute bottom-0 left-0 right-0 bg-gradient-accent text-white text-xs text-center py-1 font-medium">
Active
</div>
)}
</div>
))}
{/* Add more button */}
{thumbnails.length < 5 && (
<label className="w-32 h-18 border-2 border-dashed border-gray-700 rounded-lg
flex items-center justify-center cursor-pointer
hover:border-gray-600 transition-colors">
<label className="w-36 h-20 border border-dashed border-border rounded-xl flex items-center justify-center cursor-pointer hover:border-muted-foreground hover:bg-surface-2 transition-all">
<input
type="file"
className="hidden"
accept="image/jpeg,image/png,image/webp"
onChange={() => {
// Handle via parent component or hook
}}
onChange={() => {}}
/>
<svg className="w-8 h-8 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<Plus className="size-6 text-muted-foreground" />
</label>
)}
</div>

View File

@@ -1,6 +1,10 @@
import { useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import { ImageIcon, Upload } from 'lucide-react';
import { useUploadThumbnail } from '../hooks/useThumbnails';
import { Card } from '@/components/ui/card';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
export const ThumbnailUploader = () => {
const { mutate: upload, isPending } = useUploadThumbnail();
@@ -21,58 +25,58 @@ export const ThumbnailUploader = () => {
'image/png': ['.png'],
'image/webp': ['.webp'],
},
maxSize: 5 * 1024 * 1024, // 5MB
maxSize: 5 * 1024 * 1024,
multiple: true,
});
return (
<div
<Card
{...getRootProps()}
className={`
border-2 border-dashed rounded-xl p-8 text-center cursor-pointer
transition-all duration-200
${isDragActive
? 'border-red-500 bg-red-500/10'
: 'border-gray-600 hover:border-gray-500 bg-gray-800/50'
}
${isPending ? 'opacity-50 pointer-events-none' : ''}
`}
className={cn(
'border border-dashed rounded-xl p-10 text-center cursor-pointer transition-all duration-300',
'bg-surface-1 hover:bg-surface-2',
isDragActive
? 'border-primary bg-primary/5 glow-sm'
: 'border-border hover:border-muted-foreground',
isPending && 'opacity-50 pointer-events-none'
)}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 rounded-full bg-gray-700 flex items-center justify-center">
<svg
className="w-8 h-8 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<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'
)}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
{isPending ? (
<Spinner className="size-7" />
) : isDragActive ? (
<Upload className="size-7 text-primary" />
) : (
<ImageIcon className="size-7 text-muted-foreground" />
)}
</div>
{isPending ? (
<p className="text-gray-400">Uploading...</p>
<p className="text-muted-foreground">Uploading...</p>
) : isDragActive ? (
<p className="text-red-400 font-medium">Drop your thumbnail here</p>
<p className="text-primary font-medium">Drop your thumbnail here</p>
) : (
<>
<p className="text-gray-300 font-medium">
Drag & drop your thumbnail here
<div className="space-y-2">
<p className="text-foreground font-medium">
Drag & drop your thumbnail
</p>
<p className="text-gray-500 text-sm">or click to browse</p>
<p className="text-gray-600 text-xs">
Recommended: 1280x720 (16:9) JPG, PNG, WebP Max 5MB
<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>
);
};

View File

@@ -1,37 +1,37 @@
import { usePreviewStore } from '../store/previewStore';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export const UserInfoInputs = () => {
const { userTitle, userChannel, setUserTitle, setUserChannel } = usePreviewStore();
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-2">
<Label htmlFor="video-title" className="text-muted-foreground text-sm">
Video Title
</label>
<input
</Label>
<Input
id="video-title"
type="text"
value={userTitle}
onChange={(e) => setUserTitle(e.target.value)}
placeholder="Your Video Title Here"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg
text-white placeholder-gray-500
focus:outline-none focus:border-red-500"
className="bg-surface-1 border-border focus:border-primary transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
<div className="space-y-2">
<Label htmlFor="channel-name" className="text-muted-foreground text-sm">
Channel Name
</label>
<input
</Label>
<Input
id="channel-name"
type="text"
value={userChannel}
onChange={(e) => setUserChannel(e.target.value)}
placeholder="Your Channel"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg
text-white placeholder-gray-500
focus:outline-none focus:border-red-500"
className="bg-surface-1 border-border focus:border-primary transition-colors"
/>
</div>
</div>

View File

@@ -1,37 +1,23 @@
import type { ReactNode } from 'react';
import { Search, Menu, Smartphone } from 'lucide-react';
import { usePreviewStore } from '../store/previewStore';
import type { ViewMode } from '../types';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
const views: { id: ViewMode; label: string; icon: ReactNode }[] = [
const views: { id: ViewMode; label: string; icon: React.ReactNode }[] = [
{
id: 'search',
label: 'Search',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
),
icon: <Search className="size-4" />,
},
{
id: 'sidebar',
label: 'Sidebar',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 6h16M4 12h16M4 18h7" />
</svg>
),
icon: <Menu className="size-4" />,
},
{
id: 'mobile',
label: 'Mobile',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
),
icon: <Smartphone className="size-4" />,
},
];
@@ -39,24 +25,27 @@ export const ViewSwitcher = () => {
const { viewMode, setViewMode } = usePreviewStore();
return (
<div className="inline-flex bg-gray-800 rounded-lg p-1">
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">View:</span>
<ToggleGroup
type="single"
value={viewMode}
onValueChange={(value) => value && setViewMode(value as ViewMode)}
variant="outline"
className="bg-surface-1 p-1 rounded-lg border border-border"
>
{views.map((view) => (
<button
<ToggleGroupItem
key={view.id}
onClick={() => setViewMode(view.id)}
className={`
flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium
transition-colors
${viewMode === view.id
? 'bg-gray-700 text-white'
: 'text-gray-400 hover:text-white'
}
`}
value={view.id}
aria-label={view.label}
className="data-[state=on]:bg-surface-3 data-[state=on]:text-foreground rounded-md transition-colors"
>
{view.icon}
{view.label}
</button>
<span className="ml-2">{view.label}</span>
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
);
};

View File

@@ -1,3 +1,5 @@
import { cn } from '@/lib/utils';
interface YouTubeVideoCardProps {
thumbnailUrl: string;
title: string;
@@ -5,7 +7,8 @@ interface YouTubeVideoCardProps {
viewCount?: string;
publishedAt?: string;
isUserThumbnail?: boolean;
variant?: 'search' | 'sidebar';
variant?: 'search' | 'sidebar' | 'mobile';
duration?: string;
}
export const YouTubeVideoCard = ({
@@ -14,14 +17,20 @@ export const YouTubeVideoCard = ({
channelTitle,
viewCount,
publishedAt,
isUserThumbnail = false,
variant = 'search',
duration = '10:30',
}: YouTubeVideoCardProps) => {
const formatViews = (views?: string) => {
if (!views) return '';
if (!views) return '0 views';
const num = parseInt(views);
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M views`;
if (num >= 1000) return `${(num / 1000).toFixed(0)}K 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`;
};
@@ -30,72 +39,105 @@ export const YouTubeVideoCard = ({
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`;
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
if (days < 365) return `${Math.floor(days / 30)} months ago`;
return `${Math.floor(days / 365)} years 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)
if (variant === 'sidebar') {
return (
<div className={`flex gap-2 group cursor-pointer ${isUserThumbnail ? 'ring-2 ring-red-500 rounded-lg p-1' : ''}`}>
<div className="relative flex-shrink-0">
<div className="yt-card flex gap-2 cursor-pointer group">
<div className="relative flex-shrink-0 w-[168px] h-[94px] rounded-lg overflow-hidden bg-yt-hover">
<img
src={thumbnailUrl}
alt={title}
className="w-40 h-[90px] object-cover rounded-lg"
className="w-full h-full object-cover"
/>
{isUserThumbnail && (
<div className="absolute top-1 left-1 bg-red-500 text-white text-xs px-1.5 py-0.5 rounded font-bold">
YOUR
<span className="absolute bottom-1 right-1 bg-black/80 text-white text-xs font-medium px-1 py-0.5 rounded">
{duration}
</span>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-white line-clamp-2 group-hover:text-blue-400">
<div className="flex-1 min-w-0 pr-6">
<h3 className="yt-title text-sm font-medium line-clamp-2 mb-1">
{title}
</h4>
<p className="text-xs text-gray-400 mt-1">{channelTitle}</p>
<p className="text-xs text-gray-500">
{formatViews(viewCount)} {formatDate(publishedAt)}
</h3>
<p className="yt-meta text-xs">
{channelTitle}
</p>
<p className="yt-meta text-xs">
{formatViews(viewCount)} · {formatDate(publishedAt)}
</p>
</div>
</div>
);
}
// Mobile variant
if (variant === 'mobile') {
return (
<div className={`group cursor-pointer ${isUserThumbnail ? 'ring-2 ring-red-500 rounded-xl p-2' : ''}`}>
<div className="relative">
<div className="yt-card cursor-pointer">
<div className="relative w-full aspect-video bg-yt-hover rounded-xl overflow-hidden">
<img
src={thumbnailUrl}
alt={title}
className="w-full aspect-video object-cover rounded-xl"
className="w-full h-full object-cover"
/>
{isUserThumbnail && (
<div className="absolute top-2 left-2 bg-red-500 text-white text-xs px-2 py-1 rounded font-bold">
YOUR THUMBNAIL
<span className="absolute bottom-2 right-2 bg-black/80 text-white text-xs font-medium px-1 py-0.5 rounded">
{duration}
</span>
</div>
)}
{/* Duration placeholder */}
<div className="absolute bottom-2 right-2 bg-black/80 text-white text-xs px-1.5 py-0.5 rounded">
10:30
<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">
<div className="w-full h-full bg-gradient-to-br from-purple-500 to-pink-500" />
</div>
</div>
<div className="flex gap-3 mt-3">
{/* Channel avatar */}
<div className="w-9 h-9 rounded-full bg-gray-700 flex-shrink-0" />
<div className="flex-1 min-w-0">
<h3 className="text-white font-medium line-clamp-2 text-sm group-hover:text-blue-400">
<h3 className="yt-title text-sm font-medium line-clamp-2 leading-5">
{title}
</h3>
<p className="text-gray-400 text-sm mt-1 hover:text-gray-300">
<p className="yt-meta text-xs mt-1">
{channelTitle} · {formatViews(viewCount)} · {formatDate(publishedAt)}
</p>
</div>
</div>
</div>
);
}
// Search/Home variant (default)
return (
<div className="yt-card cursor-pointer group">
<div className="relative w-full aspect-video bg-yt-hover rounded-xl overflow-hidden">
<img
src={thumbnailUrl}
alt={title}
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}
</span>
</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">
<div className="w-full h-full bg-gradient-to-br from-purple-500 to-pink-500" />
</div>
<div className="flex-1 min-w-0">
<h3 className="yt-title text-sm font-medium line-clamp-2 leading-5 mb-1">
{title}
</h3>
<p className="yt-meta text-xs hover:text-yt-title transition-colors">
{channelTitle}
</p>
<p className="text-gray-400 text-sm">
{formatViews(viewCount)} {formatDate(publishedAt)}
<p className="yt-meta text-xs">
{formatViews(viewCount)} · {formatDate(publishedAt)}
</p>
</div>
</div>

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
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",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }

View File

@@ -0,0 +1,81 @@
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@@ -1,4 +1,21 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
/* ============================================================================
DESIGN SYSTEM: Linear + Vercel Inspired
============================================================================
Color Philosophy:
- Dark mode first (Linear style) with clean light mode (Vercel style)
- Subtle gradients and glow effects for premium feel
- High contrast text for readability
- Purple/violet primary with gradient accents
All colors defined here as single source of truth.
Components use only semantic tokens (--primary, --background, etc.)
============================================================================ */
body {
margin: 0;
@@ -8,3 +25,435 @@ body {
#root {
min-height: 100vh;
}
/* ============================================================================
TAILWIND THEME MAPPING
Maps CSS variables to Tailwind color utilities
============================================================================ */
@theme inline {
/* Radius tokens */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
/* Core semantic colors */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* Surface elevation levels (Linear style) */
--color-surface-1: var(--surface-1);
--color-surface-2: var(--surface-2);
--color-surface-3: var(--surface-3);
/* Glow effects */
--color-glow: var(--glow);
--color-glow-muted: var(--glow-muted);
/* Gradient accent colors (Vercel style) */
--color-gradient-start: var(--gradient-start);
--color-gradient-middle: var(--gradient-middle);
--color-gradient-end: var(--gradient-end);
/* Success/Warning states */
--color-success: var(--success);
--color-warning: var(--warning);
/* Chart colors */
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
/* Sidebar */
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
/* ============================================================================
LIGHT MODE (Vercel-inspired clean aesthetic)
============================================================================ */
:root {
--radius: 0.625rem;
/* Backgrounds - Clean whites */
--background: oklch(0.99 0 0);
--foreground: oklch(0.09 0 0);
/* Cards - Subtle elevation */
--card: oklch(1 0 0);
--card-foreground: oklch(0.09 0 0);
/* Popovers */
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.09 0 0);
/* Primary - Deep black (Vercel style CTA) */
--primary: oklch(0.15 0 0);
--primary-foreground: oklch(0.99 0 0);
/* Secondary - Light gray */
--secondary: oklch(0.96 0 0);
--secondary-foreground: oklch(0.15 0 0);
/* Muted - Subtle backgrounds */
--muted: oklch(0.96 0 0);
--muted-foreground: oklch(0.45 0 0);
/* Accent - Hover states */
--accent: oklch(0.94 0 0);
--accent-foreground: oklch(0.15 0 0);
/* Destructive */
--destructive: oklch(0.55 0.22 25);
/* Borders & Inputs */
--border: oklch(0.90 0 0);
--input: oklch(0.90 0 0);
--ring: oklch(0.15 0 0);
/* Surface levels (for elevated cards) */
--surface-1: oklch(0.98 0 0);
--surface-2: oklch(0.96 0 0);
--surface-3: oklch(0.94 0 0);
/* Glow (subtle in light mode) */
--glow: oklch(0.65 0.15 280);
--glow-muted: oklch(0.65 0.08 280);
/* Gradient accents (Vercel-style vibrant) */
--gradient-start: oklch(0.75 0.18 50); /* Orange */
--gradient-middle: oklch(0.70 0.20 330); /* Pink/Magenta */
--gradient-end: oklch(0.75 0.15 180); /* Cyan/Teal */
/* Status colors */
--success: oklch(0.65 0.17 145);
--warning: oklch(0.80 0.18 85);
/* Charts */
--chart-1: oklch(0.65 0.20 45);
--chart-2: oklch(0.60 0.12 185);
--chart-3: oklch(0.40 0.07 230);
--chart-4: oklch(0.80 0.19 85);
--chart-5: oklch(0.75 0.19 70);
/* Sidebar */
--sidebar: oklch(0.98 0 0);
--sidebar-foreground: oklch(0.09 0 0);
--sidebar-primary: oklch(0.15 0 0);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.94 0 0);
--sidebar-accent-foreground: oklch(0.15 0 0);
--sidebar-border: oklch(0.90 0 0);
--sidebar-ring: oklch(0.15 0 0);
}
/* ============================================================================
DARK MODE (Linear-inspired premium dark aesthetic)
============================================================================ */
.dark {
/* Backgrounds - Deep, rich blacks */
--background: oklch(0.085 0 0);
--foreground: oklch(0.95 0 0);
/* Cards - Elevated surfaces with subtle distinction */
--card: oklch(0.12 0 0);
--card-foreground: oklch(0.95 0 0);
/* Popovers - Slightly elevated */
--popover: oklch(0.14 0 0);
--popover-foreground: oklch(0.95 0 0);
/* Primary - Vibrant purple/violet (Linear style) */
--primary: oklch(0.65 0.20 280);
--primary-foreground: oklch(1 0 0);
/* Secondary - Subtle dark */
--secondary: oklch(0.18 0 0);
--secondary-foreground: oklch(0.90 0 0);
/* Muted - For subtle backgrounds */
--muted: oklch(0.16 0 0);
--muted-foreground: oklch(0.55 0 0);
/* Accent - Hover highlights */
--accent: oklch(0.20 0.02 280);
--accent-foreground: oklch(0.95 0 0);
/* Destructive */
--destructive: oklch(0.60 0.22 25);
/* Borders - Subtle, not harsh */
--border: oklch(0.22 0 0);
--input: oklch(0.16 0 0);
--ring: oklch(0.65 0.20 280);
/* Surface levels (Linear-style elevation) */
--surface-1: oklch(0.12 0 0);
--surface-2: oklch(0.16 0 0);
--surface-3: oklch(0.20 0 0);
/* Glow effects (signature Linear look) */
--glow: oklch(0.65 0.25 280);
--glow-muted: oklch(0.55 0.15 280);
/* Gradient accents */
--gradient-start: oklch(0.70 0.20 50); /* Orange */
--gradient-middle: oklch(0.65 0.25 310); /* Purple/Pink */
--gradient-end: oklch(0.70 0.18 195); /* Cyan */
/* Status colors */
--success: oklch(0.70 0.18 150);
--warning: oklch(0.80 0.18 85);
/* Charts - Vibrant on dark */
--chart-1: oklch(0.65 0.20 280);
--chart-2: oklch(0.70 0.17 165);
--chart-3: oklch(0.75 0.19 70);
--chart-4: oklch(0.65 0.25 310);
--chart-5: oklch(0.70 0.20 50);
/* Sidebar */
--sidebar: oklch(0.10 0 0);
--sidebar-foreground: oklch(0.95 0 0);
--sidebar-primary: oklch(0.65 0.20 280);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.18 0.02 280);
--sidebar-accent-foreground: oklch(0.95 0 0);
--sidebar-border: oklch(0.20 0 0);
--sidebar-ring: oklch(0.65 0.20 280);
}
/* ============================================================================
BASE STYLES
============================================================================ */
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground antialiased;
}
}
/* ============================================================================
YOUTUBE THEME VARIABLES
Pixel-perfect YouTube colors for both light and dark modes
============================================================================ */
/* Light mode YouTube colors */
: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 YouTube colors */
.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;
}
/* YouTube utility classes */
@layer utilities {
.yt-surface {
background-color: var(--yt-surface);
}
.yt-bg {
background-color: var(--yt-bg);
}
.yt-title {
color: var(--yt-title);
}
.yt-meta {
color: var(--yt-meta);
}
.yt-icon {
color: var(--yt-icon);
}
.yt-card {
color: var(--yt-title);
}
.bg-yt-hover {
background-color: var(--yt-hover);
}
.bg-yt-chip-bg {
background-color: var(--yt-chip-bg);
}
.bg-yt-title {
background-color: var(--yt-title);
}
.bg-yt-blue {
background-color: var(--yt-blue);
}
.text-yt-bg {
color: var(--yt-bg);
}
.text-yt-title {
color: var(--yt-title);
}
.border-yt-border {
border-color: var(--yt-border);
}
.border-yt-blue {
border-color: var(--yt-blue);
}
.divide-yt-border > :not([hidden]) ~ :not([hidden]) {
border-color: var(--yt-border);
}
.fill-yt-title {
fill: var(--yt-title);
}
.hover\:bg-yt-hover:hover {
background-color: var(--yt-hover);
}
.focus\:border-yt-blue:focus {
border-color: var(--yt-blue);
}
}
/* ============================================================================
UTILITY CLASSES
Reusable patterns for the Linear + Vercel aesthetic
============================================================================ */
@layer utilities {
/* Gradient text (Vercel hero style) */
.text-gradient {
@apply bg-clip-text text-transparent;
background-image: linear-gradient(
135deg,
var(--gradient-start),
var(--gradient-middle),
var(--gradient-end)
);
}
/* Gradient backgrounds */
.bg-gradient-accent {
background: linear-gradient(
135deg,
var(--gradient-start),
var(--gradient-middle),
var(--gradient-end)
);
}
/* Subtle gradient mesh (Vercel style) */
.bg-gradient-mesh {
background:
radial-gradient(at 27% 37%, var(--gradient-start) 0px, transparent 50%),
radial-gradient(at 97% 21%, var(--gradient-middle) 0px, transparent 50%),
radial-gradient(at 52% 99%, var(--gradient-end) 0px, transparent 50%),
radial-gradient(at 10% 29%, var(--gradient-middle) 0px, transparent 50%);
}
/* Glow effects (Linear style) */
.glow {
box-shadow: 0 0 40px -10px var(--glow);
}
.glow-sm {
box-shadow: 0 0 20px -5px var(--glow-muted);
}
.glow-lg {
box-shadow: 0 0 60px -15px var(--glow);
}
/* Border glow on focus/hover */
.glow-border {
box-shadow:
0 0 0 1px var(--border),
0 0 20px -10px var(--glow);
}
/* Surface elevations */
.surface-1 {
@apply bg-surface-1;
}
.surface-2 {
@apply bg-surface-2;
}
.surface-3 {
@apply bg-surface-3;
}
/* Noise texture overlay (premium feel) */
.noise {
position: relative;
}
.noise::before {
content: "";
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
opacity: 0.03;
pointer-events: none;
}
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,420 @@
import { Link } from 'react-router-dom';
import {
Play,
Eye,
Zap,
CheckCircle2,
ArrowRight,
MousePointerClick,
Search,
BarChart3,
Users,
Star,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import LogoDark from '../assets/logo-v2-5.svg';
const FEATURES = [
{
icon: Eye,
title: 'Real YouTube Context',
description: 'See exactly how your thumbnail appears in search results, suggested videos, and home feed.',
},
{
icon: Search,
title: 'Compare with Competitors',
description: 'Search any keyword and see your thumbnail side-by-side with top-ranking videos.',
},
{
icon: Zap,
title: 'Instant Preview',
description: 'Upload and preview in seconds. No account required. Test unlimited thumbnails.',
},
{
icon: BarChart3,
title: 'Multiple Views',
description: 'Preview in search results, suggested sidebar, home feed, and mobile layouts.',
},
];
const STEPS = [
{
number: '01',
title: 'Upload Your Thumbnail',
description: 'Drag and drop your thumbnail image. Supports JPG, PNG, and WebP formats.',
},
{
number: '02',
title: 'Search Competitors',
description: 'Enter your target keyword to find videos you\'ll be competing against.',
},
{
number: '03',
title: 'Compare & Optimize',
description: 'See your thumbnail in context and make data-driven decisions.',
},
];
const TESTIMONIALS = [
{
quote: "Finally, I can see how my thumbnails look before publishing. This tool is a game-changer for my workflow.",
author: "Sarah Chen",
role: "YouTube Creator, 250K subs",
avatar: "SC",
},
{
quote: "I increased my CTR by 40% just by previewing and tweaking my thumbnails before uploading.",
author: "Mike Rivera",
role: "Tech YouTuber, 1.2M subs",
avatar: "MR",
},
{
quote: "Simple, fast, and exactly what I needed. No more guessing how my thumbnail will perform.",
author: "Emma Taylor",
role: "Gaming Channel, 500K subs",
avatar: "ET",
},
];
const STATS = [
{ value: '50K+', label: 'Creators' },
{ value: '2M+', label: 'Thumbnails Previewed' },
{ value: '40%', label: 'Avg CTR Increase' },
{ value: '4.9', label: 'User Rating', icon: Star },
];
export function LandingPage() {
return (
<div className="min-h-screen bg-background text-foreground overflow-hidden">
{/* Header */}
<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">
<Link to="/" className="flex items-center gap-3">
<img src={LogoDark} alt="PrevThumb" className="size-10" />
<span className="text-xl font-semibold tracking-tight">PrevThumb</span>
</Link>
<nav className="hidden md:flex items-center gap-8">
<a href="#features" className="text-muted-foreground hover:text-foreground transition-colors text-sm">
Features
</a>
<a href="#how-it-works" className="text-muted-foreground hover:text-foreground transition-colors text-sm">
How it Works
</a>
<a href="#testimonials" className="text-muted-foreground hover:text-foreground transition-colors text-sm">
Testimonials
</a>
</nav>
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" className="hidden sm:inline-flex">
Sign In
</Button>
<Button size="sm" asChild>
<Link to="/tool">
Get Started Free
<ArrowRight className="size-4" />
</Link>
</Button>
</div>
</div>
</header>
<main>
{/* Hero Section */}
<section className="relative py-24 md:py-32 overflow-hidden">
{/* Background Gradient */}
<div className="absolute inset-0 bg-gradient-mesh opacity-10 dark:opacity-20" />
<div className="max-w-7xl mx-auto px-6 relative">
<div className="max-w-4xl mx-auto text-center space-y-8">
{/* Trust Badge */}
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-surface-2 border border-border text-sm">
<Users className="size-4 text-primary" />
<span className="text-muted-foreground">Trusted by <span className="text-foreground font-medium">50,000+</span> YouTube creators</span>
</div>
{/* Main Headline */}
<h1 className="text-5xl md:text-7xl font-bold tracking-tight leading-[1.1]">
See Your Thumbnail
<br />
<span className="text-gradient">Before the World Does</span>
</h1>
{/* Subheadline */}
<p className="text-xl md:text-2xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
Preview exactly how your YouTube thumbnail appears in search, suggested videos, and feeds.
Stop guessing. Start converting.
</p>
{/* CTA Buttons */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-4">
<Button size="lg" className="w-full sm:w-auto text-base px-8" asChild>
<Link to="/tool">
<Play className="size-5" />
Preview Now Free
</Link>
</Button>
<Button variant="outline" size="lg" className="w-full sm:w-auto text-base px-8" asChild>
<a href="#how-it-works">
See How It Works
</a>
</Button>
</div>
{/* Social Proof Micro */}
<div className="flex items-center justify-center gap-2 pt-4 text-sm text-muted-foreground">
<div className="flex -space-x-2">
{['SC', 'MR', 'ET', 'JK', 'AL'].map((initials, i) => (
<div
key={i}
className="size-8 rounded-full bg-gradient-accent flex items-center justify-center text-xs font-medium text-white ring-2 ring-background"
>
{initials}
</div>
))}
</div>
<span className="ml-2">
Join <span className="text-foreground font-medium">50,000+</span> creators
</span>
</div>
</div>
{/* Hero Image / Product Preview */}
<div className="mt-16 md:mt-24 relative">
<div className="absolute inset-0 bg-gradient-to-t from-background via-transparent to-transparent z-10 pointer-events-none" />
<Card className="relative overflow-hidden border-border/50 bg-surface-1 glow p-2 md:p-4">
<div className="rounded-lg overflow-hidden bg-background border border-border">
{/* Mock Browser Chrome */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border bg-surface-2">
<div className="flex gap-1.5">
<div className="size-3 rounded-full bg-red-500/80" />
<div className="size-3 rounded-full bg-yellow-500/80" />
<div className="size-3 rounded-full bg-green-500/80" />
</div>
<div className="flex-1 mx-4">
<div className="max-w-md mx-auto h-7 bg-surface-3 rounded-md flex items-center px-3">
<span className="text-xs text-muted-foreground">prevthumb.com/tool</span>
</div>
</div>
</div>
{/* Mock App Content */}
<div className="p-6 md:p-8 space-y-6">
<div className="flex items-center gap-4">
<div className="h-10 w-64 bg-surface-2 rounded-lg" />
<div className="h-10 w-32 bg-surface-2 rounded-lg" />
</div>
{/* Mock Video Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-3">
<div className={`aspect-video rounded-lg ${i === 1 ? 'bg-gradient-accent glow-sm' : 'bg-surface-2'} flex items-center justify-center`}>
{i === 1 && (
<div className="text-center text-white">
<MousePointerClick className="size-8 mx-auto mb-2" />
<span className="text-sm font-medium">Your Thumbnail</span>
</div>
)}
</div>
<div className="flex gap-3">
<div className="size-9 rounded-full bg-surface-2 shrink-0" />
<div className="space-y-2 flex-1">
<div className="h-4 bg-surface-2 rounded w-full" />
<div className="h-3 bg-surface-2 rounded w-2/3" />
</div>
</div>
</div>
))}
</div>
</div>
</div>
</Card>
</div>
</div>
</section>
{/* Stats Section */}
<section className="py-16 border-y border-border/50 bg-surface-1">
<div className="max-w-7xl mx-auto px-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 md:gap-12">
{STATS.map((stat, i) => (
<div key={i} className="text-center">
<div className="flex items-center justify-center gap-1">
<span className="text-4xl md:text-5xl font-bold text-gradient">{stat.value}</span>
{stat.icon && <stat.icon className="size-6 text-yellow-500 fill-yellow-500" />}
</div>
<p className="text-muted-foreground mt-2">{stat.label}</p>
</div>
))}
</div>
</div>
</section>
{/* Features Section */}
<section id="features" className="py-24 md:py-32">
<div className="max-w-7xl mx-auto px-6">
<div className="text-center max-w-2xl mx-auto mb-16">
<h2 className="text-3xl md:text-5xl font-bold tracking-tight mb-4">
Everything you need to
<br />
<span className="text-gradient">maximize clicks</span>
</h2>
<p className="text-lg text-muted-foreground">
Preview, compare, and optimize your thumbnails with professional tools.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{FEATURES.map((feature, i) => (
<Card key={i} className="p-8 bg-surface-1 border-border/50 hover:border-border hover:glow-sm transition-all duration-300">
<div className="size-12 rounded-xl bg-gradient-accent flex items-center justify-center mb-6">
<feature.icon className="size-6 text-white" />
</div>
<h3 className="text-xl font-semibold mb-3">{feature.title}</h3>
<p className="text-muted-foreground leading-relaxed">{feature.description}</p>
</Card>
))}
</div>
</div>
</section>
{/* How it Works Section */}
<section id="how-it-works" className="py-24 md:py-32 bg-surface-1 border-y border-border/50">
<div className="max-w-7xl mx-auto px-6">
<div className="text-center max-w-2xl mx-auto mb-16">
<h2 className="text-3xl md:text-5xl font-bold tracking-tight mb-4">
Start previewing in
<br />
<span className="text-gradient">3 simple steps</span>
</h2>
<p className="text-lg text-muted-foreground">
No account required. Preview unlimited thumbnails for free.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-12">
{STEPS.map((step, i) => (
<div key={i} className="text-center md:text-left">
<div className="text-6xl font-bold text-gradient opacity-20 mb-4">{step.number}</div>
<h3 className="text-xl font-semibold mb-3">{step.title}</h3>
<p className="text-muted-foreground leading-relaxed">{step.description}</p>
</div>
))}
</div>
<div className="text-center mt-16">
<Button size="lg" className="text-base px-8" asChild>
<Link to="/tool">
Try It Now Free
<ArrowRight className="size-5" />
</Link>
</Button>
</div>
</div>
</section>
{/* Testimonials Section */}
<section id="testimonials" className="py-24 md:py-32">
<div className="max-w-7xl mx-auto px-6">
<div className="text-center max-w-2xl mx-auto mb-16">
<h2 className="text-3xl md:text-5xl font-bold tracking-tight mb-4">
Loved by
<br />
<span className="text-gradient">YouTube creators</span>
</h2>
<p className="text-lg text-muted-foreground">
See what creators are saying about PrevThumb.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{TESTIMONIALS.map((testimonial, i) => (
<Card key={i} className="p-8 bg-surface-1 border-border/50">
<div className="flex items-center gap-1 mb-4">
{[...Array(5)].map((_, j) => (
<Star key={j} className="size-4 text-yellow-500 fill-yellow-500" />
))}
</div>
<p className="text-foreground mb-6 leading-relaxed">"{testimonial.quote}"</p>
<div className="flex items-center gap-3">
<div className="size-10 rounded-full bg-gradient-accent flex items-center justify-center text-sm font-medium text-white">
{testimonial.avatar}
</div>
<div>
<p className="font-medium text-sm">{testimonial.author}</p>
<p className="text-muted-foreground text-sm">{testimonial.role}</p>
</div>
</div>
</Card>
))}
</div>
</div>
</section>
{/* Final CTA Section */}
<section className="py-24 md:py-32 bg-surface-1 border-t border-border/50">
<div className="max-w-7xl mx-auto px-6">
<Card className="relative overflow-hidden bg-gradient-accent p-12 md:p-16 text-center glow">
<div className="relative z-10">
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">
Ready to get more clicks?
</h2>
<p className="text-xl text-white/80 mb-8 max-w-2xl mx-auto">
Join 50,000+ creators who preview their thumbnails before publishing.
Start for free today.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Button size="lg" variant="secondary" className="text-base px-8 bg-white text-black hover:bg-white/90" asChild>
<Link to="/tool">
<Play className="size-5" />
Start Previewing Now
</Link>
</Button>
</div>
<div className="flex items-center justify-center gap-6 mt-8 text-white/80 text-sm">
<div className="flex items-center gap-2">
<CheckCircle2 className="size-4" />
<span>Free forever</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle2 className="size-4" />
<span>No account required</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle2 className="size-4" />
<span>Unlimited previews</span>
</div>
</div>
</div>
</Card>
</div>
</section>
</main>
{/* Footer */}
<footer className="border-t border-border/50 py-12">
<div className="max-w-7xl mx-auto px-6">
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
<Link to="/" className="flex items-center gap-3">
<img src={LogoDark} alt="PrevThumb" className="size-8" />
<span className="text-sm font-medium">PrevThumb</span>
</Link>
<div className="flex items-center gap-6 text-sm text-muted-foreground">
<a href="#" className="hover:text-foreground transition-colors">Privacy</a>
<a href="#" className="hover:text-foreground transition-colors">Terms</a>
<a href="#" className="hover:text-foreground transition-colors">Contact</a>
</div>
<p className="text-sm text-muted-foreground">
Built for YouTube creators
</p>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,93 @@
import LogoV2_5 from '../assets/logo-v2-5.svg';
import LogoV2_5Light from '../assets/logo-v2-5-light.svg';
export function LogoPreview() {
return (
<div className="min-h-screen bg-background text-foreground p-12">
<h1 className="text-3xl font-bold mb-8 text-center">V2-5 Dark & Light Versions</h1>
<p className="text-center text-muted-foreground mb-12">Оригинал для тёмного фона, инвертированный для светлого</p>
<div className="max-w-4xl mx-auto">
{/* Comparison grid */}
<div className="grid grid-cols-2 gap-8 mb-16">
{/* Dark mode version */}
<div className="space-y-4">
<h3 className="text-center font-semibold">Dark Mode (Original)</h3>
<div className="p-8 rounded-2xl bg-[#0a0a0f] flex items-center justify-center">
<img src={LogoV2_5} alt="Dark mode logo" className="size-24" />
</div>
<div className="p-8 rounded-2xl bg-[#1a1a2e] flex items-center justify-center">
<img src={LogoV2_5} alt="Dark mode logo" className="size-24" />
</div>
<div className="p-8 rounded-2xl bg-gray-800 flex items-center justify-center">
<img src={LogoV2_5} alt="Dark mode logo" className="size-24" />
</div>
</div>
{/* Light mode version */}
<div className="space-y-4">
<h3 className="text-center font-semibold">Light Mode (Inverted)</h3>
<div className="p-8 rounded-2xl bg-white flex items-center justify-center">
<img src={LogoV2_5Light} alt="Light mode logo" className="size-24" />
</div>
<div className="p-8 rounded-2xl bg-gray-100 flex items-center justify-center">
<img src={LogoV2_5Light} alt="Light mode logo" className="size-24" />
</div>
<div className="p-8 rounded-2xl bg-gray-200 flex items-center justify-center">
<img src={LogoV2_5Light} alt="Light mode logo" className="size-24" />
</div>
</div>
</div>
{/* Header context */}
<h2 className="text-2xl font-bold mb-6 text-center">В контексте хедера</h2>
<div className="space-y-4">
{/* Dark header */}
<div className="flex items-center gap-3 p-4 rounded-xl bg-[#0a0a0f] border border-white/10">
<img src={LogoV2_5} alt="Logo" className="size-10" />
<span className="text-xl font-semibold tracking-tight text-white">PrevThumb</span>
<span className="ml-auto text-sm text-gray-400">Dark Mode</span>
</div>
{/* Light header */}
<div className="flex items-center gap-3 p-4 rounded-xl bg-white border border-gray-200">
<img src={LogoV2_5Light} alt="Logo" className="size-10" />
<span className="text-xl font-semibold tracking-tight text-gray-900">PrevThumb</span>
<span className="ml-auto text-sm text-gray-400">Light Mode</span>
</div>
</div>
{/* Different sizes */}
<div className="mt-16">
<h2 className="text-2xl font-bold mb-6 text-center">Масштабы</h2>
<div className="flex items-end justify-center gap-8">
<div className="flex flex-col items-center gap-2">
<div className="p-4 rounded-xl bg-[#0a0a0f]">
<img src={LogoV2_5} alt="Logo" className="size-8" />
</div>
<span className="text-xs text-muted-foreground">32px</span>
</div>
<div className="flex flex-col items-center gap-2">
<div className="p-4 rounded-xl bg-[#0a0a0f]">
<img src={LogoV2_5} alt="Logo" className="size-12" />
</div>
<span className="text-xs text-muted-foreground">48px</span>
</div>
<div className="flex flex-col items-center gap-2">
<div className="p-4 rounded-xl bg-[#0a0a0f]">
<img src={LogoV2_5} alt="Logo" className="size-16" />
</div>
<span className="text-xs text-muted-foreground">64px</span>
</div>
<div className="flex flex-col items-center gap-2">
<div className="p-4 rounded-xl bg-[#0a0a0f]">
<img src={LogoV2_5} alt="Logo" className="size-24" />
</div>
<span className="text-xs text-muted-foreground">96px</span>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
import { Link } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { Sun, Moon } from 'lucide-react';
import { ThumbnailUploader } from '../components/ThumbnailUploader';
import { SearchInput } from '../components/SearchInput';
import { ThumbnailSelector } from '../components/ThumbnailSelector';
import { ViewSwitcher } from '../components/ViewSwitcher';
import { PreviewGrid } from '../components/PreviewGrid';
import { UserInfoInputs } from '../components/UserInfoInputs';
import { UserMenu } from '../components/UserMenu';
import { usePreviewStore } from '../store/previewStore';
import LogoDark from '../assets/logo-v2-5.svg';
import LogoLight from '../assets/logo-v2-5-light.svg';
export function ToolPage() {
const { thumbnails, youtubeResults } = usePreviewStore();
const hasContent = thumbnails.length > 0 || youtubeResults.length > 0;
const [isDark, setIsDark] = useState(() => {
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 (
<div className="min-h-screen bg-background text-foreground">
{/* Header */}
<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">
<Link to="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
<img src={isDark ? LogoDark : LogoLight} alt="PrevThumb" className="size-10" />
<span className="text-xl font-semibold tracking-tight">PrevThumb</span>
</Link>
<nav className="flex items-center gap-6">
<button
onClick={toggleTheme}
className="p-2 rounded-lg hover:bg-muted transition-colors"
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">
Pricing
</a>
<UserMenu />
</nav>
</div>
</header>
<main className="max-w-7xl mx-auto px-6 py-12">
{!hasContent ? (
/* Empty State - Upload Section */
<div className="max-w-3xl mx-auto text-center space-y-12">
<div className="space-y-6">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight leading-[1.1]">
Upload your thumbnail
</h1>
<p className="text-xl text-muted-foreground max-w-xl mx-auto leading-relaxed">
Start by uploading your thumbnail, then search for competitors to compare.
</p>
</div>
{/* Upload Section */}
<div className="pt-4">
<ThumbnailUploader />
</div>
{/* Search Section */}
<div className="pt-4 space-y-4">
<p className="text-muted-foreground text-sm">
Search for competitors to compare
</p>
<SearchInput />
</div>
</div>
) : (
/* Preview Section */
<div className="space-y-8">
{/* Controls */}
<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="lg:col-span-2 space-y-6">
<ThumbnailSelector />
<UserInfoInputs />
</div>
<div className="space-y-3">
<p className="text-sm text-muted-foreground font-medium">Add more thumbnails</p>
<ThumbnailUploader />
</div>
</div>
{/* Preview */}
<PreviewGrid />
</div>
)}
</main>
{/* Footer */}
<footer className="border-t border-border/50 mt-24">
<div className="max-w-7xl mx-auto px-6 py-12">
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
<Link to="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
<img src={isDark ? LogoDark : LogoLight} alt="PrevThumb" className="size-8" />
<span className="text-sm font-medium">PrevThumb</span>
</Link>
<p className="text-sm text-muted-foreground">
Built for YouTube creators
</p>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -22,7 +22,13 @@
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noUncheckedSideEffectImports": true,
/* Path alias */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -1,4 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },

View File

@@ -1,10 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
@@ -12,6 +18,10 @@ export default defineConfig({
target: 'http://localhost:4000',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:4000',
changeOrigin: true,
},
},
},
})