feat: frontend shadcn
22
.claude/settings.local.json
Normal 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
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"shadcn@latest",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
52
frontend/.claude/settings.local.json
Normal 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
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"shadcn@latest",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
22
frontend/components.json
Normal 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": {}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
4314
frontend/package-lock.json
generated
@@ -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"
|
||||
|
||||
17
frontend/src/assets/logo-concept-1.svg
Normal 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 |
17
frontend/src/assets/logo-concept-1b.svg
Normal 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 |
18
frontend/src/assets/logo-concept-1c.svg
Normal 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 |
17
frontend/src/assets/logo-concept-5.svg
Normal 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 |
19
frontend/src/assets/logo-concept-5b.svg
Normal 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 |
17
frontend/src/assets/logo-concept-5c.svg
Normal 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 |
19
frontend/src/assets/logo-v2-1.svg
Normal 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 |
19
frontend/src/assets/logo-v2-2.svg
Normal 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 |
23
frontend/src/assets/logo-v2-3.svg
Normal 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 |
18
frontend/src/assets/logo-v2-4.svg
Normal 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 |
25
frontend/src/assets/logo-v2-5-light.svg
Normal 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 |
25
frontend/src/assets/logo-v2-5.svg
Normal 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 |
25
frontend/src/assets/logo-v2-5a.svg
Normal 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 |
26
frontend/src/assets/logo-v2-5b.svg
Normal 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 |
25
frontend/src/assets/logo-v2-5c.svg
Normal 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 |
25
frontend/src/assets/logo-v2-5d.svg
Normal 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 |
21
frontend/src/assets/logo-v2-6.svg
Normal 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 |
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
48
frontend/src/components/ui/badge.tsx
Normal 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 }
|
||||
64
frontend/src/components/ui/button.tsx
Normal 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 }
|
||||
92
frontend/src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
21
frontend/src/components/ui/input.tsx
Normal 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 }
|
||||
22
frontend/src/components/ui/label.tsx
Normal 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 }
|
||||
16
frontend/src/components/ui/spinner.tsx
Normal 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 }
|
||||
81
frontend/src/components/ui/toggle-group.tsx
Normal 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 }
|
||||
47
frontend/src/components/ui/toggle.tsx
Normal 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 }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
6
frontend/src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
420
frontend/src/pages/LandingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
frontend/src/pages/LogoPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
frontend/src/pages/ToolPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,13 @@
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
/* Path alias */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||