Compare commits
5 Commits
fe2c861007
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ead15a7fb | |||
|
|
bdb1759c81 | ||
|
|
5125bbf4d4 | ||
| 7627a00303 | |||
|
|
130f35c4f8 |
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"
|
||||||
|
]
|
||||||
|
}
|
||||||
5
.gitignore
vendored
@@ -32,5 +32,10 @@ uploads/*
|
|||||||
# Testing
|
# Testing
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
.playwright/
|
||||||
|
|
||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
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
|
## Core Principles
|
||||||
|
|
||||||
### [PRINCIPLE_1_NAME]
|
### I. Tech Stack
|
||||||
<!-- 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 -->
|
|
||||||
|
|
||||||
### [PRINCIPLE_2_NAME]
|
All new code MUST use the established technology stack:
|
||||||
<!-- 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 -->
|
|
||||||
|
|
||||||
### [PRINCIPLE_3_NAME]
|
**Frontend (React SPA):**
|
||||||
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
- React 19.x with TypeScript 5.x
|
||||||
[PRINCIPLE_3_DESCRIPTION]
|
- Vite as build tool with HMR
|
||||||
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
|
- 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]
|
**Backend (NestJS API):**
|
||||||
<!-- Example: IV. Integration Testing -->
|
- NestJS 11.x with TypeScript 5.x
|
||||||
[PRINCIPLE_4_DESCRIPTION]
|
- Express via @nestjs/platform-express
|
||||||
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
- PostgreSQL with TypeORM
|
||||||
|
- class-validator and class-transformer for DTOs
|
||||||
|
- Multer for file uploads
|
||||||
|
- Sharp for image processing
|
||||||
|
|
||||||
### [PRINCIPLE_5_NAME]
|
**Rationale:** Consistent tooling reduces onboarding friction, ensures compatibility,
|
||||||
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
and enables shared patterns across the codebase.
|
||||||
[PRINCIPLE_5_DESCRIPTION]
|
|
||||||
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
|
|
||||||
|
|
||||||
## [SECTION_2_NAME]
|
### II. Architecture & Project Structure
|
||||||
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
|
|
||||||
|
|
||||||
[SECTION_2_CONTENT]
|
This is a **monorepo** with separate frontend and backend packages. New code MUST
|
||||||
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
|
follow the established directory organization:
|
||||||
|
|
||||||
## [SECTION_3_NAME]
|
**Frontend (`/frontend/src/`):**
|
||||||
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
|
- `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]
|
**Backend (`/backend/src/`):**
|
||||||
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
|
- `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
|
## Governance
|
||||||
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
|
||||||
|
|
||||||
[GOVERNANCE_RULES]
|
This constitution establishes non-negotiable development standards for the
|
||||||
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
|
ThumbPreview project. All contributors MUST adhere to these principles.
|
||||||
|
|
||||||
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
|
**Amendment Process:**
|
||||||
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
|
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
|
||||||
|
|||||||
32
CLAUDE.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# thumbnail-preview-tool Development Guidelines
|
||||||
|
|
||||||
|
Auto-generated from all feature plans. Last updated: 2026-01-29
|
||||||
|
|
||||||
|
## Active Technologies
|
||||||
|
- TypeScript 5.9.x (frontend + backend) + React 19.x, Tailwind CSS 4.x, shadcn/ui, Zustand, Playwright MCP (002-youtube-design-preview)
|
||||||
|
- Browser localStorage (preview persistence), file system (reference screenshots) (002-youtube-design-preview)
|
||||||
|
|
||||||
|
- TypeScript 5.x (frontend + backend) (001-google-oauth-auth)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
npm test && npm run lint
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
TypeScript 5.x (frontend + backend): Follow standard conventions
|
||||||
|
|
||||||
|
## Recent Changes
|
||||||
|
- 002-youtube-design-preview: Added TypeScript 5.9.x (frontend + backend) + React 19.x, Tailwind CSS 4.x, shadcn/ui, Zustand, Playwright MCP
|
||||||
|
|
||||||
|
- 001-google-oauth-auth: Added TypeScript 5.x (frontend + backend)
|
||||||
|
|
||||||
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
<!-- MANUAL ADDITIONS END -->
|
||||||
@@ -15,3 +15,16 @@ YOUTUBE_API_KEY=YOUR_YOUTUBE_API_KEY_HERE
|
|||||||
# Upload
|
# Upload
|
||||||
UPLOAD_DIR=./uploads
|
UPLOAD_DIR=./uploads
|
||||||
MAX_FILE_SIZE=5242880
|
MAX_FILE_SIZE=5242880
|
||||||
|
|
||||||
|
# Google OAuth
|
||||||
|
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||||
|
GOOGLE_CALLBACK_URL=http://localhost:4000/api/auth/google/callback
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET=your-secure-random-secret-min-32-characters-long
|
||||||
|
JWT_ACCESS_EXPIRATION=15m
|
||||||
|
JWT_REFRESH_EXPIRATION=7d
|
||||||
|
|
||||||
|
# Frontend URL (for OAuth redirects)
|
||||||
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
|||||||
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
|
||||||
1577
backend/package-lock.json
generated
@@ -24,11 +24,18 @@
|
|||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-google-oauth20": "^2.0.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
"pg": "^8.17.2",
|
"pg": "^8.17.2",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
@@ -43,10 +50,14 @@
|
|||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@swc/cli": "^0.6.0",
|
"@swc/cli": "^0.6.0",
|
||||||
"@swc/core": "^1.10.7",
|
"@swc/core": "^1.10.7",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/passport-google-oauth20": "^2.0.17",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ThumbnailsModule } from './modules/thumbnails/thumbnails.module';
|
import { ThumbnailsModule } from './modules/thumbnails/thumbnails.module';
|
||||||
import { YouTubeModule } from './modules/youtube/youtube.module';
|
import { YouTubeModule } from './modules/youtube/youtube.module';
|
||||||
|
// import { AuthModule } from './modules/auth/auth.module'; // TODO: Enable when Google OAuth is configured
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -25,6 +26,7 @@ import { YouTubeModule } from './modules/youtube/youtube.module';
|
|||||||
}),
|
}),
|
||||||
ThumbnailsModule,
|
ThumbnailsModule,
|
||||||
YouTubeModule,
|
YouTubeModule,
|
||||||
|
// AuthModule, // TODO: Enable when Google OAuth is configured
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
36
backend/src/entities/refresh-token.entity.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { User } from './user.entity';
|
||||||
|
|
||||||
|
@Entity('refresh_tokens')
|
||||||
|
export class RefreshToken {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, (user) => user.refreshTokens, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'userId' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
@Index('idx_refresh_token_token')
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp' })
|
||||||
|
expiresAt: Date;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true })
|
||||||
|
revokedAt: Date;
|
||||||
|
}
|
||||||
38
backend/src/entities/user.entity.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { RefreshToken } from './refresh-token.entity';
|
||||||
|
|
||||||
|
@Entity('users')
|
||||||
|
export class User {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
@Index('idx_user_google_id')
|
||||||
|
googleId: string;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
@Index('idx_user_email')
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
avatarUrl: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
lastLoginAt: Date;
|
||||||
|
|
||||||
|
@OneToMany(() => RefreshToken, (refreshToken) => refreshToken.user)
|
||||||
|
refreshTokens: RefreshToken[];
|
||||||
|
}
|
||||||
@@ -3,10 +3,14 @@ import { ValidationPipe } from '@nestjs/common';
|
|||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import * as cookieParser from 'cookie-parser';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
|
|
||||||
|
// Cookie parser for refresh tokens
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
// Global prefix
|
// Global prefix
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
@@ -19,8 +23,9 @@ async function bootstrap() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// CORS
|
// CORS
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: ['http://localhost:3000', 'http://localhost:5173'],
|
origin: [frontendUrl, 'http://localhost:3000', 'http://localhost:5173'],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
114
backend/src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { GoogleAuthGuard } from './guards/google-auth.guard';
|
||||||
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
import { User } from '../../entities/user.entity';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('google')
|
||||||
|
@UseGuards(GoogleAuthGuard)
|
||||||
|
googleAuth() {
|
||||||
|
// Guard redirects to Google
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('google/callback')
|
||||||
|
@UseGuards(GoogleAuthGuard)
|
||||||
|
async googleCallback(@Req() req: Request, @Res() res: Response) {
|
||||||
|
try {
|
||||||
|
const user = req.user as User;
|
||||||
|
const { accessToken, refreshToken } =
|
||||||
|
await this.authService.generateTokens(user);
|
||||||
|
|
||||||
|
// Set HttpOnly cookie for refresh token
|
||||||
|
res.cookie('refreshToken', refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: this.configService.get('NODE_ENV') === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect to frontend with access token
|
||||||
|
const frontendUrl = this.configService.get<string>(
|
||||||
|
'FRONTEND_URL',
|
||||||
|
'http://localhost:3000',
|
||||||
|
);
|
||||||
|
res.redirect(`${frontendUrl}/auth/callback?token=${accessToken}`);
|
||||||
|
} catch {
|
||||||
|
const frontendUrl = this.configService.get<string>(
|
||||||
|
'FRONTEND_URL',
|
||||||
|
'http://localhost:3000',
|
||||||
|
);
|
||||||
|
res.redirect(`${frontendUrl}/login?error=auth_failed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
async refresh(@Req() req: Request, @Res() res: Response) {
|
||||||
|
const refreshToken = (req.cookies as Record<string, string>)?.refreshToken;
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new UnauthorizedException('No refresh token provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.authService.refreshTokens(refreshToken);
|
||||||
|
|
||||||
|
// Set new HttpOnly cookie for refresh token
|
||||||
|
res.cookie('refreshToken', result.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: this.configService.get('NODE_ENV') === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
user: result.user,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
getMe(@Req() req: Request) {
|
||||||
|
const user = req.user as User;
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async logout(@Req() req: Request, @Res() res: Response) {
|
||||||
|
const refreshToken = (req.cookies as Record<string, string>)?.refreshToken;
|
||||||
|
|
||||||
|
if (refreshToken) {
|
||||||
|
await this.authService.revokeRefreshToken(refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.clearCookie('refreshToken', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: this.configService.get('NODE_ENV') === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ message: 'Successfully logged out' });
|
||||||
|
}
|
||||||
|
}
|
||||||
34
backend/src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { GoogleStrategy } from './google.strategy';
|
||||||
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
|
import { User } from '../../entities/user.entity';
|
||||||
|
import { RefreshToken } from '../../entities/refresh-token.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (configService: ConfigService): JwtModuleOptions => {
|
||||||
|
return {
|
||||||
|
secret: configService.get<string>('JWT_SECRET'),
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: '15m',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
TypeOrmModule.forFeature([User, RefreshToken]),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, GoogleStrategy, JwtStrategy],
|
||||||
|
exports: [AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
158
backend/src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, IsNull, MoreThan } from 'typeorm';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { User } from '../../entities/user.entity';
|
||||||
|
import { RefreshToken } from '../../entities/refresh-token.entity';
|
||||||
|
|
||||||
|
interface GoogleProfile {
|
||||||
|
id: string;
|
||||||
|
emails: Array<{ value: string; verified: boolean }>;
|
||||||
|
displayName: string;
|
||||||
|
photos: Array<{ value: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepository: Repository<User>,
|
||||||
|
@InjectRepository(RefreshToken)
|
||||||
|
private readonly refreshTokenRepository: Repository<RefreshToken>,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async validateOAuthUser(profile: GoogleProfile): Promise<User> {
|
||||||
|
const email = profile.emails?.[0]?.value;
|
||||||
|
const googleId = profile.id;
|
||||||
|
const displayName = profile.displayName;
|
||||||
|
const avatarUrl = profile.photos?.[0]?.value;
|
||||||
|
|
||||||
|
if (!email || !googleId) {
|
||||||
|
throw new UnauthorizedException('Invalid Google profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = await this.userRepository.findOne({
|
||||||
|
where: { googleId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Update last login and profile info
|
||||||
|
user.lastLoginAt = new Date();
|
||||||
|
user.displayName = displayName;
|
||||||
|
user.avatarUrl = avatarUrl;
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
user = this.userRepository.create({
|
||||||
|
googleId,
|
||||||
|
email,
|
||||||
|
displayName,
|
||||||
|
avatarUrl,
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
});
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateTokens(
|
||||||
|
user: User,
|
||||||
|
): Promise<{ accessToken: string; refreshToken: string }> {
|
||||||
|
// Generate access token
|
||||||
|
const payload = { sub: user.id, email: user.email };
|
||||||
|
const accessToken = this.jwtService.sign(payload);
|
||||||
|
|
||||||
|
// Generate refresh token
|
||||||
|
const refreshTokenValue = this.generateRandomToken();
|
||||||
|
const hashedToken = await bcrypt.hash(refreshTokenValue, 10);
|
||||||
|
|
||||||
|
// Calculate expiration (7 days)
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 7);
|
||||||
|
|
||||||
|
// Save refresh token to database
|
||||||
|
const refreshTokenEntity = this.refreshTokenRepository.create({
|
||||||
|
userId: user.id,
|
||||||
|
token: hashedToken,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
await this.refreshTokenRepository.save(refreshTokenEntity);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken: `${refreshTokenEntity.id}:${refreshTokenValue}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshTokens(
|
||||||
|
token: string,
|
||||||
|
): Promise<{ accessToken: string; refreshToken: string; user: User }> {
|
||||||
|
const [tokenId, tokenValue] = token.split(':');
|
||||||
|
|
||||||
|
if (!tokenId || !tokenValue) {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedToken = await this.refreshTokenRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: tokenId,
|
||||||
|
revokedAt: IsNull(),
|
||||||
|
expiresAt: MoreThan(new Date()),
|
||||||
|
},
|
||||||
|
relations: ['user'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!storedToken) {
|
||||||
|
throw new UnauthorizedException('Invalid or expired refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await bcrypt.compare(tokenValue, storedToken.token);
|
||||||
|
if (!isValid) {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke old token (rotation)
|
||||||
|
await this.revokeRefreshToken(token);
|
||||||
|
|
||||||
|
// Generate new tokens
|
||||||
|
const { accessToken, refreshToken } = await this.generateTokens(
|
||||||
|
storedToken.user,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user: storedToken.user,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeRefreshToken(token: string): Promise<void> {
|
||||||
|
const [tokenId] = token.split(':');
|
||||||
|
|
||||||
|
if (tokenId) {
|
||||||
|
await this.refreshTokenRepository.update(
|
||||||
|
{ id: tokenId },
|
||||||
|
{ revokedAt: new Date() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateUserById(userId: string): Promise<User | null> {
|
||||||
|
return this.userRepository.findOne({ where: { id: userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateRandomToken(): string {
|
||||||
|
const chars =
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/src/modules/auth/dto/auth-response.dto.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { IsString, IsUUID, IsEmail, IsOptional, IsUrl } from 'class-validator';
|
||||||
|
|
||||||
|
export class UserResponseDto {
|
||||||
|
@IsUUID()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
avatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthResponseDto {
|
||||||
|
@IsString()
|
||||||
|
accessToken: string;
|
||||||
|
|
||||||
|
user: UserResponseDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MessageResponseDto {
|
||||||
|
@IsString()
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
40
backend/src/modules/auth/google.strategy.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { Strategy, VerifyCallback, Profile, StrategyOptions } from 'passport-google-oauth20';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
) {
|
||||||
|
const options: StrategyOptions = {
|
||||||
|
clientID: configService.get<string>('GOOGLE_CLIENT_ID') || '',
|
||||||
|
clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET') || '',
|
||||||
|
callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL') || '',
|
||||||
|
scope: ['email', 'profile'],
|
||||||
|
};
|
||||||
|
super(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(
|
||||||
|
_accessToken: string,
|
||||||
|
_refreshToken: string,
|
||||||
|
profile: Profile,
|
||||||
|
done: VerifyCallback,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const user = await this.authService.validateOAuthUser({
|
||||||
|
id: profile.id,
|
||||||
|
emails: profile.emails as Array<{ value: string; verified: boolean }>,
|
||||||
|
displayName: profile.displayName,
|
||||||
|
photos: profile.photos as Array<{ value: string }>,
|
||||||
|
});
|
||||||
|
done(null, user);
|
||||||
|
} catch (error) {
|
||||||
|
done(error as Error, undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
backend/src/modules/auth/guards/google-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GoogleAuthGuard extends AuthGuard('google') {}
|
||||||
5
backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||||
41
backend/src/modules/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import {
|
||||||
|
Strategy,
|
||||||
|
ExtractJwt,
|
||||||
|
StrategyOptionsWithoutRequest,
|
||||||
|
} from 'passport-jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
) {
|
||||||
|
const options: StrategyOptionsWithoutRequest = {
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: configService.get<string>('JWT_SECRET') || 'fallback-secret',
|
||||||
|
};
|
||||||
|
super(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: JwtPayload) {
|
||||||
|
const user = await this.authService.validateUserById(payload.sub);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,7 @@ import { YouTubeService } from './youtube.service';
|
|||||||
import { YouTubeCache } from '../../entities/youtube-cache.entity';
|
import { YouTubeCache } from '../../entities/youtube-cache.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [TypeOrmModule.forFeature([YouTubeCache]), HttpModule],
|
||||||
TypeOrmModule.forFeature([YouTubeCache]),
|
|
||||||
HttpModule,
|
|
||||||
],
|
|
||||||
controllers: [YouTubeController],
|
controllers: [YouTubeController],
|
||||||
providers: [YouTubeService],
|
providers: [YouTubeService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ export class YouTubeService {
|
|||||||
this.apiKey = this.configService.get<string>('YOUTUBE_API_KEY', '');
|
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
|
// Check cache first
|
||||||
const cached = await this.getFromCache(query);
|
const cached = await this.getFromCache(query);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -76,7 +79,9 @@ export class YouTubeService {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getFromCache(query: string): Promise<YouTubeVideoResponse[] | null> {
|
private async getFromCache(
|
||||||
|
query: string,
|
||||||
|
): Promise<YouTubeVideoResponse[] | null> {
|
||||||
const cached = await this.cacheRepository.findOne({
|
const cached = await this.cacheRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
searchQuery: query.toLowerCase(),
|
searchQuery: query.toLowerCase(),
|
||||||
@@ -87,7 +92,10 @@ export class YouTubeService {
|
|||||||
return cached ? cached.results : null;
|
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();
|
const expiresAt = new Date();
|
||||||
expiresAt.setHours(expiresAt.getHours() + this.cacheHours);
|
expiresAt.setHours(expiresAt.getHours() + this.cacheHours);
|
||||||
|
|
||||||
@@ -100,7 +108,10 @@ export class YouTubeService {
|
|||||||
await this.cacheRepository.save(cache);
|
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 searchUrl = 'https://www.googleapis.com/youtube/v3/search';
|
||||||
|
|
||||||
const { data } = await firstValueFrom(
|
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
|
// Get view counts
|
||||||
const statsUrl = 'https://www.googleapis.com/youtube/v3/videos';
|
const statsUrl = 'https://www.googleapis.com/youtube/v3/videos';
|
||||||
@@ -138,20 +151,26 @@ export class YouTubeService {
|
|||||||
videoId: item.id.videoId,
|
videoId: item.id.videoId,
|
||||||
title: item.snippet.title,
|
title: item.snippet.title,
|
||||||
channelTitle: item.snippet.channelTitle,
|
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,
|
publishedAt: item.snippet.publishedAt,
|
||||||
viewCount: viewCounts.get(item.id.videoId),
|
viewCount: viewCounts.get(item.id.videoId),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMockResults(query: string, maxResults: number): YouTubeVideoResponse[] {
|
private getMockResults(
|
||||||
|
query: string,
|
||||||
|
maxResults: number,
|
||||||
|
): YouTubeVideoResponse[] {
|
||||||
const mockVideos: YouTubeVideoResponse[] = [
|
const mockVideos: YouTubeVideoResponse[] = [
|
||||||
{
|
{
|
||||||
videoId: 'mock1',
|
videoId: 'mock1',
|
||||||
title: `${query} - Complete Tutorial for Beginners`,
|
title: `${query} - Complete Tutorial for Beginners`,
|
||||||
channelTitle: 'Tech Academy',
|
channelTitle: 'Tech Academy',
|
||||||
thumbnailUrl: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg',
|
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',
|
viewCount: '1250000',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -159,7 +178,9 @@ export class YouTubeService {
|
|||||||
title: `Learn ${query} in 30 Minutes`,
|
title: `Learn ${query} in 30 Minutes`,
|
||||||
channelTitle: 'Code Master',
|
channelTitle: 'Code Master',
|
||||||
thumbnailUrl: 'https://i.ytimg.com/vi/9bZkp7q19f0/hqdefault.jpg',
|
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',
|
viewCount: '890000',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -167,7 +188,9 @@ export class YouTubeService {
|
|||||||
title: `${query} Crash Course 2025`,
|
title: `${query} Crash Course 2025`,
|
||||||
channelTitle: 'Dev Tutorial',
|
channelTitle: 'Dev Tutorial',
|
||||||
thumbnailUrl: 'https://i.ytimg.com/vi/kJQP7kiw5Fk/hqdefault.jpg',
|
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',
|
viewCount: '2100000',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -175,7 +198,9 @@ export class YouTubeService {
|
|||||||
title: `Why ${query} is Amazing`,
|
title: `Why ${query} is Amazing`,
|
||||||
channelTitle: 'Tech Reviews',
|
channelTitle: 'Tech Reviews',
|
||||||
thumbnailUrl: 'https://i.ytimg.com/vi/RgKAFK5djSk/hqdefault.jpg',
|
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',
|
viewCount: '450000',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -183,7 +208,9 @@ export class YouTubeService {
|
|||||||
title: `${query} Tips and Tricks`,
|
title: `${query} Tips and Tricks`,
|
||||||
channelTitle: 'Pro Tips',
|
channelTitle: 'Pro Tips',
|
||||||
thumbnailUrl: 'https://i.ytimg.com/vi/fJ9rUzIMcZQ/hqdefault.jpg',
|
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',
|
viewCount: '320000',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -191,7 +218,9 @@ export class YouTubeService {
|
|||||||
title: `${query} for Professionals`,
|
title: `${query} for Professionals`,
|
||||||
channelTitle: 'Advanced Learning',
|
channelTitle: 'Advanced Learning',
|
||||||
thumbnailUrl: 'https://i.ytimg.com/vi/09R8_2nJtjg/hqdefault.jpg',
|
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',
|
viewCount: '780000',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: thumbpreview123
|
POSTGRES_PASSWORD: thumbpreview123
|
||||||
POSTGRES_DB: thumbpreview
|
POSTGRES_DB: thumbpreview
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5435:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
58
frontend/.claude/settings.local.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"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:*)",
|
||||||
|
"mcp__playwright__playwright_evaluate",
|
||||||
|
"mcp__playwright__playwright_close",
|
||||||
|
"mcp__playwright__playwright_fill",
|
||||||
|
"mcp__playwright__playwright_click",
|
||||||
|
"mcp__playwright__playwright_resize",
|
||||||
|
"Bash(git -C /Users/andrei/Work/Web/thumbnail-preview-tool rev-parse:*)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"enabledMcpjsonServers": [
|
||||||
|
"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,16 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>PrevThumb</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
4378
frontend/package-lock.json
generated
@@ -7,20 +7,33 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:visual": "playwright test",
|
||||||
|
"test:visual:update": "playwright test --update-snapshots",
|
||||||
|
"test:visual:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
|
"@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",
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"axios": "^1.13.4",
|
"axios": "^1.13.4",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zustand": "^5.0.10"
|
"zustand": "^5.0.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@playwright/test": "^1.58.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
@@ -31,7 +44,9 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"shadcn": "^3.7.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4"
|
||||||
|
|||||||
59
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright configuration for YouTube Design Preview visual regression testing
|
||||||
|
* @see https://playwright.dev/docs/test-configuration
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/visual',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'html',
|
||||||
|
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mobile-chrome',
|
||||||
|
use: { ...devices['Pixel 5'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mobile-safari',
|
||||||
|
use: { ...devices['iPhone 12'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
url: 'http://localhost:5173',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120 * 1000,
|
||||||
|
},
|
||||||
|
|
||||||
|
expect: {
|
||||||
|
toHaveScreenshot: {
|
||||||
|
// 98% match threshold as per specification
|
||||||
|
threshold: 0.02,
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
maxDiffPixelRatio: 0.02,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,100 +1,23 @@
|
|||||||
import { ThumbnailUploader } from './components/ThumbnailUploader';
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
import { SearchInput } from './components/SearchInput';
|
import { LandingPage } from './pages/LandingPage';
|
||||||
import { ThumbnailSelector } from './components/ThumbnailSelector';
|
import { ToolPage } from './pages/ToolPage';
|
||||||
import { ViewSwitcher } from './components/ViewSwitcher';
|
import { LogoPreview } from './pages/LogoPreview';
|
||||||
import { PreviewGrid } from './components/PreviewGrid';
|
import { LoginPage } from './pages/LoginPage';
|
||||||
import { UserInfoInputs } from './components/UserInfoInputs';
|
import { AuthCallbackPage } from './pages/AuthCallbackPage';
|
||||||
import { usePreviewStore } from './store/previewStore';
|
// import { AuthGuard } from './components/AuthGuard'; // TODO: Enable when Google OAuth is configured
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { thumbnails, youtubeResults } = usePreviewStore();
|
|
||||||
const hasContent = thumbnails.length > 0 || youtubeResults.length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-950 text-white">
|
<BrowserRouter>
|
||||||
{/* Header */}
|
<Routes>
|
||||||
<header className="border-b border-gray-800">
|
<Route path="/" element={<LandingPage />} />
|
||||||
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<div className="flex items-center gap-2">
|
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||||
<div className="w-10 h-10 bg-red-600 rounded-lg flex items-center justify-center">
|
{/* TODO: Re-enable AuthGuard when Google OAuth is configured */}
|
||||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Route path="/tool" element={<ToolPage />} />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
<Route path="/logo-preview" element={<LogoPreview />} />
|
||||||
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
</Routes>
|
||||||
</svg>
|
</BrowserRouter>
|
||||||
</div>
|
|
||||||
<span className="text-xl font-bold">ThumbPreview</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex items-center gap-4">
|
|
||||||
<a href="#" className="text-gray-400 hover:text-white transition-colors">
|
|
||||||
Pricing
|
|
||||||
</a>
|
|
||||||
<button className="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg font-medium transition-colors">
|
|
||||||
Sign In
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
|
||||||
{!hasContent ? (
|
|
||||||
/* Landing / Upload Section */
|
|
||||||
<div className="max-w-2xl mx-auto text-center space-y-8">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h1 className="text-4xl font-bold">
|
|
||||||
See how your thumbnail looks
|
|
||||||
<span className="text-red-500"> before publishing</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-400">
|
|
||||||
Preview your YouTube thumbnails against real competitors.
|
|
||||||
Make data-driven decisions to maximize clicks.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ThumbnailUploader />
|
|
||||||
|
|
||||||
<div className="pt-4">
|
|
||||||
<p className="text-gray-500 text-sm mb-4">Then search for competitors:</p>
|
|
||||||
<SearchInput />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Preview Section */
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="flex flex-col lg:flex-row gap-6 items-start lg:items-end justify-between">
|
|
||||||
<div className="flex-1 w-full lg:w-auto">
|
|
||||||
<SearchInput />
|
|
||||||
</div>
|
|
||||||
<ViewSwitcher />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thumbnail Management */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<div className="lg:col-span-2 space-y-4">
|
|
||||||
<ThumbnailSelector />
|
|
||||||
<UserInfoInputs />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-400 mb-2">Add more thumbnails</p>
|
|
||||||
<ThumbnailUploader />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview */}
|
|
||||||
<PreviewGrid />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="border-t border-gray-800 mt-16">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 py-8 text-center text-gray-500 text-sm">
|
|
||||||
<p>ThumbPreview - Preview your YouTube thumbnails before publishing</p>
|
|
||||||
<p className="mt-2">Built with React + NestJS</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
94
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios';
|
||||||
|
import type { AuthResponse, User } from '../types/auth';
|
||||||
|
|
||||||
|
const authClient = axios.create({
|
||||||
|
baseURL: '/api/auth',
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token refresh state
|
||||||
|
let isRefreshing = false;
|
||||||
|
let failedQueue: Array<{
|
||||||
|
resolve: (value: string) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const processQueue = (error: Error | null, token: string | null = null) => {
|
||||||
|
failedQueue.forEach((prom) => {
|
||||||
|
if (error) {
|
||||||
|
prom.reject(error);
|
||||||
|
} else {
|
||||||
|
prom.resolve(token!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
failedQueue = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Response interceptor for token refresh
|
||||||
|
authClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||||
|
_retry?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({ resolve, reject });
|
||||||
|
})
|
||||||
|
.then((token) => {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return authClient(originalRequest);
|
||||||
|
})
|
||||||
|
.catch((err) => Promise.reject(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true;
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authClient.post<AuthResponse>('/refresh');
|
||||||
|
const { accessToken } = response.data;
|
||||||
|
|
||||||
|
processQueue(null, accessToken);
|
||||||
|
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
return authClient(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
processQueue(refreshError as Error, null);
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
refreshToken: async (): Promise<AuthResponse> => {
|
||||||
|
const response = await authClient.post<AuthResponse>('/refresh');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getMe: async (accessToken: string): Promise<User> => {
|
||||||
|
const response = await authClient.get<User>('/me', {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async (accessToken: string): Promise<void> => {
|
||||||
|
await authClient.post(
|
||||||
|
'/logout',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export { authClient };
|
||||||
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 |
33
frontend/src/components/AuthGuard.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { type ReactNode, useEffect } from 'react';
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
interface AuthGuardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthGuard({ children }: AuthGuardProps) {
|
||||||
|
const { isAuthenticated, isLoading, checkAuth } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated && !isLoading) {
|
||||||
|
checkAuth();
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, isLoading, checkAuth]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-muted-foreground">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
// Preserve the intended destination for post-login redirect
|
||||||
|
return <Navigate to="/login" state={{ from: location.pathname }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { usePreviewStore } from '../store/previewStore';
|
import { usePreviewStore } from '../store/previewStore';
|
||||||
import { YouTubeVideoCard } from './YouTubeVideoCard';
|
import { YouTubeVideoCard } from './YouTubeVideoCard';
|
||||||
|
import { formatViewCount, formatRelativeTime } from '@/lib/youtube-styles';
|
||||||
|
import { Menu, Video, Bell, Search, Mic } from 'lucide-react';
|
||||||
|
|
||||||
export const PreviewGrid = () => {
|
export const PreviewGrid = () => {
|
||||||
const {
|
const {
|
||||||
@@ -7,78 +9,122 @@ export const PreviewGrid = () => {
|
|||||||
activeThumbnailIndex,
|
activeThumbnailIndex,
|
||||||
youtubeResults,
|
youtubeResults,
|
||||||
viewMode,
|
viewMode,
|
||||||
|
metadata,
|
||||||
userTitle,
|
userTitle,
|
||||||
userChannel
|
userChannel,
|
||||||
} = usePreviewStore();
|
} = usePreviewStore();
|
||||||
|
|
||||||
const activeThumbnail = thumbnails[activeThumbnailIndex];
|
const activeThumbnail = thumbnails[activeThumbnailIndex];
|
||||||
|
|
||||||
if (!activeThumbnail || youtubeResults.length === 0) {
|
if (!activeThumbnail || youtubeResults.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 rounded-xl p-8 text-center">
|
<div className="yt-surface rounded-xl border border-yt-border p-12 text-center">
|
||||||
<p className="text-gray-500">
|
<p className="yt-meta text-sm">
|
||||||
{!activeThumbnail
|
{!activeThumbnail
|
||||||
? 'Upload a thumbnail to see preview'
|
? 'Upload a thumbnail to see preview'
|
||||||
: 'Search for competitors to see preview'
|
: 'Search for competitors to see preview'}
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert user thumbnail at position 0 (top of results)
|
// Format user's metadata
|
||||||
|
const formattedUserViews = formatViewCount(metadata.viewCount);
|
||||||
|
const formattedUserTime = formatRelativeTime(metadata.publishedAt);
|
||||||
|
|
||||||
const combinedResults = [
|
const combinedResults = [
|
||||||
{
|
{
|
||||||
videoId: 'user',
|
videoId: 'user',
|
||||||
title: userTitle,
|
title: metadata.title || userTitle,
|
||||||
channelTitle: userChannel,
|
channelTitle: metadata.channelName || userChannel,
|
||||||
thumbnailUrl: activeThumbnail.url,
|
thumbnailUrl: activeThumbnail.url,
|
||||||
publishedAt: new Date().toISOString(),
|
publishedAt: formattedUserTime,
|
||||||
viewCount: '0',
|
viewCount: formattedUserViews,
|
||||||
|
duration: metadata.duration,
|
||||||
isUser: true,
|
isUser: true,
|
||||||
|
isVerified: true, // User is verified for preview
|
||||||
},
|
},
|
||||||
...youtubeResults.map(v => ({ ...v, isUser: false })),
|
...youtubeResults.map((v) => ({
|
||||||
|
...v,
|
||||||
|
viewCount: v.viewCount
|
||||||
|
? formatViewCount(parseInt(v.viewCount, 10))
|
||||||
|
: undefined,
|
||||||
|
publishedAt: formatRelativeTime(new Date(v.publishedAt)),
|
||||||
|
isUser: false,
|
||||||
|
duration: '10:30', // Default duration for YouTube results
|
||||||
|
isVerified: Math.random() > 0.5, // Randomly verify 50% of results for realism
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Mobile view - YouTube mobile app style
|
||||||
if (viewMode === 'mobile') {
|
if (viewMode === 'mobile') {
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 rounded-xl p-4 max-w-sm mx-auto">
|
<div className="yt-surface max-w-[428px] mx-auto rounded-xl overflow-hidden border border-yt-border">
|
||||||
{/* Mobile header */}
|
{/* Mobile header */}
|
||||||
<div className="flex items-center justify-between mb-4 pb-3 border-b border-gray-800">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-yt-border">
|
||||||
<svg className="w-24 h-6 text-white" viewBox="0 0 90 20" fill="currentColor">
|
<svg height="20" viewBox="0 0 90 20" width="90" focusable="false">
|
||||||
<text x="0" y="16" className="text-lg font-bold">YouTube</text>
|
<g>
|
||||||
|
<path
|
||||||
|
d="M27.9727 3.12324C27.6435 1.89323 26.6768 0.926623 25.4468 0.597366C23.2197 2.24288e-07 14.285 0 14.285 0C14.285 0 5.35042 2.24288e-07 3.12323 0.597366C1.89323 0.926623 0.926623 1.89323 0.597366 3.12324C2.24288e-07 5.35042 0 10 0 10C0 10 2.24288e-07 14.6496 0.597366 16.8768C0.926623 18.1068 1.89323 19.0734 3.12323 19.4026C5.35042 20 14.285 20 14.285 20C14.285 20 23.2197 20 25.4468 19.4026C26.6768 19.0734 27.6435 18.1068 27.9727 16.8768C28.5701 14.6496 28.5701 10 28.5701 10C28.5701 10 28.5677 5.35042 27.9727 3.12324Z"
|
||||||
|
fill="#FF0000"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M11.4253 14.2854L18.8477 10.0004L11.4253 5.71533V14.2854Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<g className="fill-yt-title">
|
||||||
|
<path d="M34.6024 13.0036L31.3945 1.41846H34.1932L35.3174 6.6701C35.6043 7.96361 35.8136 9.06662 35.95 9.97913H36.0323C36.1264 9.32532 36.3381 8.22937 36.665 6.68892L37.8291 1.41846H40.6278L37.3799 13.0036V18.561H34.6001V13.0036H34.6024Z" />
|
||||||
|
<path d="M41.4697 18.1937C40.9053 17.8127 40.5031 17.22 40.2632 16.4157C40.0257 15.6114 39.9058 14.5437 39.9058 13.2078V11.3898C39.9058 10.0422 40.0422 8.95805 40.315 8.14196C40.5878 7.32588 41.0135 6.72851 41.592 6.35457C42.1706 5.98063 42.9302 5.79248 43.871 5.79248C44.7976 5.79248 45.5384 5.98298 46.0981 6.36398C46.6555 6.74497 47.0647 7.34234 47.3234 8.15137C47.5821 8.96275 47.7115 10.0422 47.7115 11.3898V13.2078C47.7115 14.5437 47.5845 15.6161 47.3329 16.4251C47.0812 17.2365 46.672 17.8292 46.1075 18.2031C45.5431 18.5771 44.7764 18.7652 43.8098 18.7652C42.8126 18.7675 42.0342 18.5747 41.4697 18.1937ZM44.6353 16.2323C44.7905 15.8231 44.8705 15.1575 44.8705 14.2309V10.3292C44.8705 9.43077 44.7929 8.77225 44.6353 8.35833C44.4777 7.94206 44.2026 7.7351 43.8074 7.7351C43.4265 7.7351 43.156 7.94206 43.0008 8.35833C42.8432 8.77461 42.7656 9.43077 42.7656 10.3292V14.2309C42.7656 15.1575 42.8408 15.8254 42.9914 16.2323C43.1419 16.6415 43.4123 16.8461 43.8074 16.8461C44.2026 16.8461 44.4777 16.6415 44.6353 16.2323Z" />
|
||||||
|
<path d="M56.8154 18.5634H54.6094L54.3648 17.03H54.3037C53.7039 18.1871 52.8055 18.7656 51.6061 18.7656C50.7759 18.7656 50.1621 18.4928 49.767 17.9496C49.3719 17.4039 49.1743 16.5526 49.1743 15.3955V6.03751H51.9942V15.2308C51.9942 15.7906 52.0553 16.188 52.1776 16.4256C52.2999 16.6631 52.5045 16.783 52.7914 16.783C53.036 16.783 53.2712 16.7078 53.497 16.5573C53.7228 16.4067 53.8874 16.2162 53.9979 15.9858V6.03516H56.8154V18.5634Z" />
|
||||||
|
<path d="M64.4755 3.68758H61.6768V18.5629H58.9181V3.68758H56.1194V1.42041H64.4755V3.68758Z" />
|
||||||
|
<path d="M71.2768 18.5634H69.0708L68.8262 17.03H68.7651C68.1654 18.1871 67.267 18.7656 66.0675 18.7656C65.2373 18.7656 64.6235 18.4928 64.2284 17.9496C63.8333 17.4039 63.6357 16.5526 63.6357 15.3955V6.03751H66.4556V15.2308C66.4556 15.7906 66.5167 16.188 66.639 16.4256C66.7613 16.6631 66.9659 16.783 67.2529 16.783C67.4974 16.783 67.7326 16.7078 67.9584 16.5573C68.1842 16.4067 68.3488 16.2162 68.4593 15.9858V6.03516H71.2768V18.5634Z" />
|
||||||
|
<path d="M80.609 8.0387C80.4373 7.24849 80.1621 6.67699 79.7812 6.32186C79.4002 5.96674 78.8757 5.79035 78.2078 5.79035C77.6904 5.79035 77.2059 5.93616 76.7567 6.23014C76.3075 6.52412 75.9594 6.90747 75.7148 7.38489H75.6937V0.785645H72.9773V18.5608H75.3056L75.5925 17.3755H75.6537C75.8724 17.7988 76.1993 18.1304 76.6344 18.3774C77.0695 18.622 77.554 18.7443 78.0855 18.7443C79.038 18.7443 79.7412 18.3045 80.1904 17.4272C80.6396 16.5476 80.8653 15.1765 80.8653 13.3092V11.3266C80.8653 9.92722 80.7783 8.82892 80.609 8.0387ZM78.0243 13.1492C78.0243 14.0617 77.9867 14.7767 77.9114 15.2941C77.8362 15.8115 77.7115 16.1808 77.5328 16.3971C77.3564 16.6158 77.1165 16.724 76.8178 16.724C76.585 16.724 76.371 16.6699 76.1734 16.5594C75.9759 16.4512 75.816 16.2866 75.6937 16.0702V8.96062C75.7877 8.6196 75.9524 8.34209 76.1852 8.12337C76.4157 7.90465 76.6697 7.79646 76.9401 7.79646C77.2271 7.79646 77.4481 7.90935 77.6034 8.13278C77.7609 8.35855 77.8691 8.73485 77.9303 9.26636C77.9914 9.79787 78.022 10.5528 78.022 11.5335V13.1492H78.0243Z" />
|
||||||
|
<path d="M84.8657 13.8712C84.8657 14.6755 84.8892 15.2776 84.9363 15.6798C84.9833 16.0819 85.0821 16.3736 85.2326 16.5594C85.3831 16.7428 85.6136 16.8345 85.9264 16.8345C86.3474 16.8345 86.639 16.6699 86.7942 16.343C86.9518 16.0161 87.0365 15.4705 87.0506 14.7085L89.4824 14.8519C89.4965 14.9601 89.5035 15.1106 89.5035 15.3011C89.5035 16.4582 89.186 17.3237 88.5534 17.8952C87.9208 18.4667 87.0247 18.7536 85.8676 18.7536C84.4777 18.7536 83.504 18.3185 82.9466 17.446C82.3869 16.5735 82.1094 15.2259 82.1094 13.4008V11.2136C82.1094 9.33452 82.3987 7.96105 82.9772 7.09558C83.5558 6.2301 84.5459 5.79736 85.9499 5.79736C86.9165 5.79736 87.6597 5.97375 88.1771 6.32888C88.6945 6.684 89.059 7.23433 89.2707 7.98457C89.4824 8.7348 89.5882 9.76961 89.5882 11.0913V13.2362H84.8657V13.8712ZM85.2232 7.96811C85.0797 8.14449 84.9857 8.43377 84.9363 8.83593C84.8892 9.2381 84.8657 9.84722 84.8657 10.6657V11.5641H86.9283V10.6657C86.9283 9.86133 86.9001 9.25221 86.846 8.83593C86.7919 8.41966 86.6931 8.12803 86.5496 7.95635C86.4062 7.78702 86.1851 7.7 85.8864 7.7C85.5854 7.70235 85.3643 7.79172 85.2232 7.96811Z" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
<div className="flex gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Mobile content */}
|
||||||
|
<div className="divide-y divide-yt-border">
|
||||||
{combinedResults.slice(0, 6).map((video) => (
|
{combinedResults.slice(0, 6).map((video) => (
|
||||||
<YouTubeVideoCard
|
<div key={video.videoId} className="py-3">
|
||||||
key={video.videoId}
|
<YouTubeVideoCard
|
||||||
thumbnailUrl={video.thumbnailUrl}
|
thumbnailUrl={video.thumbnailUrl}
|
||||||
title={video.title}
|
title={video.title}
|
||||||
channelTitle={video.channelTitle}
|
channelTitle={video.channelTitle}
|
||||||
viewCount={video.viewCount}
|
viewCount={video.viewCount}
|
||||||
publishedAt={video.publishedAt}
|
publishedAt={video.publishedAt}
|
||||||
isUserThumbnail={video.isUser}
|
isUserThumbnail={video.isUser}
|
||||||
variant="search"
|
variant="mobile"
|
||||||
|
duration={video.duration}
|
||||||
|
isVerified={video.isVerified}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sidebar view - YouTube "Up next" style
|
||||||
if (viewMode === 'sidebar') {
|
if (viewMode === 'sidebar') {
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 rounded-xl p-4">
|
<div className="yt-surface max-w-[400px] rounded-xl border border-yt-border overflow-hidden">
|
||||||
<h3 className="text-white text-sm font-medium mb-4">Up next</h3>
|
<div className="px-4 py-3 border-b border-yt-border">
|
||||||
<div className="space-y-3">
|
<h2 className="yt-title font-medium">Up next</h2>
|
||||||
{combinedResults.slice(0, 8).map((video) => (
|
</div>
|
||||||
|
<div className="p-2 space-y-2">
|
||||||
|
{combinedResults.slice(0, 10).map((video) => (
|
||||||
<YouTubeVideoCard
|
<YouTubeVideoCard
|
||||||
key={video.videoId}
|
key={video.videoId}
|
||||||
thumbnailUrl={video.thumbnailUrl}
|
thumbnailUrl={video.thumbnailUrl}
|
||||||
@@ -88,6 +134,8 @@ export const PreviewGrid = () => {
|
|||||||
publishedAt={video.publishedAt}
|
publishedAt={video.publishedAt}
|
||||||
isUserThumbnail={video.isUser}
|
isUserThumbnail={video.isUser}
|
||||||
variant="sidebar"
|
variant="sidebar"
|
||||||
|
duration={video.duration}
|
||||||
|
isVerified={video.isVerified}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -95,45 +143,134 @@ export const PreviewGrid = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: Search view
|
// Desktop view - YouTube homepage/search grid style (default)
|
||||||
|
// Uses responsive breakpoints matching YouTube: 6→5→4→3→2→1 columns
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 rounded-xl p-4">
|
<div className="yt-surface rounded-xl border border-yt-border overflow-hidden">
|
||||||
{/* YouTube-like header */}
|
{/* YouTube header */}
|
||||||
<div className="flex items-center gap-4 mb-6 pb-4 border-b border-gray-800">
|
<div className="flex items-center justify-between px-4 py-2 border-b border-yt-border h-[56px]">
|
||||||
<svg className="w-8 h-8 text-red-500" viewBox="0 0 24 24" fill="currentColor">
|
<div className="flex items-center gap-4">
|
||||||
<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"/>
|
<button className="p-2 hover:bg-yt-hover rounded-full transition-colors">
|
||||||
</svg>
|
<Menu className="w-6 h-6 text-yt-title" strokeWidth={1} />
|
||||||
<div className="flex-1 max-w-2xl">
|
</button>
|
||||||
<div className="flex bg-gray-800 rounded-full overflow-hidden">
|
<div className="flex items-center gap-1 cursor-pointer" title="YouTube Home">
|
||||||
<input
|
<svg
|
||||||
type="text"
|
height="20"
|
||||||
value={usePreviewStore.getState().searchQuery}
|
viewBox="0 0 90 20"
|
||||||
readOnly
|
width="90"
|
||||||
className="flex-1 bg-transparent px-4 py-2 text-white outline-none"
|
focusable="false"
|
||||||
/>
|
style={{ width: '90px', height: '20px' }}
|
||||||
<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">
|
<g>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
<path
|
||||||
</svg>
|
d="M27.9727 3.12324C27.6435 1.89323 26.6768 0.926623 25.4468 0.597366C23.2197 2.24288e-07 14.285 0 14.285 0C14.285 0 5.35042 2.24288e-07 3.12323 0.597366C1.89323 0.926623 0.926623 1.89323 0.597366 3.12324C2.24288e-07 5.35042 0 10 0 10C0 10 2.24288e-07 14.6496 0.597366 16.8768C0.926623 18.1068 1.89323 19.0734 3.12323 19.4026C5.35042 20 14.285 20 14.285 20C14.285 20 23.2197 20 25.4468 19.4026C26.6768 19.0734 27.6435 18.1068 27.9727 16.8768C28.5701 14.6496 28.5701 10 28.5701 10C28.5701 10 28.5677 5.35042 27.9727 3.12324Z"
|
||||||
</button>
|
fill="#FF0000"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M11.4253 14.2854L18.8477 10.0004L11.4253 5.71533V14.2854Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<g className="fill-yt-title">
|
||||||
|
<path d="M34.6024 13.0036L31.3945 1.41846H34.1932L35.3174 6.6701C35.6043 7.96361 35.8136 9.06662 35.95 9.97913H36.0323C36.1264 9.32532 36.3381 8.22937 36.665 6.68892L37.8291 1.41846H40.6278L37.3799 13.0036V18.561H34.6001V13.0036H34.6024Z" />
|
||||||
|
<path d="M41.4697 18.1937C40.9053 17.8127 40.5031 17.22 40.2632 16.4157C40.0257 15.6114 39.9058 14.5437 39.9058 13.2078V11.3898C39.9058 10.0422 40.0422 8.95805 40.315 8.14196C40.5878 7.32588 41.0135 6.72851 41.592 6.35457C42.1706 5.98063 42.9302 5.79248 43.871 5.79248C44.7976 5.79248 45.5384 5.98298 46.0981 6.36398C46.6555 6.74497 47.0647 7.34234 47.3234 8.15137C47.5821 8.96275 47.7115 10.0422 47.7115 11.3898V13.2078C47.7115 14.5437 47.5845 15.6161 47.3329 16.4251C47.0812 17.2365 46.672 17.8292 46.1075 18.2031C45.5431 18.5771 44.7764 18.7652 43.8098 18.7652C42.8126 18.7675 42.0342 18.5747 41.4697 18.1937ZM44.6353 16.2323C44.7905 15.8231 44.8705 15.1575 44.8705 14.2309V10.3292C44.8705 9.43077 44.7929 8.77225 44.6353 8.35833C44.4777 7.94206 44.2026 7.7351 43.8074 7.7351C43.4265 7.7351 43.156 7.94206 43.0008 8.35833C42.8432 8.77461 42.7656 9.43077 42.7656 10.3292V14.2309C42.7656 15.1575 42.8408 15.8254 42.9914 16.2323C43.1419 16.6415 43.4123 16.8461 43.8074 16.8461C44.2026 16.8461 44.4777 16.6415 44.6353 16.2323Z" />
|
||||||
|
<path d="M56.8154 18.5634H54.6094L54.3648 17.03H54.3037C53.7039 18.1871 52.8055 18.7656 51.6061 18.7656C50.7759 18.7656 50.1621 18.4928 49.767 17.9496C49.3719 17.4039 49.1743 16.5526 49.1743 15.3955V6.03751H51.9942V15.2308C51.9942 15.7906 52.0553 16.188 52.1776 16.4256C52.2999 16.6631 52.5045 16.783 52.7914 16.783C53.036 16.783 53.2712 16.7078 53.497 16.5573C53.7228 16.4067 53.8874 16.2162 53.9979 15.9858V6.03516H56.8154V18.5634Z" />
|
||||||
|
<path d="M64.4755 3.68758H61.6768V18.5629H58.9181V3.68758H56.1194V1.42041H64.4755V3.68758Z" />
|
||||||
|
<path d="M71.2768 18.5634H69.0708L68.8262 17.03H68.7651C68.1654 18.1871 67.267 18.7656 66.0675 18.7656C65.2373 18.7656 64.6235 18.4928 64.2284 17.9496C63.8333 17.4039 63.6357 16.5526 63.6357 15.3955V6.03751H66.4556V15.2308C66.4556 15.7906 66.5167 16.188 66.639 16.4256C66.7613 16.6631 66.9659 16.783 67.2529 16.783C67.4974 16.783 67.7326 16.7078 67.9584 16.5573C68.1842 16.4067 68.3488 16.2162 68.4593 15.9858V6.03516H71.2768V18.5634Z" />
|
||||||
|
<path d="M80.609 8.0387C80.4373 7.24849 80.1621 6.67699 79.7812 6.32186C79.4002 5.96674 78.8757 5.79035 78.2078 5.79035C77.6904 5.79035 77.2059 5.93616 76.7567 6.23014C76.3075 6.52412 75.9594 6.90747 75.7148 7.38489H75.6937V0.785645H72.9773V18.5608H75.3056L75.5925 17.3755H75.6537C75.8724 17.7988 76.1993 18.1304 76.6344 18.3774C77.0695 18.622 77.554 18.7443 78.0855 18.7443C79.038 18.7443 79.7412 18.3045 80.1904 17.4272C80.6396 16.5476 80.8653 15.1765 80.8653 13.3092V11.3266C80.8653 9.92722 80.7783 8.82892 80.609 8.0387ZM78.0243 13.1492C78.0243 14.0617 77.9867 14.7767 77.9114 15.2941C77.8362 15.8115 77.7115 16.1808 77.5328 16.3971C77.3564 16.6158 77.1165 16.724 76.8178 16.724C76.585 16.724 76.371 16.6699 76.1734 16.5594C75.9759 16.4512 75.816 16.2866 75.6937 16.0702V8.96062C75.7877 8.6196 75.9524 8.34209 76.1852 8.12337C76.4157 7.90465 76.6697 7.79646 76.9401 7.79646C77.2271 7.79646 77.4481 7.90935 77.6034 8.13278C77.7609 8.35855 77.8691 8.73485 77.9303 9.26636C77.9914 9.79787 78.022 10.5528 78.022 11.5335V13.1492H78.0243Z" />
|
||||||
|
<path d="M84.8657 13.8712C84.8657 14.6755 84.8892 15.2776 84.9363 15.6798C84.9833 16.0819 85.0821 16.3736 85.2326 16.5594C85.3831 16.7428 85.6136 16.8345 85.9264 16.8345C86.3474 16.8345 86.639 16.6699 86.7942 16.343C86.9518 16.0161 87.0365 15.4705 87.0506 14.7085L89.4824 14.8519C89.4965 14.9601 89.5035 15.1106 89.5035 15.3011C89.5035 16.4582 89.186 17.3237 88.5534 17.8952C87.9208 18.4667 87.0247 18.7536 85.8676 18.7536C84.4777 18.7536 83.504 18.3185 82.9466 17.446C82.3869 16.5735 82.1094 15.2259 82.1094 13.4008V11.2136C82.1094 9.33452 82.3987 7.96105 82.9772 7.09558C83.5558 6.2301 84.5459 5.79736 85.9499 5.79736C86.9165 5.79736 87.6597 5.97375 88.1771 6.32888C88.6945 6.684 89.059 7.23433 89.2707 7.98457C89.4824 8.7348 89.5882 9.76961 89.5882 11.0913V13.2362H84.8657V13.8712ZM85.2232 7.96811C85.0797 8.14449 84.9857 8.43377 84.9363 8.83593C84.8892 9.2381 84.8657 9.84722 84.8657 10.6657V11.5641H86.9283V10.6657C86.9283 9.86133 86.9001 9.25221 86.846 8.83593C86.7919 8.41966 86.6931 8.12803 86.5496 7.95635C86.4062 7.78702 86.1851 7.7 85.8864 7.7C85.5854 7.70235 85.3643 7.79172 85.2232 7.96811Z" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<span className="text-[10px] text-yt-meta mb-auto ml-1 mt-1">PL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 max-w-[720px] mx-10 hidden sm:flex items-center gap-4">
|
||||||
|
<div className="flex flex-1">
|
||||||
|
<div className="flex flex-1 items-center">
|
||||||
|
<div className="flex flex-1 relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={usePreviewStore.getState().searchQuery}
|
||||||
|
readOnly
|
||||||
|
className="w-full h-10 px-4 pl-4 bg-transparent border border-yt-border rounded-l-full text-yt-title text-base focus:outline-none focus:border-blue-500 shadow-inner ml-8"
|
||||||
|
placeholder="Search"
|
||||||
|
style={{ boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.05)' }}
|
||||||
|
/>
|
||||||
|
<div className="absolute left-0 top-0 h-full hidden pl-4 items-center">
|
||||||
|
<Search className="w-5 h-5 text-yt-meta" strokeWidth={1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="h-10 px-6 bg-yt-chip-bg border border-l-0 border-yt-border rounded-r-full hover:bg-yt-hover transition-colors flex items-center justify-center">
|
||||||
|
<Search className="w-5 h-5 text-yt-title" strokeWidth={1} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="w-10 h-10 bg-yt-chip-bg hover:bg-yt-hover rounded-full flex items-center justify-center transition-colors">
|
||||||
|
<Mic className="w-5 h-5 text-yt-title" strokeWidth={1} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
|
<button className="p-2 hover:bg-yt-hover rounded-full transition-colors hidden sm:block">
|
||||||
|
<Video className="w-6 h-6 text-yt-title" strokeWidth={1} />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 hover:bg-yt-hover rounded-full transition-colors hidden sm:block">
|
||||||
|
<Bell className="w-6 h-6 text-yt-title" strokeWidth={1} />
|
||||||
|
</button>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-purple-600 flex items-center justify-center text-white text-sm font-medium cursor-pointer">
|
||||||
|
A
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results grid */}
|
{/* Filter chips */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="flex gap-3 px-4 py-3 border-b border-yt-border overflow-x-auto">
|
||||||
{combinedResults.map((video) => (
|
<button className="px-3 py-1.5 bg-yt-title text-yt-bg rounded-lg text-sm font-medium whitespace-nowrap">
|
||||||
<YouTubeVideoCard
|
All
|
||||||
key={video.videoId}
|
</button>
|
||||||
thumbnailUrl={video.thumbnailUrl}
|
<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">
|
||||||
title={video.title}
|
Videos
|
||||||
channelTitle={video.channelTitle}
|
</button>
|
||||||
viewCount={video.viewCount}
|
<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">
|
||||||
publishedAt={video.publishedAt}
|
Channels
|
||||||
isUserThumbnail={video.isUser}
|
</button>
|
||||||
variant="search"
|
<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 with YouTube responsive breakpoints */}
|
||||||
|
{/* 6 cols @ >2136px, 5 cols @ 1712-2136px, 4 cols @ 1288-1712px, 3 cols @ 888-1288px, 2 cols @ 512-888px, 1 col @ <512px */}
|
||||||
|
<div className="p-4">
|
||||||
|
<div
|
||||||
|
className="grid gap-x-4 gap-y-10"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns:
|
||||||
|
'repeat(auto-fill, minmax(min(100%, 320px), 1fr))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{combinedResults.map((video) => (
|
||||||
|
<YouTubeVideoCard
|
||||||
|
key={video.videoId}
|
||||||
|
thumbnailUrl={video.thumbnailUrl}
|
||||||
|
title={video.title}
|
||||||
|
channelTitle={video.channelTitle}
|
||||||
|
viewCount={video.viewCount}
|
||||||
|
publishedAt={video.publishedAt}
|
||||||
|
isUserThumbnail={video.isUser}
|
||||||
|
variant="desktop"
|
||||||
|
duration={video.duration}
|
||||||
|
isVerified={video.isVerified}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,83 +1,63 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Search, ArrowRight } from 'lucide-react';
|
||||||
import { usePreviewStore } from '../store/previewStore';
|
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 = () => {
|
export const SearchInput = () => {
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const { searchQuery, setSearchQuery, setYoutubeResults } = usePreviewStore();
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { setSearchQuery, setYoutubeResults } = usePreviewStore();
|
||||||
const { isLoading, refetch } = useYouTubeSearch(searchQuery, !!searchQuery);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!inputValue.trim()) return;
|
if (!inputValue.trim() || isLoading) return;
|
||||||
|
|
||||||
setSearchQuery(inputValue.trim());
|
const query = inputValue.trim();
|
||||||
const result = await refetch();
|
setIsLoading(true);
|
||||||
if (result.data) {
|
|
||||||
setYoutubeResults(result.data);
|
try {
|
||||||
|
setSearchQuery(query);
|
||||||
|
const results = await searchYouTube(query);
|
||||||
|
setYoutubeResults(results);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex gap-3">
|
<form onSubmit={handleSubmit} className="flex gap-3">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1 group">
|
||||||
<svg
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 size-5 text-muted-foreground transition-colors group-focus-within:text-foreground" />
|
||||||
className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500"
|
<Input
|
||||||
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
|
|
||||||
type="text"
|
type="text"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
placeholder="Enter search query (e.g., react tutorial)"
|
placeholder="Search for competitors (e.g., react tutorial)"
|
||||||
className="w-full pl-12 pr-4 py-3 bg-gray-800 border border-gray-700 rounded-lg
|
className="pl-12 h-11 bg-surface-1 border-border focus:border-primary focus:glow-border transition-all"
|
||||||
text-white placeholder-gray-500
|
|
||||||
focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500
|
|
||||||
transition-colors"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading || !inputValue.trim()}
|
disabled={isLoading || !inputValue.trim()}
|
||||||
className="px-6 py-3 bg-red-600 hover:bg-red-700 disabled:bg-gray-700
|
size="lg"
|
||||||
disabled:cursor-not-allowed text-white font-medium rounded-lg
|
className="h-11 px-6 gap-2"
|
||||||
transition-colors flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<svg className="animate-spin w-5 h-5" viewBox="0 0 24 24">
|
<Spinner className="size-4" />
|
||||||
<circle
|
<span>Searching</span>
|
||||||
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...
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Search'
|
<>
|
||||||
|
<span>Search</span>
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
42
frontend/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Sun, Moon } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { ThemeMode } from '../types';
|
||||||
|
|
||||||
|
interface ThemeToggleProps {
|
||||||
|
/** Current theme mode */
|
||||||
|
theme: ThemeMode;
|
||||||
|
/** Callback when theme changes */
|
||||||
|
onThemeChange: (theme: ThemeMode) => void;
|
||||||
|
/** Optional className for styling */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme toggle component for switching between light and dark modes
|
||||||
|
* Matches YouTube's manual theme toggle behavior (no auto-detection)
|
||||||
|
*/
|
||||||
|
export function ThemeToggle({ theme, onThemeChange, className }: ThemeToggleProps) {
|
||||||
|
const toggleTheme = () => {
|
||||||
|
onThemeChange(theme === 'light' ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg hover:bg-muted transition-colors',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? (
|
||||||
|
<Sun className="size-5" />
|
||||||
|
) : (
|
||||||
|
<Moon className="size-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import { X, Plus } from 'lucide-react';
|
||||||
import { usePreviewStore } from '../store/previewStore';
|
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 = () => {
|
export const ThumbnailSelector = () => {
|
||||||
const { thumbnails, activeThumbnailIndex, setActiveThumbnailIndex, removeThumbnail } =
|
const { thumbnails, activeThumbnailIndex, setActiveThumbnailIndex, removeThumbnail } =
|
||||||
@@ -7,77 +11,63 @@ export const ThumbnailSelector = () => {
|
|||||||
if (thumbnails.length === 0) return null;
|
if (thumbnails.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium text-gray-400">Your Thumbnails</h3>
|
<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) => (
|
{thumbnails.map((thumbnail, index) => (
|
||||||
<div
|
<div
|
||||||
key={thumbnail.id}
|
key={thumbnail.id}
|
||||||
className={`
|
className={cn(
|
||||||
relative group cursor-pointer rounded-lg overflow-hidden
|
'relative group cursor-pointer rounded-xl overflow-hidden transition-all duration-200',
|
||||||
border-2 transition-all
|
index === activeThumbnailIndex
|
||||||
${index === activeThumbnailIndex
|
? 'ring-2 ring-primary glow-sm scale-[1.02]'
|
||||||
? 'border-red-500 ring-2 ring-red-500/30'
|
: 'ring-1 ring-border hover:ring-muted-foreground'
|
||||||
: 'border-transparent hover:border-gray-600'
|
)}
|
||||||
}
|
|
||||||
`}
|
|
||||||
onClick={() => setActiveThumbnailIndex(index)}
|
onClick={() => setActiveThumbnailIndex(index)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={thumbnail.url}
|
src={thumbnail.url}
|
||||||
alt={thumbnail.originalName}
|
alt={thumbnail.originalName}
|
||||||
className="w-32 h-18 object-cover"
|
className="w-36 h-20 object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Index badge */}
|
<Badge
|
||||||
<div className="absolute top-1 left-1 w-6 h-6 rounded bg-black/70
|
variant="secondary"
|
||||||
flex items-center justify-center text-xs font-bold text-white">
|
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)}
|
{String.fromCharCode(65 + index)}
|
||||||
</div>
|
</Badge>
|
||||||
|
|
||||||
{/* Delete button */}
|
<Button
|
||||||
<button
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
removeThumbnail(thumbnail.id);
|
removeThumbnail(thumbnail.id);
|
||||||
}}
|
}}
|
||||||
className="absolute top-1 right-1 w-6 h-6 rounded bg-black/70
|
className="absolute top-1.5 right-1.5 bg-black/70 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive"
|
||||||
flex items-center justify-center text-white
|
|
||||||
opacity-0 group-hover:opacity-100 transition-opacity
|
|
||||||
hover:bg-red-600"
|
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<X className="size-3" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
</Button>
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Active indicator */}
|
|
||||||
{index === activeThumbnailIndex && (
|
{index === activeThumbnailIndex && (
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-red-500 text-white
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-accent text-white text-xs text-center py-1 font-medium">
|
||||||
text-xs text-center py-0.5 font-medium">
|
|
||||||
Active
|
Active
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Add more button */}
|
|
||||||
{thumbnails.length < 5 && (
|
{thumbnails.length < 5 && (
|
||||||
<label className="w-32 h-18 border-2 border-dashed border-gray-700 rounded-lg
|
<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">
|
||||||
flex items-center justify-center cursor-pointer
|
|
||||||
hover:border-gray-600 transition-colors">
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
accept="image/jpeg,image/png,image/webp"
|
accept="image/jpeg,image/png,image/webp"
|
||||||
onChange={() => {
|
onChange={() => {}}
|
||||||
// Handle via parent component or hook
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<svg className="w-8 h-8 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Plus className="size-6 text-muted-foreground" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone, type FileRejection } from 'react-dropzone';
|
||||||
|
import { ImageIcon, Upload, AlertCircle, X } from 'lucide-react';
|
||||||
import { useUploadThumbnail } from '../hooks/useThumbnails';
|
import { useUploadThumbnail } from '../hooks/useThumbnails';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { VALIDATION_CONSTANTS } from '../types';
|
||||||
|
|
||||||
|
interface ValidationError {
|
||||||
|
message: string;
|
||||||
|
type: 'size' | 'format' | 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
export const ThumbnailUploader = () => {
|
export const ThumbnailUploader = () => {
|
||||||
const { mutate: upload, isPending } = useUploadThumbnail();
|
const { mutate: upload, isPending } = useUploadThumbnail();
|
||||||
|
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([]);
|
||||||
|
|
||||||
|
const clearErrors = () => setValidationErrors([]);
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(acceptedFiles: File[]) => {
|
(acceptedFiles: File[]) => {
|
||||||
|
clearErrors();
|
||||||
acceptedFiles.forEach((file) => {
|
acceptedFiles.forEach((file) => {
|
||||||
upload(file);
|
upload(file);
|
||||||
});
|
});
|
||||||
@@ -14,65 +28,126 @@ export const ThumbnailUploader = () => {
|
|||||||
[upload]
|
[upload]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onDropRejected = useCallback((fileRejections: FileRejection[]) => {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
|
||||||
|
fileRejections.forEach((rejection) => {
|
||||||
|
rejection.errors.forEach((error) => {
|
||||||
|
if (error.code === 'file-too-large') {
|
||||||
|
errors.push({
|
||||||
|
message: `File "${rejection.file.name}" exceeds 5MB limit. Please reduce file size.`,
|
||||||
|
type: 'size',
|
||||||
|
});
|
||||||
|
} else if (error.code === 'file-invalid-type') {
|
||||||
|
errors.push({
|
||||||
|
message: `File "${rejection.file.name}" has unsupported format. Accepted: JPG, PNG, WebP.`,
|
||||||
|
type: 'format',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
errors.push({
|
||||||
|
message: `File "${rejection.file.name}": ${error.message}`,
|
||||||
|
type: 'unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setValidationErrors(errors);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
onDrop,
|
onDrop,
|
||||||
|
onDropRejected,
|
||||||
accept: {
|
accept: {
|
||||||
'image/jpeg': ['.jpg', '.jpeg'],
|
'image/jpeg': ['.jpg', '.jpeg'],
|
||||||
'image/png': ['.png'],
|
'image/png': ['.png'],
|
||||||
'image/webp': ['.webp'],
|
'image/webp': ['.webp'],
|
||||||
},
|
},
|
||||||
maxSize: 5 * 1024 * 1024, // 5MB
|
maxSize: VALIDATION_CONSTANTS.MAX_FILE_SIZE,
|
||||||
multiple: true,
|
multiple: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="space-y-3">
|
||||||
{...getRootProps()}
|
{/* Validation Errors */}
|
||||||
className={`
|
{validationErrors.length > 0 && (
|
||||||
border-2 border-dashed rounded-xl p-8 text-center cursor-pointer
|
<div className="space-y-2">
|
||||||
transition-all duration-200
|
{validationErrors.map((error, index) => (
|
||||||
${isDragActive
|
<div
|
||||||
? 'border-red-500 bg-red-500/10'
|
key={index}
|
||||||
: 'border-gray-600 hover:border-gray-500 bg-gray-800/50'
|
className={cn(
|
||||||
}
|
'flex items-start gap-3 p-3 rounded-lg text-sm',
|
||||||
${isPending ? 'opacity-50 pointer-events-none' : ''}
|
error.type === 'size' && 'bg-destructive/10 text-destructive',
|
||||||
`}
|
error.type === 'format' && 'bg-warning/10 text-warning',
|
||||||
>
|
error.type === 'unknown' && 'bg-muted text-muted-foreground'
|
||||||
<input {...getInputProps()} />
|
)}
|
||||||
|
>
|
||||||
<div className="flex flex-col items-center gap-4">
|
<AlertCircle className="size-4 flex-shrink-0 mt-0.5" />
|
||||||
<div className="w-16 h-16 rounded-full bg-gray-700 flex items-center justify-center">
|
<span className="flex-1">{error.message}</span>
|
||||||
<svg
|
<button
|
||||||
className="w-8 h-8 text-gray-400"
|
onClick={() => {
|
||||||
fill="none"
|
setValidationErrors((prev) =>
|
||||||
stroke="currentColor"
|
prev.filter((_, i) => i !== index)
|
||||||
viewBox="0 0 24 24"
|
);
|
||||||
>
|
}}
|
||||||
<path
|
className="p-0.5 hover:bg-black/10 rounded transition-colors"
|
||||||
strokeLinecap="round"
|
aria-label="Dismiss error"
|
||||||
strokeLinejoin="round"
|
>
|
||||||
strokeWidth={2}
|
<X className="size-3" />
|
||||||
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"
|
</button>
|
||||||
/>
|
</div>
|
||||||
</svg>
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isPending ? (
|
{/* Upload Area */}
|
||||||
<p className="text-gray-400">Uploading...</p>
|
<Card
|
||||||
) : isDragActive ? (
|
{...getRootProps()}
|
||||||
<p className="text-red-400 font-medium">Drop your thumbnail here</p>
|
className={cn(
|
||||||
) : (
|
'border border-dashed rounded-xl p-13.75 text-center cursor-pointer transition-all duration-300',
|
||||||
<>
|
'bg-surface-1 hover:bg-surface-2',
|
||||||
<p className="text-gray-300 font-medium">
|
isDragActive
|
||||||
Drag & drop your thumbnail here
|
? 'border-primary bg-primary/5 glow-sm'
|
||||||
</p>
|
: 'border-border hover:border-muted-foreground',
|
||||||
<p className="text-gray-500 text-sm">or click to browse</p>
|
isPending && 'opacity-50 pointer-events-none',
|
||||||
<p className="text-gray-600 text-xs">
|
validationErrors.length > 0 && 'border-destructive/50'
|
||||||
Recommended: 1280x720 (16:9) • JPG, PNG, WebP • Max 5MB
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-5">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'size-16 rounded-2xl flex items-center justify-center transition-all duration-300',
|
||||||
|
isDragActive ? 'bg-primary/10 glow-sm' : 'bg-surface-3'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Spinner className="size-7" />
|
||||||
|
) : isDragActive ? (
|
||||||
|
<Upload className="size-7 text-primary" />
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="size-7 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPending ? (
|
||||||
|
<p className="text-muted-foreground">Uploading...</p>
|
||||||
|
) : isDragActive ? (
|
||||||
|
<p className="text-primary font-medium">Drop your thumbnail here</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-foreground font-medium">
|
||||||
|
Drag & drop your thumbnail
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-sm">or click to browse</p>
|
||||||
|
<p className="text-muted-foreground/60 text-xs pt-2">
|
||||||
|
1280×720 (16:9) • JPG, PNG, WebP • Max 5MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,38 +1,107 @@
|
|||||||
import { usePreviewStore } from '../store/previewStore';
|
import { usePreviewStore } from '../store/previewStore';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { formatViewCount } from '@/lib/youtube-styles';
|
||||||
|
|
||||||
export const UserInfoInputs = () => {
|
export const UserInfoInputs = () => {
|
||||||
const { userTitle, userChannel, setUserTitle, setUserChannel } = usePreviewStore();
|
const { metadata, setMetadata, setUserTitle, setUserChannel } = usePreviewStore();
|
||||||
|
|
||||||
|
const handleTitleChange = (value: string) => {
|
||||||
|
setUserTitle(value);
|
||||||
|
setMetadata({ title: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChannelChange = (value: string) => {
|
||||||
|
setUserChannel(value);
|
||||||
|
setMetadata({ channelName: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewCountChange = (value: string) => {
|
||||||
|
const numValue = parseInt(value.replace(/[^0-9]/g, ''), 10) || 0;
|
||||||
|
setMetadata({ viewCount: numValue });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDurationChange = (value: string) => {
|
||||||
|
// Allow partial input during typing (just digits and colons)
|
||||||
|
const sanitized = value.replace(/[^0-9:]/g, '');
|
||||||
|
setMetadata({ duration: sanitized });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
<div>
|
{/* Video Title */}
|
||||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="video-title" className="text-muted-foreground text-sm">
|
||||||
Video Title
|
Video Title
|
||||||
</label>
|
</Label>
|
||||||
<input
|
<Input
|
||||||
|
id="video-title"
|
||||||
type="text"
|
type="text"
|
||||||
value={userTitle}
|
value={metadata.title}
|
||||||
onChange={(e) => setUserTitle(e.target.value)}
|
onChange={(e) => handleTitleChange(e.target.value)}
|
||||||
placeholder="Your Video Title Here"
|
placeholder="Your Video Title Here"
|
||||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg
|
maxLength={100}
|
||||||
text-white placeholder-gray-500
|
className="bg-surface-1 border-border focus:border-primary transition-colors"
|
||||||
focus:outline-none focus:border-red-500"
|
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground/60">
|
||||||
|
{metadata.title.length}/100 characters
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Channel Name */}
|
||||||
<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
|
Channel Name
|
||||||
</label>
|
</Label>
|
||||||
<input
|
<Input
|
||||||
|
id="channel-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={userChannel}
|
value={metadata.channelName}
|
||||||
onChange={(e) => setUserChannel(e.target.value)}
|
onChange={(e) => handleChannelChange(e.target.value)}
|
||||||
placeholder="Your Channel"
|
placeholder="Your Channel"
|
||||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg
|
maxLength={50}
|
||||||
text-white placeholder-gray-500
|
className="bg-surface-1 border-border focus:border-primary transition-colors"
|
||||||
focus:outline-none focus:border-red-500"
|
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground/60">
|
||||||
|
{metadata.channelName.length}/50 characters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Count */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="view-count" className="text-muted-foreground text-sm">
|
||||||
|
View Count
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="view-count"
|
||||||
|
type="text"
|
||||||
|
value={metadata.viewCount.toLocaleString()}
|
||||||
|
onChange={(e) => handleViewCountChange(e.target.value)}
|
||||||
|
placeholder="125000"
|
||||||
|
className="bg-surface-1 border-border focus:border-primary transition-colors"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground/60">
|
||||||
|
Displays as: {formatViewCount(metadata.viewCount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="duration" className="text-muted-foreground text-sm">
|
||||||
|
Duration
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="duration"
|
||||||
|
type="text"
|
||||||
|
value={metadata.duration}
|
||||||
|
onChange={(e) => handleDurationChange(e.target.value)}
|
||||||
|
placeholder="10:30"
|
||||||
|
maxLength={8}
|
||||||
|
className="bg-surface-1 border-border focus:border-primary transition-colors"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground/60">
|
||||||
|
Format: MM:SS or H:MM:SS
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
46
frontend/src/components/UserMenu.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { LogOut, User as UserIcon } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
export function UserMenu() {
|
||||||
|
const { user, logout, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
window.location.href = '/login';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={user.avatarUrl}
|
||||||
|
alt={user.displayName}
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
|
||||||
|
<UserIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="hidden text-sm font-medium sm:inline">
|
||||||
|
{user.displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleLogout}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Sign out</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,37 +1,23 @@
|
|||||||
import type { ReactNode } from 'react';
|
import { Search, Menu, Smartphone } from 'lucide-react';
|
||||||
import { usePreviewStore } from '../store/previewStore';
|
import { usePreviewStore } from '../store/previewStore';
|
||||||
import type { ViewMode } from '../types';
|
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',
|
id: 'search',
|
||||||
label: 'Search',
|
label: 'Search',
|
||||||
icon: (
|
icon: <Search className="size-4" />,
|
||||||
<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>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sidebar',
|
id: 'sidebar',
|
||||||
label: 'Sidebar',
|
label: 'Sidebar',
|
||||||
icon: (
|
icon: <Menu className="size-4" />,
|
||||||
<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>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mobile',
|
id: 'mobile',
|
||||||
label: 'Mobile',
|
label: 'Mobile',
|
||||||
icon: (
|
icon: <Smartphone className="size-4" />,
|
||||||
<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>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -39,24 +25,27 @@ export const ViewSwitcher = () => {
|
|||||||
const { viewMode, setViewMode } = usePreviewStore();
|
const { viewMode, setViewMode } = usePreviewStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex bg-gray-800 rounded-lg p-1">
|
<div className="flex items-center gap-3">
|
||||||
{views.map((view) => (
|
<span className="text-sm text-muted-foreground">View:</span>
|
||||||
<button
|
<ToggleGroup
|
||||||
key={view.id}
|
type="single"
|
||||||
onClick={() => setViewMode(view.id)}
|
value={viewMode}
|
||||||
className={`
|
onValueChange={(value) => value && setViewMode(value as ViewMode)}
|
||||||
flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium
|
variant="outline"
|
||||||
transition-colors
|
className="bg-surface-1 p-1 rounded-lg border border-border"
|
||||||
${viewMode === view.id
|
>
|
||||||
? 'bg-gray-700 text-white'
|
{views.map((view) => (
|
||||||
: 'text-gray-400 hover:text-white'
|
<ToggleGroupItem
|
||||||
}
|
key={view.id}
|
||||||
`}
|
value={view.id}
|
||||||
>
|
aria-label={view.label}
|
||||||
{view.icon}
|
className="data-[state=on]:bg-surface-3 data-[state=on]:text-foreground rounded-md transition-colors min-w-27.5 justify-center cursor-pointer"
|
||||||
{view.label}
|
>
|
||||||
</button>
|
{view.icon}
|
||||||
))}
|
<span className="ml-2">{view.label}</span>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,69 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Clock, ListPlus, MoreVertical } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { PreviewMode } from '../types';
|
||||||
|
|
||||||
|
// YouTube official verified badge SVG
|
||||||
|
const VerifiedBadge = ({ className }: { className?: string }) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
focusable="false"
|
||||||
|
className={className}
|
||||||
|
style={{ pointerEvents: 'none', display: 'inline-block' }}
|
||||||
|
>
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zM9.8 17.3l-4.2-4.1L7 11.8l2.8 2.7L17 7.4l1.4 1.4-8.6 8.5z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// YouTube-like avatar colors based on channel name
|
||||||
|
const AVATAR_COLORS = [
|
||||||
|
'#ef4444', // red
|
||||||
|
'#f97316', // orange
|
||||||
|
'#eab308', // yellow
|
||||||
|
'#22c55e', // green
|
||||||
|
'#14b8a6', // teal
|
||||||
|
'#3b82f6', // blue
|
||||||
|
'#8b5cf6', // violet
|
||||||
|
'#ec4899', // pink
|
||||||
|
'#6366f1', // indigo
|
||||||
|
'#06b6d4', // cyan
|
||||||
|
];
|
||||||
|
|
||||||
|
function getChannelColor(channelName: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < channelName.length; i++) {
|
||||||
|
hash = channelName.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
interface YouTubeVideoCardProps {
|
interface YouTubeVideoCardProps {
|
||||||
|
/** Thumbnail image URL or data URL */
|
||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
|
/** Video title */
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Channel name */
|
||||||
channelTitle: string;
|
channelTitle: string;
|
||||||
|
/** Formatted view count (e.g., "1.2M views") */
|
||||||
viewCount?: string;
|
viewCount?: string;
|
||||||
|
/** Relative time string (e.g., "3 days ago") */
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
|
/** Whether this is user's uploaded thumbnail (for highlighting) */
|
||||||
isUserThumbnail?: boolean;
|
isUserThumbnail?: boolean;
|
||||||
variant?: 'search' | 'sidebar';
|
/** Layout variant */
|
||||||
|
variant?: PreviewMode | 'search';
|
||||||
|
/** Video duration string */
|
||||||
|
duration?: string;
|
||||||
|
/** Optional channel avatar URL */
|
||||||
|
channelAvatarUrl?: string;
|
||||||
|
/** Whether the channel is verified */
|
||||||
|
isVerified?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const YouTubeVideoCard = ({
|
export const YouTubeVideoCard = ({
|
||||||
@@ -14,90 +72,380 @@ export const YouTubeVideoCard = ({
|
|||||||
channelTitle,
|
channelTitle,
|
||||||
viewCount,
|
viewCount,
|
||||||
publishedAt,
|
publishedAt,
|
||||||
isUserThumbnail = false,
|
variant = 'desktop',
|
||||||
variant = 'search',
|
duration = '10:30',
|
||||||
|
channelAvatarUrl,
|
||||||
|
isVerified = false,
|
||||||
}: YouTubeVideoCardProps) => {
|
}: YouTubeVideoCardProps) => {
|
||||||
const formatViews = (views?: string) => {
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
if (!views) return '';
|
|
||||||
const num = parseInt(views);
|
|
||||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M views`;
|
|
||||||
if (num >= 1000) return `${(num / 1000).toFixed(0)}K views`;
|
|
||||||
return `${num} views`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date?: string) => {
|
// Format view count from string or use as-is
|
||||||
if (!date) return '';
|
const formattedViews = viewCount || 'No views';
|
||||||
const diff = Date.now() - new Date(date).getTime();
|
|
||||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
||||||
if (days < 1) return 'Today';
|
|
||||||
if (days < 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`;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Format date if it's a date string
|
||||||
|
const formattedTime = publishedAt || '';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sidebar Variant - YouTube "Up next" style (168x94px thumbnail)
|
||||||
|
// No avatar, horizontal layout, compact spacing
|
||||||
|
// ============================================================================
|
||||||
if (variant === 'sidebar') {
|
if (variant === 'sidebar') {
|
||||||
return (
|
return (
|
||||||
<div className={`flex gap-2 group cursor-pointer ${isUserThumbnail ? 'ring-2 ring-red-500 rounded-lg p-1' : ''}`}>
|
<div className="yt-card flex cursor-pointer group" style={{ gap: '8px' }}>
|
||||||
<div className="relative flex-shrink-0">
|
{/* Thumbnail - 168x94px, 8px radius */}
|
||||||
|
<div
|
||||||
|
className="relative flex-shrink-0 overflow-hidden bg-yt-hover"
|
||||||
|
style={{
|
||||||
|
width: '168px',
|
||||||
|
height: '94px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={thumbnailUrl}
|
src={thumbnailUrl}
|
||||||
alt={title}
|
alt={title}
|
||||||
className="w-40 h-[90px] object-cover rounded-lg"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
{isUserThumbnail && (
|
{/* Duration badge - bottom-right, 4px radius */}
|
||||||
<div className="absolute top-1 left-1 bg-red-500 text-white text-xs px-1.5 py-0.5 rounded font-bold">
|
<span
|
||||||
YOUR
|
className="absolute text-white"
|
||||||
</div>
|
style={{
|
||||||
)}
|
bottom: '4px',
|
||||||
|
right: '4px',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
padding: '3px 4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
lineHeight: 'normal',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{duration}
|
||||||
|
</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">
|
{/* Metadata - right side, no avatar */}
|
||||||
|
<div className="flex-1 min-w-0 pr-6 relative">
|
||||||
|
{/* Title - 14px/500, 2-line clamp */}
|
||||||
|
<h3
|
||||||
|
className="yt-title line-clamp-2"
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: '20px',
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h4>
|
</h3>
|
||||||
<p className="text-xs text-gray-400 mt-1">{channelTitle}</p>
|
{/* Channel name - 12px, meta color */}
|
||||||
<p className="text-xs text-gray-500">
|
<div className="flex items-center flex-wrap">
|
||||||
{formatViews(viewCount)} • {formatDate(publishedAt)}
|
<p
|
||||||
|
className="yt-meta"
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '18px',
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channelTitle}
|
||||||
|
</p>
|
||||||
|
{isVerified && (
|
||||||
|
<VerifiedBadge className="w-3.5 h-3.5 ml-1 text-yt-meta" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Views and time */}
|
||||||
|
<p
|
||||||
|
className="yt-meta"
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '18px',
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formattedViews} · {formattedTime}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Action Menu (3 dots) */}
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0 right-[-8px] p-1 opacity-0 group-hover:opacity-100 transition-opacity',
|
||||||
|
'hover:bg-yt-hover rounded-full'
|
||||||
|
)}
|
||||||
|
aria-label="Action menu"
|
||||||
|
>
|
||||||
|
<MoreVertical className="size-4 text-yt-title" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mobile Variant - Full-width thumbnail, 0px radius (edge-to-edge)
|
||||||
|
// Avatar below, 36x36px
|
||||||
|
// ============================================================================
|
||||||
|
if (variant === 'mobile') {
|
||||||
|
return (
|
||||||
|
<div className="yt-card cursor-pointer">
|
||||||
|
{/* Thumbnail - 100% width, 16:9, 0px radius */}
|
||||||
|
<div
|
||||||
|
className="relative w-full bg-yt-hover overflow-hidden"
|
||||||
|
style={{
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
borderRadius: '0px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={thumbnailUrl}
|
||||||
|
alt={title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
{/* Duration badge */}
|
||||||
|
<span
|
||||||
|
className="absolute text-white"
|
||||||
|
style={{
|
||||||
|
bottom: '4px',
|
||||||
|
right: '4px',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
padding: '3px 4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
lineHeight: 'normal',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{duration}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata - below thumbnail */}
|
||||||
|
<div className="flex px-3" style={{ gap: '12px', marginTop: '12px' }}>
|
||||||
|
{/* Avatar - 36x36px circle */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channelAvatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={channelAvatarUrl}
|
||||||
|
alt={channelTitle}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-gradient-to-br from-purple-500 to-pink-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Title - 14px/500, 2-line clamp */}
|
||||||
|
<h3
|
||||||
|
className="yt-title line-clamp-2"
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: '20px',
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{/* Channel, views, time - single line */}
|
||||||
|
<div
|
||||||
|
className="yt-meta flex items-center flex-wrap"
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '18px',
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{channelTitle}</span>
|
||||||
|
{isVerified && (
|
||||||
|
<VerifiedBadge className="w-3 h-3 mx-1 text-yt-meta" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{' '}
|
||||||
|
· {formattedViews} · {formattedTime}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Menu - Always visible on mobile */}
|
||||||
|
<button className="flex-shrink-0 -mr-2 p-1 text-yt-title" aria-label="Action menu">
|
||||||
|
<MoreVertical className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Desktop/Search Variant (default) - responsive thumbnail, 12px radius
|
||||||
|
// Avatar left, title/metadata right, hover states
|
||||||
|
// ============================================================================
|
||||||
return (
|
return (
|
||||||
<div className={`group cursor-pointer ${isUserThumbnail ? 'ring-2 ring-red-500 rounded-xl p-2' : ''}`}>
|
<div
|
||||||
<div className="relative">
|
className="yt-card cursor-pointer group w-full"
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
{/* Thumbnail - responsive, 16:9 aspect ratio, 12px radius */}
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden bg-yt-hover w-full"
|
||||||
|
style={{
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
borderRadius: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={thumbnailUrl}
|
src={thumbnailUrl}
|
||||||
alt={title}
|
alt={title}
|
||||||
className="w-full 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">
|
{/* Duration badge - bottom-right */}
|
||||||
YOUR THUMBNAIL
|
<span
|
||||||
|
className="absolute text-white"
|
||||||
|
style={{
|
||||||
|
bottom: '4px',
|
||||||
|
right: '4px',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
padding: '3px 4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
lineHeight: 'normal',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{duration}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Hover overlay with icons */}
|
||||||
|
{isHovered && (
|
||||||
|
<div className="absolute top-2 right-2 flex flex-col gap-1">
|
||||||
|
{/* Watch later button */}
|
||||||
|
<button
|
||||||
|
className="p-1.5 bg-black/80 rounded-sm hover:bg-black transition-colors"
|
||||||
|
aria-label="Watch later"
|
||||||
|
>
|
||||||
|
<Clock className="size-4 text-white" />
|
||||||
|
</button>
|
||||||
|
{/* Add to queue button */}
|
||||||
|
<button
|
||||||
|
className="p-1.5 bg-black/80 rounded-sm hover:bg-black transition-colors"
|
||||||
|
aria-label="Add to queue"
|
||||||
|
>
|
||||||
|
<ListPlus className="size-4 text-white" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 mt-3">
|
{/* Metadata - 12px gap from thumbnail */}
|
||||||
{/* Channel avatar */}
|
<div className="flex relative" style={{ gap: '12px', marginTop: '12px' }}>
|
||||||
<div className="w-9 h-9 rounded-full bg-gray-700 flex-shrink-0" />
|
{/* Avatar - 36x36px circle */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channelAvatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={channelAvatarUrl}
|
||||||
|
alt={channelTitle}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-full h-full flex items-center justify-center text-white font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: getChannelColor(channelTitle),
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channelTitle.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
{/* Text content */}
|
||||||
<h3 className="text-white font-medium line-clamp-2 text-sm group-hover:text-blue-400">
|
<div className="flex-1 min-w-0 pr-6">
|
||||||
|
{/* Title - 16px/500, 22px line-height, 2-line clamp */}
|
||||||
|
<h3
|
||||||
|
className="yt-title line-clamp-2"
|
||||||
|
style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: '22px',
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-400 text-sm mt-1 hover:text-gray-300">
|
|
||||||
{channelTitle}
|
{/* Channel name - 12px, meta color, hover effect */}
|
||||||
</p>
|
<div className="flex items-center flex-wrap">
|
||||||
<p className="text-gray-400 text-sm">
|
<p
|
||||||
{formatViews(viewCount)} • {formatDate(publishedAt)}
|
className={cn(
|
||||||
|
'yt-meta transition-colors cursor-pointer',
|
||||||
|
isHovered && 'text-yt-title'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '18px',
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channelTitle}
|
||||||
|
</p>
|
||||||
|
{isVerified && (
|
||||||
|
<VerifiedBadge className="w-3.5 h-3.5 ml-1 text-yt-meta" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Views and time */}
|
||||||
|
<p
|
||||||
|
className="yt-meta"
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '18px',
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formattedViews} · {formattedTime}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Action Menu (3 dots) - Absolute positioned right */}
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0 right-[-8px] p-2 opacity-0 group-hover:opacity-100 transition-opacity',
|
||||||
|
'hover:bg-yt-hover rounded-full'
|
||||||
|
)}
|
||||||
|
aria-label="Action menu"
|
||||||
|
>
|
||||||
|
<MoreVertical className="size-5 text-yt-title" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
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-0",
|
||||||
|
"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 }
|
||||||
47
frontend/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { authApi } from '../api/auth';
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
accessToken,
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated,
|
||||||
|
setAuth,
|
||||||
|
clearAuth,
|
||||||
|
setLoading,
|
||||||
|
logout,
|
||||||
|
} = useAuthStore();
|
||||||
|
|
||||||
|
const checkAuth = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to refresh the token using the HttpOnly cookie
|
||||||
|
const response = await authApi.refreshToken();
|
||||||
|
setAuth(response.user, response.accessToken);
|
||||||
|
} catch (error) {
|
||||||
|
// No valid session
|
||||||
|
clearAuth();
|
||||||
|
}
|
||||||
|
}, [setAuth, clearAuth, setLoading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only check auth on mount if we don't already have a user
|
||||||
|
if (!user && !accessToken) {
|
||||||
|
checkAuth();
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
accessToken,
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated,
|
||||||
|
checkAuth,
|
||||||
|
logout,
|
||||||
|
};
|
||||||
|
}
|
||||||
133
frontend/src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for persisting state to localStorage with JSON serialization
|
||||||
|
*
|
||||||
|
* @param key - localStorage key
|
||||||
|
* @param initialValue - default value if no stored value exists
|
||||||
|
* @returns [value, setValue, remove] tuple
|
||||||
|
*/
|
||||||
|
export function useLocalStorage<T>(
|
||||||
|
key: string,
|
||||||
|
initialValue: T
|
||||||
|
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
|
||||||
|
// Initialize state with stored value or initial value
|
||||||
|
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const item = window.localStorage.getItem(key);
|
||||||
|
if (item === null) {
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse stored JSON, handling Date objects
|
||||||
|
const parsed = JSON.parse(item, (_, value) => {
|
||||||
|
// Revive Date objects from ISO strings
|
||||||
|
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return parsed as T;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Error reading localStorage key "${key}":`, error);
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update localStorage when value changes
|
||||||
|
const setValue = useCallback(
|
||||||
|
(value: T | ((prev: T) => T)) => {
|
||||||
|
try {
|
||||||
|
// Handle function updates
|
||||||
|
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||||
|
setStoredValue(valueToStore);
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Error setting localStorage key "${key}":`, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[key, storedValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove from localStorage
|
||||||
|
const remove = useCallback(() => {
|
||||||
|
try {
|
||||||
|
setStoredValue(initialValue);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Error removing localStorage key "${key}":`, error);
|
||||||
|
}
|
||||||
|
}, [key, initialValue]);
|
||||||
|
|
||||||
|
// Listen for changes from other tabs/windows
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStorageChange = (event: StorageEvent) => {
|
||||||
|
if (event.key === key && event.newValue !== null) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.newValue, (_, value) => {
|
||||||
|
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
setStoredValue(parsed as T);
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors from other tabs
|
||||||
|
}
|
||||||
|
} else if (event.key === key && event.newValue === null) {
|
||||||
|
// Key was removed in another tab
|
||||||
|
setStoredValue(initialValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('storage', handleStorageChange);
|
||||||
|
return () => window.removeEventListener('storage', handleStorageChange);
|
||||||
|
}, [key, initialValue]);
|
||||||
|
|
||||||
|
return [storedValue, setValue, remove];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get localStorage usage in bytes
|
||||||
|
*/
|
||||||
|
export function getLocalStorageUsage(): number {
|
||||||
|
if (typeof window === 'undefined') return 0;
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
for (const key in window.localStorage) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(window.localStorage, key)) {
|
||||||
|
const value = window.localStorage.getItem(key);
|
||||||
|
if (value) {
|
||||||
|
// UTF-16 encoding: 2 bytes per character
|
||||||
|
total += (key.length + value.length) * 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if localStorage has sufficient space (approximately)
|
||||||
|
* Most browsers allow ~5MB
|
||||||
|
*/
|
||||||
|
export function hasLocalStorageSpace(requiredBytes: number): boolean {
|
||||||
|
const MAX_STORAGE = 5 * 1024 * 1024; // 5MB
|
||||||
|
const currentUsage = getLocalStorageUsage();
|
||||||
|
return currentUsage + requiredBytes < MAX_STORAGE;
|
||||||
|
}
|
||||||
@@ -1,4 +1,21 @@
|
|||||||
@import "tailwindcss";
|
@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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -8,3 +25,473 @@ body {
|
|||||||
#root {
|
#root {
|
||||||
min-height: 100vh;
|
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
|
||||||
|
Extracted from live YouTube (January 2026)
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
|
||||||
|
/* YouTube Typography */
|
||||||
|
--yt-font-family: 'Roboto', 'Arial', sans-serif;
|
||||||
|
--yt-title-desktop-size: 16px;
|
||||||
|
--yt-title-desktop-weight: 500;
|
||||||
|
--yt-title-desktop-line-height: 22px;
|
||||||
|
--yt-title-sidebar-size: 14px;
|
||||||
|
--yt-title-sidebar-weight: 500;
|
||||||
|
--yt-title-sidebar-line-height: 20px;
|
||||||
|
--yt-meta-size: 12px;
|
||||||
|
--yt-meta-weight: 400;
|
||||||
|
--yt-meta-line-height: 18px;
|
||||||
|
|
||||||
|
/* YouTube Dimensions */
|
||||||
|
--yt-thumbnail-desktop-width: 360px;
|
||||||
|
--yt-thumbnail-desktop-height: 202px;
|
||||||
|
--yt-thumbnail-desktop-radius: 12px;
|
||||||
|
--yt-thumbnail-sidebar-width: 168px;
|
||||||
|
--yt-thumbnail-sidebar-height: 94px;
|
||||||
|
--yt-thumbnail-sidebar-radius: 8px;
|
||||||
|
--yt-avatar-size: 36px;
|
||||||
|
|
||||||
|
/* YouTube Duration Badge */
|
||||||
|
--yt-duration-bg: rgba(0, 0, 0, 0.8);
|
||||||
|
--yt-duration-color: #ffffff;
|
||||||
|
--yt-duration-size: 12px;
|
||||||
|
--yt-duration-weight: 500;
|
||||||
|
--yt-duration-padding: 3px 4px;
|
||||||
|
--yt-duration-radius: 4px;
|
||||||
|
|
||||||
|
/* YouTube Spacing */
|
||||||
|
--yt-card-gap-h: 16px;
|
||||||
|
--yt-card-gap-v: 40px;
|
||||||
|
--yt-thumbnail-meta-gap: 12px;
|
||||||
|
--yt-avatar-text-gap: 12px;
|
||||||
|
--yt-sidebar-card-gap: 8px;
|
||||||
|
--yt-sidebar-thumbnail-text-gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode YouTube colors */
|
||||||
|
.dark {
|
||||||
|
--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))
|
||||||
|
}
|
||||||
259
frontend/src/lib/youtube-styles.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* YouTube Style Constants Module
|
||||||
|
* Extracted CSS tokens from live YouTube (January 2026)
|
||||||
|
* Source: specs/002-youtube-design-preview/research.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Typography
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const YT_TYPOGRAPHY = {
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
|
||||||
|
title: {
|
||||||
|
desktop: {
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: '22px',
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '26px',
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: '20px',
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: '20px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
channelName: {
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '18px',
|
||||||
|
},
|
||||||
|
|
||||||
|
metadata: {
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '18px',
|
||||||
|
},
|
||||||
|
|
||||||
|
durationBadge: {
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 'normal',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Thumbnail Dimensions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const YT_THUMBNAIL = {
|
||||||
|
desktop: {
|
||||||
|
width: 360,
|
||||||
|
height: 202,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
width: 168,
|
||||||
|
height: 94,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
borderRadius: 0,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Avatar Dimensions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const YT_AVATAR = {
|
||||||
|
desktop: {
|
||||||
|
size: 36,
|
||||||
|
borderRadius: '50%',
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
size: 36,
|
||||||
|
borderRadius: '50%',
|
||||||
|
},
|
||||||
|
sidebar: null, // No avatar in sidebar
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Duration Badge
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const YT_DURATION_BADGE = {
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily: 'Roboto, Arial, sans-serif',
|
||||||
|
padding: '3px 4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
position: {
|
||||||
|
bottom: '4px',
|
||||||
|
right: '4px',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Colors
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const YT_COLORS = {
|
||||||
|
light: {
|
||||||
|
bg: '#ffffff',
|
||||||
|
surface: '#ffffff',
|
||||||
|
title: '#0f0f0f',
|
||||||
|
meta: '#606060',
|
||||||
|
icon: '#606060',
|
||||||
|
border: '#e5e5e5',
|
||||||
|
hover: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
chipBg: '#f2f2f2',
|
||||||
|
chipActiveBg: '#0f0f0f',
|
||||||
|
chipActiveText: '#ffffff',
|
||||||
|
blue: '#065fd4',
|
||||||
|
red: '#ff0000',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
bg: '#0f0f0f',
|
||||||
|
surface: '#0f0f0f',
|
||||||
|
title: '#f1f1f1',
|
||||||
|
meta: '#aaaaaa',
|
||||||
|
icon: '#aaaaaa',
|
||||||
|
border: '#3f3f3f',
|
||||||
|
hover: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
chipBg: '#272727',
|
||||||
|
chipActiveBg: '#f1f1f1',
|
||||||
|
chipActiveText: '#0f0f0f',
|
||||||
|
blue: '#3ea6ff',
|
||||||
|
red: '#ff0000',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Spacing & Layout
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const YT_SPACING = {
|
||||||
|
desktop: {
|
||||||
|
cardGapHorizontal: 16,
|
||||||
|
cardGapVertical: 40,
|
||||||
|
thumbnailToMetadata: 12,
|
||||||
|
avatarToText: 12,
|
||||||
|
titleMaxLines: 2,
|
||||||
|
channelMaxLines: 1,
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
cardGapVertical: 8,
|
||||||
|
thumbnailToText: 8,
|
||||||
|
titleMaxLines: 2,
|
||||||
|
channelMaxLines: 1,
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
thumbnailToMetadata: 12,
|
||||||
|
avatarToText: 12,
|
||||||
|
titleMaxLines: 2,
|
||||||
|
channelMaxLines: 1,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Responsive Breakpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const YT_BREAKPOINTS = {
|
||||||
|
columns: {
|
||||||
|
6: 2136, // > 2136px = 6 columns
|
||||||
|
5: 1712, // 1712-2136px = 5 columns
|
||||||
|
4: 1288, // 1288-1712px = 4 columns
|
||||||
|
3: 888, // 888-1288px = 3 columns
|
||||||
|
2: 512, // 512-888px = 2 columns
|
||||||
|
1: 0, // < 512px = 1 column (mobile)
|
||||||
|
},
|
||||||
|
thumbnailWidths: {
|
||||||
|
6: 356,
|
||||||
|
5: 342,
|
||||||
|
4: 320,
|
||||||
|
3: 290,
|
||||||
|
2: 280,
|
||||||
|
1: '100%',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utility Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format view count to YouTube style (e.g., "1.2M views")
|
||||||
|
*/
|
||||||
|
export function formatViewCount(views: number): string {
|
||||||
|
if (views >= 1_000_000_000) {
|
||||||
|
const formatted = (views / 1_000_000_000).toFixed(1);
|
||||||
|
return `${formatted.replace('.0', '')}B views`;
|
||||||
|
}
|
||||||
|
if (views >= 1_000_000) {
|
||||||
|
const formatted = (views / 1_000_000).toFixed(1);
|
||||||
|
return `${formatted.replace('.0', '')}M views`;
|
||||||
|
}
|
||||||
|
if (views >= 1_000) {
|
||||||
|
const formatted = (views / 1_000).toFixed(0);
|
||||||
|
return `${formatted}K views`;
|
||||||
|
}
|
||||||
|
if (views === 0) {
|
||||||
|
return 'No views';
|
||||||
|
}
|
||||||
|
return `${views} views`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date to YouTube style relative time (e.g., "3 days ago")
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(date: Date): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - date.getTime();
|
||||||
|
|
||||||
|
if (diff < 0) {
|
||||||
|
return 'Just now';
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
const weeks = Math.floor(days / 7);
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
const years = Math.floor(days / 365);
|
||||||
|
|
||||||
|
if (seconds < 60) return 'Just now';
|
||||||
|
if (minutes < 60) return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`;
|
||||||
|
if (hours < 24) return hours === 1 ? '1 hour ago' : `${hours} hours ago`;
|
||||||
|
if (days < 7) return days === 1 ? '1 day ago' : `${days} days ago`;
|
||||||
|
if (weeks < 4) return weeks === 1 ? '1 week ago' : `${weeks} weeks ago`;
|
||||||
|
if (months < 12) return months === 1 ? '1 month ago' : `${months} months ago`;
|
||||||
|
return years === 1 ? '1 year ago' : `${years} years ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate duration format (MM:SS or H:MM:SS)
|
||||||
|
*/
|
||||||
|
export function isValidDuration(duration: string): boolean {
|
||||||
|
return /^\d{1,2}:\d{2}(:\d{2})?$/.test(duration);
|
||||||
|
}
|
||||||
64
frontend/src/pages/AuthCallbackPage.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { authApi } from '../api/auth';
|
||||||
|
|
||||||
|
export function AuthCallbackPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { setAuth, clearAuth } = useAuthStore();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleCallback = async () => {
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
const errorParam = searchParams.get('error');
|
||||||
|
|
||||||
|
if (errorParam) {
|
||||||
|
clearAuth();
|
||||||
|
navigate(`/login?error=${errorParam}`, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
clearAuth();
|
||||||
|
navigate('/login?error=invalid_request', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch user info with the access token
|
||||||
|
const user = await authApi.getMe(token);
|
||||||
|
setAuth(user, token);
|
||||||
|
|
||||||
|
// Get stored redirect destination
|
||||||
|
const redirectTo = sessionStorage.getItem('auth_redirect') || '/tool';
|
||||||
|
sessionStorage.removeItem('auth_redirect');
|
||||||
|
|
||||||
|
// Navigate to intended destination
|
||||||
|
navigate(redirectTo, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Auth callback error:', err);
|
||||||
|
setError('Failed to complete authentication');
|
||||||
|
clearAuth();
|
||||||
|
navigate('/login?error=auth_failed', { replace: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCallback();
|
||||||
|
}, [searchParams, navigate, setAuth, clearAuth]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-destructive">{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-muted-foreground">Completing sign in...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
425
frontend/src/pages/LandingPage.tsx
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
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 { usePreviewStore } from '../store/previewStore';
|
||||||
|
import LogoDark from '../assets/logo-v2-5.svg';
|
||||||
|
import LogoLight from '../assets/logo-v2-5-light.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() {
|
||||||
|
const { themeMode } = usePreviewStore();
|
||||||
|
const isDark = themeMode === 'dark';
|
||||||
|
|
||||||
|
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={isDark ? LogoDark : LogoLight} 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={isDark ? LogoDark : LogoLight} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { isAuthenticated, isLoading } = useAuthStore();
|
||||||
|
|
||||||
|
const error = searchParams.get('error');
|
||||||
|
const from = (location.state as { from?: string })?.from || '/tool';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated && !isLoading) {
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, isLoading, navigate, from]);
|
||||||
|
|
||||||
|
const handleGoogleLogin = () => {
|
||||||
|
// Store intended destination in sessionStorage for callback
|
||||||
|
sessionStorage.setItem('auth_redirect', from);
|
||||||
|
// Redirect to backend OAuth endpoint
|
||||||
|
window.location.href = '/api/auth/google';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getErrorMessage = (errorCode: string | null): string | null => {
|
||||||
|
if (!errorCode) return null;
|
||||||
|
|
||||||
|
const errorMessages: Record<string, string> = {
|
||||||
|
auth_failed: 'Authentication failed. Please try again.',
|
||||||
|
access_denied: 'Access was denied. Please grant the required permissions.',
|
||||||
|
invalid_request: 'Invalid request. Please try again.',
|
||||||
|
};
|
||||||
|
|
||||||
|
return errorMessages[errorCode] || 'An error occurred during sign in.';
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorMessage = getErrorMessage(error);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-muted-foreground">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl font-bold">
|
||||||
|
Welcome to ThumbPreview
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Sign in to start previewing your thumbnails
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="rounded-md bg-destructive/10 p-3 text-center text-sm text-destructive">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleGoogleLogin}
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="mr-2 h-5 w-5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
fill="#4285F4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
fill="#34A853"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
fill="#FBBC05"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
fill="#EA4335"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Google
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
By signing in, you agree to our terms of service and privacy policy.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
frontend/src/pages/ToolPage.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
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 { ThemeToggle } from '../components/ThemeToggle';
|
||||||
|
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,
|
||||||
|
themeMode,
|
||||||
|
setThemeMode,
|
||||||
|
} = usePreviewStore();
|
||||||
|
const hasContent = thumbnails.length > 0 || youtubeResults.length > 0;
|
||||||
|
const isDark = themeMode === 'dark';
|
||||||
|
|
||||||
|
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">
|
||||||
|
<ThemeToggle
|
||||||
|
theme={themeMode}
|
||||||
|
onThemeChange={setThemeMode}
|
||||||
|
/>
|
||||||
|
<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 & Thumbnail Management */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<SearchInput />
|
||||||
|
<ThumbnailSelector />
|
||||||
|
<UserInfoInputs />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ViewSwitcher />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground font-medium">Add more thumbnails</p>
|
||||||
|
<ThumbnailUploader />
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
frontend/src/store/authStore.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { User } from '../types/auth';
|
||||||
|
import { authApi } from '../api/auth';
|
||||||
|
|
||||||
|
interface AuthStore {
|
||||||
|
user: User | null;
|
||||||
|
accessToken: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
setAuth: (user: User, accessToken: string) => void;
|
||||||
|
clearAuth: () => void;
|
||||||
|
setLoading: (isLoading: boolean) => void;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthStore>((set, get) => ({
|
||||||
|
user: null,
|
||||||
|
accessToken: null,
|
||||||
|
isLoading: true,
|
||||||
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
setAuth: (user: User, accessToken: string) => {
|
||||||
|
set({
|
||||||
|
user,
|
||||||
|
accessToken,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAuth: () => {
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
accessToken: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setLoading: (isLoading: boolean) => {
|
||||||
|
set({ isLoading });
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
const token = get().accessToken;
|
||||||
|
if (token) {
|
||||||
|
await authApi.logout(token);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
} finally {
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
accessToken: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,7 +1,21 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { UploadedThumbnail, YouTubeVideo, ViewMode } from '../types';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
import type {
|
||||||
|
UploadedThumbnail,
|
||||||
|
YouTubeVideo,
|
||||||
|
ViewMode,
|
||||||
|
PreviewMode,
|
||||||
|
ThemeMode,
|
||||||
|
VideoMetadata,
|
||||||
|
UploadedPreviewThumbnail,
|
||||||
|
} from '../types';
|
||||||
|
import { DEFAULT_METADATA, VALIDATION_CONSTANTS } from '../types';
|
||||||
|
|
||||||
interface PreviewState {
|
// ============================================================================
|
||||||
|
// Legacy State (existing functionality)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface LegacyState {
|
||||||
thumbnails: UploadedThumbnail[];
|
thumbnails: UploadedThumbnail[];
|
||||||
activeThumbnailIndex: number;
|
activeThumbnailIndex: number;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -10,8 +24,37 @@ interface PreviewState {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
userTitle: string;
|
userTitle: string;
|
||||||
userChannel: string;
|
userChannel: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Actions
|
// ============================================================================
|
||||||
|
// YouTube Preview State (new functionality)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface YouTubePreviewState {
|
||||||
|
/** Currently active thumbnail for preview */
|
||||||
|
currentThumbnail: UploadedPreviewThumbnail | null;
|
||||||
|
/** Active preview layout mode */
|
||||||
|
previewMode: PreviewMode;
|
||||||
|
/** Active color theme */
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
/** Video metadata for display */
|
||||||
|
metadata: VideoMetadata;
|
||||||
|
/** Recently used thumbnails (max 10) */
|
||||||
|
recentThumbnails: UploadedPreviewThumbnail[];
|
||||||
|
/** UI state: metadata editor visibility */
|
||||||
|
showMetadataEditor: boolean;
|
||||||
|
/** Last persistence timestamp */
|
||||||
|
lastSaved: Date;
|
||||||
|
/** Schema version for migrations */
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Combined State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface PreviewState extends LegacyState, YouTubePreviewState {
|
||||||
|
// Legacy Actions
|
||||||
addThumbnail: (thumbnail: UploadedThumbnail) => void;
|
addThumbnail: (thumbnail: UploadedThumbnail) => void;
|
||||||
removeThumbnail: (id: string) => void;
|
removeThumbnail: (id: string) => void;
|
||||||
setActiveThumbnailIndex: (index: number) => void;
|
setActiveThumbnailIndex: (index: number) => void;
|
||||||
@@ -22,9 +65,24 @@ interface PreviewState {
|
|||||||
setUserTitle: (title: string) => void;
|
setUserTitle: (title: string) => void;
|
||||||
setUserChannel: (channel: string) => void;
|
setUserChannel: (channel: string) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
|
|
||||||
|
// YouTube Preview Actions
|
||||||
|
setCurrentThumbnail: (thumbnail: UploadedPreviewThumbnail | null) => void;
|
||||||
|
setPreviewMode: (mode: PreviewMode) => void;
|
||||||
|
setThemeMode: (mode: ThemeMode) => void;
|
||||||
|
setMetadata: (metadata: Partial<VideoMetadata>) => void;
|
||||||
|
setShowMetadataEditor: (show: boolean) => void;
|
||||||
|
addRecentThumbnail: (thumbnail: UploadedPreviewThumbnail) => void;
|
||||||
|
removeRecentThumbnail: (id: string) => void;
|
||||||
|
clearRecentThumbnails: () => void;
|
||||||
|
cleanupOldThumbnails: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState = {
|
// ============================================================================
|
||||||
|
// Initial State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const legacyInitialState: LegacyState = {
|
||||||
thumbnails: [],
|
thumbnails: [],
|
||||||
activeThumbnailIndex: 0,
|
activeThumbnailIndex: 0,
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
@@ -35,36 +93,209 @@ const initialState = {
|
|||||||
userChannel: 'Your Channel',
|
userChannel: 'Your Channel',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePreviewStore = create<PreviewState>((set) => ({
|
const youtubePreviewInitialState: YouTubePreviewState = {
|
||||||
...initialState,
|
currentThumbnail: null,
|
||||||
|
previewMode: 'desktop',
|
||||||
|
themeMode: 'light',
|
||||||
|
metadata: { ...DEFAULT_METADATA },
|
||||||
|
recentThumbnails: [],
|
||||||
|
showMetadataEditor: false,
|
||||||
|
lastSaved: new Date(),
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
addThumbnail: (thumbnail) =>
|
const initialState = {
|
||||||
set((state) => ({
|
...legacyInitialState,
|
||||||
thumbnails: [...state.thumbnails, thumbnail],
|
...youtubePreviewInitialState,
|
||||||
})),
|
};
|
||||||
|
|
||||||
removeThumbnail: (id) =>
|
// ============================================================================
|
||||||
set((state) => ({
|
// Store with Persistence
|
||||||
thumbnails: state.thumbnails.filter((t) => t.id !== id),
|
// ============================================================================
|
||||||
activeThumbnailIndex: Math.min(
|
|
||||||
state.activeThumbnailIndex,
|
|
||||||
Math.max(0, state.thumbnails.length - 2)
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
|
|
||||||
setActiveThumbnailIndex: (index) => set({ activeThumbnailIndex: index }),
|
export const usePreviewStore = create<PreviewState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
// ========================================================================
|
||||||
|
// Legacy Actions
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
setYoutubeResults: (results) => set({ youtubeResults: results }),
|
addThumbnail: (thumbnail) =>
|
||||||
|
set((state) => ({
|
||||||
|
thumbnails: [...state.thumbnails, thumbnail],
|
||||||
|
})),
|
||||||
|
|
||||||
setViewMode: (mode) => set({ viewMode: mode }),
|
removeThumbnail: (id) =>
|
||||||
|
set((state) => ({
|
||||||
|
thumbnails: state.thumbnails.filter((t) => t.id !== id),
|
||||||
|
activeThumbnailIndex: Math.min(
|
||||||
|
state.activeThumbnailIndex,
|
||||||
|
Math.max(0, state.thumbnails.length - 2)
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
setIsLoading: (loading) => set({ isLoading: loading }),
|
setActiveThumbnailIndex: (index) => set({ activeThumbnailIndex: index }),
|
||||||
|
|
||||||
setUserTitle: (title) => set({ userTitle: title }),
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
|
|
||||||
setUserChannel: (channel) => set({ userChannel: channel }),
|
setYoutubeResults: (results) => set({ youtubeResults: results }),
|
||||||
|
|
||||||
reset: () => set(initialState),
|
setViewMode: (mode) => set({ viewMode: mode }),
|
||||||
}));
|
|
||||||
|
setIsLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
|
||||||
|
setUserTitle: (title) =>
|
||||||
|
set((state) => ({
|
||||||
|
userTitle: title,
|
||||||
|
metadata: { ...state.metadata, title },
|
||||||
|
})),
|
||||||
|
|
||||||
|
setUserChannel: (channel) =>
|
||||||
|
set((state) => ({
|
||||||
|
userChannel: channel,
|
||||||
|
metadata: { ...state.metadata, channelName: channel },
|
||||||
|
})),
|
||||||
|
|
||||||
|
reset: () => set(initialState),
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// YouTube Preview Actions
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
setCurrentThumbnail: (thumbnail) =>
|
||||||
|
set((state) => {
|
||||||
|
// If setting a new thumbnail, add current to recent
|
||||||
|
if (thumbnail && state.currentThumbnail) {
|
||||||
|
const recentThumbnails = [
|
||||||
|
state.currentThumbnail,
|
||||||
|
...state.recentThumbnails.filter(
|
||||||
|
(t) => t.id !== state.currentThumbnail?.id
|
||||||
|
),
|
||||||
|
].slice(0, VALIDATION_CONSTANTS.MAX_RECENT_THUMBNAILS);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentThumbnail: thumbnail,
|
||||||
|
recentThumbnails,
|
||||||
|
lastSaved: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentThumbnail: thumbnail,
|
||||||
|
lastSaved: new Date(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
setPreviewMode: (mode) =>
|
||||||
|
set({
|
||||||
|
previewMode: mode,
|
||||||
|
lastSaved: new Date(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
setThemeMode: (mode) => {
|
||||||
|
// Apply theme to document
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
if (mode === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
themeMode: mode,
|
||||||
|
lastSaved: new Date(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setMetadata: (partialMetadata) =>
|
||||||
|
set((state) => ({
|
||||||
|
metadata: { ...state.metadata, ...partialMetadata },
|
||||||
|
lastSaved: new Date(),
|
||||||
|
})),
|
||||||
|
|
||||||
|
setShowMetadataEditor: (show) => set({ showMetadataEditor: show }),
|
||||||
|
|
||||||
|
addRecentThumbnail: (thumbnail) =>
|
||||||
|
set((state) => {
|
||||||
|
const filtered = state.recentThumbnails.filter(
|
||||||
|
(t) => t.id !== thumbnail.id
|
||||||
|
);
|
||||||
|
const updated = [thumbnail, ...filtered].slice(
|
||||||
|
0,
|
||||||
|
VALIDATION_CONSTANTS.MAX_RECENT_THUMBNAILS
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
recentThumbnails: updated,
|
||||||
|
lastSaved: new Date(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeRecentThumbnail: (id) =>
|
||||||
|
set((state) => ({
|
||||||
|
recentThumbnails: state.recentThumbnails.filter((t) => t.id !== id),
|
||||||
|
lastSaved: new Date(),
|
||||||
|
})),
|
||||||
|
|
||||||
|
clearRecentThumbnails: () =>
|
||||||
|
set({
|
||||||
|
recentThumbnails: [],
|
||||||
|
lastSaved: new Date(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
cleanupOldThumbnails: () =>
|
||||||
|
set((state) => ({
|
||||||
|
recentThumbnails: state.recentThumbnails.slice(
|
||||||
|
0,
|
||||||
|
VALIDATION_CONSTANTS.MAX_RECENT_THUMBNAILS
|
||||||
|
),
|
||||||
|
lastSaved: new Date(),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'thumbpreview_state',
|
||||||
|
storage: createJSONStorage(() => localStorage, {
|
||||||
|
reviver: (_key, value) => {
|
||||||
|
// Revive Date objects from ISO strings
|
||||||
|
if (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)
|
||||||
|
) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
partialize: (state) => ({
|
||||||
|
// Persist YouTube preview state
|
||||||
|
currentThumbnail: state.currentThumbnail,
|
||||||
|
previewMode: state.previewMode,
|
||||||
|
themeMode: state.themeMode,
|
||||||
|
metadata: state.metadata,
|
||||||
|
recentThumbnails: state.recentThumbnails,
|
||||||
|
showMetadataEditor: state.showMetadataEditor,
|
||||||
|
lastSaved: state.lastSaved,
|
||||||
|
version: state.version,
|
||||||
|
// Also persist legacy state for backward compatibility
|
||||||
|
userTitle: state.userTitle,
|
||||||
|
userChannel: state.userChannel,
|
||||||
|
}),
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
// Apply persisted theme on rehydration
|
||||||
|
if (state?.themeMode && typeof document !== 'undefined') {
|
||||||
|
if (state.themeMode === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|||||||
18
frontend/src/types/auth.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
accessToken: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
accessToken: string;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
@@ -24,3 +24,125 @@ export interface PreviewSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ViewMode = 'search' | 'sidebar' | 'mobile';
|
export type ViewMode = 'search' | 'sidebar' | 'mobile';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// YouTube Design Preview Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview display modes matching YouTube's layout variations
|
||||||
|
*/
|
||||||
|
export type PreviewMode = 'desktop' | 'sidebar' | 'mobile';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme modes matching YouTube's color schemes
|
||||||
|
*/
|
||||||
|
export type ThemeMode = 'light' | 'dark';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported image formats for thumbnail upload
|
||||||
|
*/
|
||||||
|
export type SupportedImageFormat = 'image/jpeg' | 'image/png' | 'image/webp';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video metadata displayed alongside the thumbnail
|
||||||
|
*/
|
||||||
|
export interface VideoMetadata {
|
||||||
|
/** Video title (1-100 characters) */
|
||||||
|
title: string;
|
||||||
|
/** Channel name (1-50 characters) */
|
||||||
|
channelName: string;
|
||||||
|
/** Optional channel avatar URL or base64 data URL */
|
||||||
|
channelAvatarUrl?: string;
|
||||||
|
/** Duration in format "MM:SS" or "H:MM:SS" */
|
||||||
|
duration: string;
|
||||||
|
/** View count as raw number (formatted for display) */
|
||||||
|
viewCount: number;
|
||||||
|
/** Publication date (used for relative time display) */
|
||||||
|
publishedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploaded thumbnail image with metadata for preview persistence
|
||||||
|
*/
|
||||||
|
export interface UploadedPreviewThumbnail {
|
||||||
|
/** Unique identifier (UUID) */
|
||||||
|
id: string;
|
||||||
|
/** Base64 data URL of the image */
|
||||||
|
dataUrl: string;
|
||||||
|
/** Original filename */
|
||||||
|
originalName: string;
|
||||||
|
/** File size in bytes (max 5MB = 5,242,880) */
|
||||||
|
fileSize: number;
|
||||||
|
/** MIME type */
|
||||||
|
mimeType: SupportedImageFormat;
|
||||||
|
/** Original image width */
|
||||||
|
width: number;
|
||||||
|
/** Original image height */
|
||||||
|
height: number;
|
||||||
|
/** Upload timestamp */
|
||||||
|
uploadedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete preview state persisted to localStorage
|
||||||
|
*/
|
||||||
|
export interface PreviewState {
|
||||||
|
/** Currently active thumbnail for preview */
|
||||||
|
currentThumbnail: UploadedPreviewThumbnail | null;
|
||||||
|
/** Active preview layout mode */
|
||||||
|
previewMode: PreviewMode;
|
||||||
|
/** Active color theme */
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
/** Video metadata for display */
|
||||||
|
metadata: VideoMetadata;
|
||||||
|
/** Recently used thumbnails (max 10) */
|
||||||
|
recentThumbnails: UploadedPreviewThumbnail[];
|
||||||
|
/** UI state: metadata editor visibility */
|
||||||
|
showMetadataEditor: boolean;
|
||||||
|
/** Last persistence timestamp */
|
||||||
|
lastSaved: Date;
|
||||||
|
/** Schema version for migrations */
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* localStorage serialization wrapper
|
||||||
|
*/
|
||||||
|
export interface LocalStorageSchema {
|
||||||
|
version: 1;
|
||||||
|
state: PreviewState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thumbnail upload validation result
|
||||||
|
*/
|
||||||
|
export interface ThumbnailValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation constants
|
||||||
|
*/
|
||||||
|
export const VALIDATION_CONSTANTS = {
|
||||||
|
MAX_FILE_SIZE: 5 * 1024 * 1024, // 5MB
|
||||||
|
MAX_TITLE_LENGTH: 100,
|
||||||
|
MAX_CHANNEL_NAME_LENGTH: 50,
|
||||||
|
MAX_RECENT_THUMBNAILS: 10,
|
||||||
|
VISUAL_MATCH_THRESHOLD: 98, // percentage
|
||||||
|
SUPPORTED_FORMATS: ['image/jpeg', 'image/png', 'image/webp'] as const,
|
||||||
|
DURATION_REGEX: /^\d{1,2}:\d{2}(:\d{2})?$/,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default metadata values
|
||||||
|
*/
|
||||||
|
export const DEFAULT_METADATA: VideoMetadata = {
|
||||||
|
title: 'Your Video Title Here',
|
||||||
|
channelName: 'Your Channel',
|
||||||
|
channelAvatarUrl: undefined,
|
||||||
|
duration: '10:30',
|
||||||
|
viewCount: 125000,
|
||||||
|
publishedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // 1 week ago
|
||||||
|
};
|
||||||
|
|||||||
128
frontend/tests/visual/desktop.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { setupPreviewState } from './utils';
|
||||||
|
|
||||||
|
test.describe('Desktop Preview Visual Tests', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Navigate to the tool page
|
||||||
|
await page.goto('/tool');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop preview - light mode', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'desktop', theme: 'light' });
|
||||||
|
|
||||||
|
// Wait for YouTube card to be visible
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
// Verify card dimensions (360x202px thumbnail)
|
||||||
|
const thumbnail = ytCard.locator('img').first();
|
||||||
|
await expect(thumbnail).toBeVisible();
|
||||||
|
|
||||||
|
// Take screenshot for visual comparison
|
||||||
|
await expect(ytCard).toHaveScreenshot('desktop-light-card.png', {
|
||||||
|
threshold: 0.02,
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop preview - dark mode', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'desktop', theme: 'dark' });
|
||||||
|
|
||||||
|
// Verify dark mode is applied
|
||||||
|
const isDark = await page.evaluate(() =>
|
||||||
|
document.documentElement.classList.contains('dark')
|
||||||
|
);
|
||||||
|
expect(isDark).toBe(true);
|
||||||
|
|
||||||
|
// Wait for YouTube card
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
// Take screenshot for visual comparison
|
||||||
|
await expect(ytCard).toHaveScreenshot('desktop-dark-card.png', {
|
||||||
|
threshold: 0.02,
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop preview - hover state shows icons', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'desktop', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
const thumbnail = ytCard.locator('[style*="360px"]');
|
||||||
|
|
||||||
|
// Hover over the thumbnail
|
||||||
|
await thumbnail.hover();
|
||||||
|
|
||||||
|
// Wait for hover state
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Check that watch later and add to queue buttons are visible
|
||||||
|
const watchLaterButton = ytCard.locator('button[aria-label="Watch later"]');
|
||||||
|
const addToQueueButton = ytCard.locator('button[aria-label="Add to queue"]');
|
||||||
|
|
||||||
|
await expect(watchLaterButton).toBeVisible();
|
||||||
|
await expect(addToQueueButton).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop preview - typography matches YouTube', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'desktop', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
// Check title typography
|
||||||
|
const title = ytCard.locator('h3').first();
|
||||||
|
const titleStyles = await title.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
fontSize: computed.fontSize,
|
||||||
|
fontWeight: computed.fontWeight,
|
||||||
|
lineHeight: computed.lineHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(titleStyles.fontSize).toBe('16px');
|
||||||
|
expect(titleStyles.fontWeight).toBe('500');
|
||||||
|
expect(titleStyles.lineHeight).toBe('22px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop preview - duration badge styling', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'desktop', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
const durationBadge = ytCard.locator('span').filter({ hasText: /\d+:\d+/ }).first();
|
||||||
|
|
||||||
|
const badgeStyles = await durationBadge.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
fontSize: computed.fontSize,
|
||||||
|
fontWeight: computed.fontWeight,
|
||||||
|
borderRadius: computed.borderRadius,
|
||||||
|
padding: computed.padding,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(badgeStyles.fontSize).toBe('12px');
|
||||||
|
expect(badgeStyles.fontWeight).toBe('500');
|
||||||
|
expect(badgeStyles.borderRadius).toBe('4px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop preview - avatar dimensions', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'desktop', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
const avatar = ytCard.locator('[style*="36px"][style*="border-radius"]').first();
|
||||||
|
|
||||||
|
const avatarStyles = await avatar.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
width: computed.width,
|
||||||
|
height: computed.height,
|
||||||
|
borderRadius: computed.borderRadius,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(avatarStyles.width).toBe('36px');
|
||||||
|
expect(avatarStyles.height).toBe('36px');
|
||||||
|
expect(avatarStyles.borderRadius).toBe('50%');
|
||||||
|
});
|
||||||
|
});
|
||||||
115
frontend/tests/visual/mobile.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { setupPreviewState } from './utils';
|
||||||
|
|
||||||
|
test.describe('Mobile Preview Visual Tests', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/tool');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile preview - light mode', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'mobile', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
// Take screenshot for visual comparison
|
||||||
|
await expect(ytCard).toHaveScreenshot('mobile-light-card.png', {
|
||||||
|
threshold: 0.02,
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile preview - dark mode', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'mobile', theme: 'dark' });
|
||||||
|
|
||||||
|
// Verify dark mode
|
||||||
|
const isDark = await page.evaluate(() =>
|
||||||
|
document.documentElement.classList.contains('dark')
|
||||||
|
);
|
||||||
|
expect(isDark).toBe(true);
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
await expect(ytCard).toHaveScreenshot('mobile-dark-card.png', {
|
||||||
|
threshold: 0.02,
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile preview - full-width thumbnail', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'mobile', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
const thumbnail = ytCard.locator('[style*="aspect-ratio"]').first();
|
||||||
|
|
||||||
|
const thumbnailStyles = await thumbnail.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
width: computed.width,
|
||||||
|
borderRadius: computed.borderRadius,
|
||||||
|
aspectRatio: computed.aspectRatio,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mobile thumbnail should be full width with 0px radius
|
||||||
|
expect(thumbnailStyles.borderRadius).toBe('0px');
|
||||||
|
expect(thumbnailStyles.aspectRatio).toContain('16');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile preview - avatar 36x36', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'mobile', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
const avatar = ytCard.locator('[style*="36px"]').first();
|
||||||
|
|
||||||
|
const avatarStyles = await avatar.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
width: computed.width,
|
||||||
|
height: computed.height,
|
||||||
|
borderRadius: computed.borderRadius,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(avatarStyles.width).toBe('36px');
|
||||||
|
expect(avatarStyles.height).toBe('36px');
|
||||||
|
expect(avatarStyles.borderRadius).toBe('50%');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile preview - metadata below thumbnail', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'mobile', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
// Verify layout structure: thumbnail first, then metadata
|
||||||
|
const children = await ytCard.locator('> div').all();
|
||||||
|
expect(children.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
// First child should be the thumbnail container (has aspect-ratio)
|
||||||
|
const firstChild = children[0];
|
||||||
|
const hasAspectRatio = await firstChild.evaluate((el) =>
|
||||||
|
el.getAttribute('style')?.includes('aspect-ratio')
|
||||||
|
);
|
||||||
|
expect(hasAspectRatio).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile preview - typography sizes', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'mobile', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
const title = ytCard.locator('h3').first();
|
||||||
|
|
||||||
|
const titleStyles = await title.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
fontSize: computed.fontSize,
|
||||||
|
fontWeight: computed.fontWeight,
|
||||||
|
lineHeight: computed.lineHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(titleStyles.fontSize).toBe('14px');
|
||||||
|
expect(titleStyles.fontWeight).toBe('500');
|
||||||
|
expect(titleStyles.lineHeight).toBe('20px');
|
||||||
|
});
|
||||||
|
});
|
||||||
140
frontend/tests/visual/sidebar.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { setupPreviewState } from './utils';
|
||||||
|
|
||||||
|
test.describe('Sidebar Preview Visual Tests', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/tool');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar preview - light mode', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'sidebar', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
// Take screenshot for visual comparison
|
||||||
|
await expect(ytCard).toHaveScreenshot('sidebar-light-card.png', {
|
||||||
|
threshold: 0.02,
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar preview - dark mode', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'sidebar', theme: 'dark' });
|
||||||
|
|
||||||
|
// Verify dark mode
|
||||||
|
const isDark = await page.evaluate(() =>
|
||||||
|
document.documentElement.classList.contains('dark')
|
||||||
|
);
|
||||||
|
expect(isDark).toBe(true);
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
await expect(ytCard).toHaveScreenshot('sidebar-dark-card.png', {
|
||||||
|
threshold: 0.02,
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar preview - thumbnail dimensions 168x94px', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'sidebar', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
const thumbnail = ytCard.locator('[style*="168px"]').first();
|
||||||
|
|
||||||
|
const thumbnailStyles = await thumbnail.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
width: computed.width,
|
||||||
|
height: computed.height,
|
||||||
|
borderRadius: computed.borderRadius,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(thumbnailStyles.width).toBe('168px');
|
||||||
|
expect(thumbnailStyles.height).toBe('94px');
|
||||||
|
expect(thumbnailStyles.borderRadius).toBe('8px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar preview - horizontal layout', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'sidebar', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
// Sidebar should have flex layout (horizontal)
|
||||||
|
const cardStyles = await ytCard.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
display: computed.display,
|
||||||
|
flexDirection: computed.flexDirection,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cardStyles.display).toBe('flex');
|
||||||
|
// Default flex direction is row (horizontal)
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar preview - no avatar', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'sidebar', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
// Sidebar variant should not have the avatar element (36x36 circle)
|
||||||
|
// Check that there's no element with 36px circle (avatar styling)
|
||||||
|
const avatarElements = await ytCard.locator('[style*="36px"][style*="50%"]').count();
|
||||||
|
|
||||||
|
// Sidebar should have 0 avatars
|
||||||
|
expect(avatarElements).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar preview - title typography 14px/500', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'sidebar', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
const title = ytCard.locator('h3').first();
|
||||||
|
|
||||||
|
const titleStyles = await title.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
fontSize: computed.fontSize,
|
||||||
|
fontWeight: computed.fontWeight,
|
||||||
|
lineHeight: computed.lineHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(titleStyles.fontSize).toBe('14px');
|
||||||
|
expect(titleStyles.fontWeight).toBe('500');
|
||||||
|
expect(titleStyles.lineHeight).toBe('20px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar preview - 8px gap between thumbnail and text', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'sidebar', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
const gapStyles = await ytCard.evaluate((el) => {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
gap: computed.gap,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(gapStyles.gap).toBe('8px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar preview - metadata below title', async ({ page }) => {
|
||||||
|
await setupPreviewState(page, { mode: 'sidebar', theme: 'light' });
|
||||||
|
|
||||||
|
const ytCard = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
// Get metadata container (right side of thumbnail)
|
||||||
|
const metadataContainer = ytCard.locator('.flex-1.min-w-0').first();
|
||||||
|
|
||||||
|
// Should have title (h3), channel name (p), and views/time (p)
|
||||||
|
const title = metadataContainer.locator('h3');
|
||||||
|
const paragraphs = metadataContainer.locator('p');
|
||||||
|
|
||||||
|
await expect(title).toBeVisible();
|
||||||
|
expect(await paragraphs.count()).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
149
frontend/tests/visual/utils.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { Page, expect } from '@playwright/test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual test utilities for YouTube Design Preview
|
||||||
|
* Provides screenshot comparison and diff generation helpers
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ScreenshotOptions {
|
||||||
|
mode: 'desktop' | 'mobile' | 'sidebar';
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference screenshots directory
|
||||||
|
*/
|
||||||
|
export const REFERENCE_DIR = path.join(__dirname, 'reference');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get reference screenshot path
|
||||||
|
*/
|
||||||
|
export function getReferenceScreenshotPath(options: ScreenshotOptions): string {
|
||||||
|
return path.join(
|
||||||
|
REFERENCE_DIR,
|
||||||
|
`${options.mode}-${options.theme}-${options.name}.png`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if reference screenshot exists
|
||||||
|
*/
|
||||||
|
export function hasReferenceScreenshot(options: ScreenshotOptions): boolean {
|
||||||
|
const refPath = getReferenceScreenshotPath(options);
|
||||||
|
return fs.existsSync(refPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up preview state for testing
|
||||||
|
*/
|
||||||
|
export async function setupPreviewState(
|
||||||
|
page: Page,
|
||||||
|
options: { mode: 'desktop' | 'mobile' | 'sidebar'; theme: 'light' | 'dark' }
|
||||||
|
): Promise<void> {
|
||||||
|
// Navigate to tool page
|
||||||
|
await page.goto('/tool');
|
||||||
|
|
||||||
|
// Wait for the page to load
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Set theme
|
||||||
|
if (options.theme === 'dark') {
|
||||||
|
// Click theme toggle to switch to dark mode
|
||||||
|
const themeButton = page.locator('button[aria-label*="dark"], button[aria-label*="light"]');
|
||||||
|
const isDark = await page.evaluate(() =>
|
||||||
|
document.documentElement.classList.contains('dark')
|
||||||
|
);
|
||||||
|
if (!isDark) {
|
||||||
|
await themeButton.click();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ensure light mode
|
||||||
|
const isDark = await page.evaluate(() =>
|
||||||
|
document.documentElement.classList.contains('dark')
|
||||||
|
);
|
||||||
|
if (isDark) {
|
||||||
|
const themeButton = page.locator('button[aria-label*="dark"], button[aria-label*="light"]');
|
||||||
|
await themeButton.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set preview mode
|
||||||
|
const modeSelector = page.locator(`[aria-label="${options.mode} preview"]`);
|
||||||
|
if (await modeSelector.isVisible()) {
|
||||||
|
await modeSelector.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a test thumbnail
|
||||||
|
*/
|
||||||
|
export async function uploadTestThumbnail(page: Page, imagePath: string): Promise<void> {
|
||||||
|
const fileInput = page.locator('input[type="file"]');
|
||||||
|
await fileInput.setInputFiles(imagePath);
|
||||||
|
|
||||||
|
// Wait for upload to complete
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a screenshot of the preview area and compare with reference
|
||||||
|
* Uses Playwright's built-in visual comparison with 98% threshold
|
||||||
|
*/
|
||||||
|
export async function comparePreviewScreenshot(
|
||||||
|
page: Page,
|
||||||
|
options: ScreenshotOptions
|
||||||
|
): Promise<void> {
|
||||||
|
// Locate the preview area
|
||||||
|
const previewArea = page.locator('.yt-card').first();
|
||||||
|
|
||||||
|
await expect(previewArea).toHaveScreenshot(
|
||||||
|
`${options.mode}-${options.theme}-${options.name}.png`,
|
||||||
|
{
|
||||||
|
threshold: 0.02, // 98% match
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a full page screenshot for visual regression
|
||||||
|
*/
|
||||||
|
export async function takeFullPageScreenshot(
|
||||||
|
page: Page,
|
||||||
|
name: string
|
||||||
|
): Promise<Buffer> {
|
||||||
|
return await page.screenshot({
|
||||||
|
fullPage: true,
|
||||||
|
path: path.join(__dirname, 'snapshots', `${name}.png`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual match result interface
|
||||||
|
*/
|
||||||
|
export interface VisualMatchResult {
|
||||||
|
passed: boolean;
|
||||||
|
matchPercentage: number;
|
||||||
|
diffImagePath?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate visual match percentage (for custom comparison)
|
||||||
|
* Note: Playwright's built-in comparison is preferred
|
||||||
|
*/
|
||||||
|
export function calculateMatchPercentage(
|
||||||
|
actualPixels: number,
|
||||||
|
differentPixels: number
|
||||||
|
): number {
|
||||||
|
if (actualPixels === 0) return 0;
|
||||||
|
return ((actualPixels - differentPixels) / actualPixels) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Threshold constant (98% match as per specification)
|
||||||
|
*/
|
||||||
|
export const VISUAL_MATCH_THRESHOLD = 98;
|
||||||
@@ -22,7 +22,13 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
|
/* Path alias */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
{
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
@@ -12,6 +18,10 @@ export default defineConfig({
|
|||||||
target: 'http://localhost:4000',
|
target: 'http://localhost:4000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
'/uploads': {
|
||||||
|
target: 'http://localhost:4000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
190
scripts/capture-youtube-reference.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* YouTube Reference Screenshot Capture Script
|
||||||
|
*
|
||||||
|
* Captures reference screenshots from live YouTube for visual comparison testing.
|
||||||
|
* Run weekly to keep baselines current with YouTube's design changes.
|
||||||
|
*
|
||||||
|
* Usage: npx ts-node scripts/capture-youtube-reference.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { chromium, Browser, Page } from '@playwright/test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const OUTPUT_DIR = path.join(__dirname, '../specs/002-youtube-design-preview/reference');
|
||||||
|
const VIEWPORT = { width: 1920, height: 1080 };
|
||||||
|
|
||||||
|
interface CaptureConfig {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
selector: string;
|
||||||
|
waitFor?: string;
|
||||||
|
theme?: 'light' | 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
const CAPTURES: CaptureConfig[] = [
|
||||||
|
{
|
||||||
|
name: 'youtube-desktop-homepage',
|
||||||
|
url: 'https://www.youtube.com',
|
||||||
|
selector: 'ytd-rich-item-renderer',
|
||||||
|
waitFor: 'ytd-rich-item-renderer',
|
||||||
|
theme: 'light',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'youtube-search-results',
|
||||||
|
url: 'https://www.youtube.com/results?search_query=tutorial',
|
||||||
|
selector: 'ytd-video-renderer',
|
||||||
|
waitFor: 'ytd-video-renderer',
|
||||||
|
theme: 'light',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'youtube-watch-sidebar',
|
||||||
|
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
selector: 'ytd-compact-video-renderer, yt-lockup-view-model',
|
||||||
|
waitFor: 'ytd-watch-flexy',
|
||||||
|
theme: 'light',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'youtube-trending',
|
||||||
|
url: 'https://www.youtube.com/feed/trending',
|
||||||
|
selector: 'ytd-video-renderer',
|
||||||
|
waitFor: 'ytd-video-renderer',
|
||||||
|
theme: 'light',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function ensureOutputDir(): Promise<void> {
|
||||||
|
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||||
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setTheme(page: Page, theme: 'light' | 'dark'): Promise<void> {
|
||||||
|
// YouTube uses document.documentElement attributes for theming
|
||||||
|
if (theme === 'dark') {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.setAttribute('dark', 'true');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Light is default
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureScreenshot(
|
||||||
|
browser: Browser,
|
||||||
|
config: CaptureConfig
|
||||||
|
): Promise<string> {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: VIEWPORT,
|
||||||
|
userAgent:
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Capturing: ${config.name}`);
|
||||||
|
console.log(` URL: ${config.url}`);
|
||||||
|
|
||||||
|
await page.goto(config.url, { waitUntil: 'networkidle' });
|
||||||
|
|
||||||
|
if (config.waitFor) {
|
||||||
|
await page.waitForSelector(config.waitFor, { timeout: 30000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.theme) {
|
||||||
|
await setTheme(page, config.theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for images to load
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Generate timestamp for versioning
|
||||||
|
const timestamp = new Date().toISOString().split('T')[0];
|
||||||
|
const filename = `${config.name}-${timestamp}.png`;
|
||||||
|
const filepath = path.join(OUTPUT_DIR, filename);
|
||||||
|
|
||||||
|
// Full page screenshot
|
||||||
|
await page.screenshot({
|
||||||
|
path: filepath,
|
||||||
|
fullPage: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` Saved: ${filename}`);
|
||||||
|
|
||||||
|
// Also capture just the video card element if possible
|
||||||
|
try {
|
||||||
|
const element = await page.locator(config.selector).first();
|
||||||
|
if (await element.isVisible()) {
|
||||||
|
const elementFilename = `${config.name}-element-${timestamp}.png`;
|
||||||
|
await element.screenshot({
|
||||||
|
path: path.join(OUTPUT_DIR, elementFilename),
|
||||||
|
});
|
||||||
|
console.log(` Saved element: ${elementFilename}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` Could not capture element screenshot`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath;
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log('YouTube Reference Screenshot Capture');
|
||||||
|
console.log('====================================');
|
||||||
|
console.log(`Output directory: ${OUTPUT_DIR}`);
|
||||||
|
console.log(`Viewport: ${VIEWPORT.width}x${VIEWPORT.height}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
await ensureOutputDir();
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const config of CAPTURES) {
|
||||||
|
try {
|
||||||
|
await captureScreenshot(browser, config);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error capturing ${config.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also capture dark mode versions
|
||||||
|
console.log('\nCapturing dark mode versions...');
|
||||||
|
for (const config of CAPTURES.slice(0, 2)) {
|
||||||
|
try {
|
||||||
|
await captureScreenshot(browser, {
|
||||||
|
...config,
|
||||||
|
name: `${config.name}-dark`,
|
||||||
|
theme: 'dark',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error capturing dark mode ${config.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nCapture complete!');
|
||||||
|
console.log(`Screenshots saved to: ${OUTPUT_DIR}`);
|
||||||
|
|
||||||
|
// Generate manifest
|
||||||
|
const manifest = {
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
viewport: VIEWPORT,
|
||||||
|
screenshots: fs.readdirSync(OUTPUT_DIR).filter((f) => f.endsWith('.png')),
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(OUTPUT_DIR, 'manifest.json'),
|
||||||
|
JSON.stringify(manifest, null, 2)
|
||||||
|
);
|
||||||
|
console.log('Manifest saved: manifest.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
41
specs/001-google-oauth-auth/checklists/requirements.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Specification Quality Checklist: Google OAuth Authentication Screen
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-01-29
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
All checklist items pass validation. The specification is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||||
|
|
||||||
|
**Validation Summary**:
|
||||||
|
- 3 user stories covering sign-in, session persistence, and sign-out
|
||||||
|
- 10 functional requirements, all testable
|
||||||
|
- 5 success criteria with measurable outcomes
|
||||||
|
- 4 edge cases identified
|
||||||
|
- Assumptions clearly documented
|
||||||
236
specs/001-google-oauth-auth/contracts/auth-api.yaml
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: ThumbPreview Auth API
|
||||||
|
description: Authentication endpoints for Google OAuth integration
|
||||||
|
version: 1.0.0
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: /api/auth
|
||||||
|
description: Auth API base path
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/google:
|
||||||
|
get:
|
||||||
|
summary: Initiate Google OAuth flow
|
||||||
|
description: Redirects user to Google consent screen. Called via browser navigation (not AJAX).
|
||||||
|
operationId: initiateGoogleAuth
|
||||||
|
tags:
|
||||||
|
- OAuth
|
||||||
|
responses:
|
||||||
|
'302':
|
||||||
|
description: Redirect to Google OAuth consent screen
|
||||||
|
headers:
|
||||||
|
Location:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Google OAuth authorization URL
|
||||||
|
|
||||||
|
/google/callback:
|
||||||
|
get:
|
||||||
|
summary: Google OAuth callback
|
||||||
|
description: |
|
||||||
|
Handles Google OAuth callback. Creates/updates user, issues tokens,
|
||||||
|
redirects to frontend with access token.
|
||||||
|
operationId: googleCallback
|
||||||
|
tags:
|
||||||
|
- OAuth
|
||||||
|
parameters:
|
||||||
|
- name: code
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: OAuth authorization code from Google
|
||||||
|
- name: state
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: OAuth state parameter (CSRF protection)
|
||||||
|
responses:
|
||||||
|
'302':
|
||||||
|
description: Redirect to frontend with access token
|
||||||
|
headers:
|
||||||
|
Location:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Frontend callback URL with token parameter
|
||||||
|
Set-Cookie:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: HttpOnly refresh token cookie
|
||||||
|
'401':
|
||||||
|
description: OAuth validation failed
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/refresh:
|
||||||
|
post:
|
||||||
|
summary: Refresh access token
|
||||||
|
description: |
|
||||||
|
Uses refresh token from HttpOnly cookie to issue new access token.
|
||||||
|
Rotates refresh token on each use.
|
||||||
|
operationId: refreshToken
|
||||||
|
tags:
|
||||||
|
- Session
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: New tokens issued
|
||||||
|
headers:
|
||||||
|
Set-Cookie:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: New HttpOnly refresh token cookie
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/AuthResponse'
|
||||||
|
'401':
|
||||||
|
description: Invalid or expired refresh token
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/me:
|
||||||
|
get:
|
||||||
|
summary: Get current user
|
||||||
|
description: Returns the authenticated user's profile information
|
||||||
|
operationId: getCurrentUser
|
||||||
|
tags:
|
||||||
|
- User
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: User profile
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UserResponse'
|
||||||
|
'401':
|
||||||
|
description: Not authenticated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/logout:
|
||||||
|
post:
|
||||||
|
summary: Sign out user
|
||||||
|
description: |
|
||||||
|
Revokes current refresh token and clears cookie.
|
||||||
|
Access token remains valid until expiration (short-lived).
|
||||||
|
operationId: logout
|
||||||
|
tags:
|
||||||
|
- Session
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successfully logged out
|
||||||
|
headers:
|
||||||
|
Set-Cookie:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Cleared refresh token cookie
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MessageResponse'
|
||||||
|
'401':
|
||||||
|
description: Not authenticated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
description: JWT access token in Authorization header
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
AuthResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- accessToken
|
||||||
|
- user
|
||||||
|
properties:
|
||||||
|
accessToken:
|
||||||
|
type: string
|
||||||
|
description: JWT access token (15 min expiry)
|
||||||
|
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
|
user:
|
||||||
|
$ref: '#/components/schemas/UserResponse'
|
||||||
|
|
||||||
|
UserResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- email
|
||||||
|
- displayName
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: User unique identifier
|
||||||
|
example: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
description: User email address
|
||||||
|
example: user@example.com
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
description: User display name
|
||||||
|
example: John Doe
|
||||||
|
avatarUrl:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
nullable: true
|
||||||
|
description: User profile picture URL
|
||||||
|
example: https://lh3.googleusercontent.com/a/...
|
||||||
|
|
||||||
|
MessageResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- message
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: Success message
|
||||||
|
example: Successfully logged out
|
||||||
|
|
||||||
|
ErrorResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- statusCode
|
||||||
|
- message
|
||||||
|
properties:
|
||||||
|
statusCode:
|
||||||
|
type: integer
|
||||||
|
description: HTTP status code
|
||||||
|
example: 401
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: Error message
|
||||||
|
example: Invalid or expired token
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
description: Error type
|
||||||
|
example: Unauthorized
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: OAuth
|
||||||
|
description: Google OAuth authentication flow
|
||||||
|
- name: Session
|
||||||
|
description: Token management and session operations
|
||||||
|
- name: User
|
||||||
|
description: User profile operations
|
||||||
102
specs/001-google-oauth-auth/data-model.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Data Model: Google OAuth Authentication
|
||||||
|
|
||||||
|
**Feature**: 001-google-oauth-auth
|
||||||
|
**Date**: 2026-01-29
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### User
|
||||||
|
|
||||||
|
Represents an authenticated user in the system.
|
||||||
|
|
||||||
|
| Field | Type | Constraints | Description |
|
||||||
|
|-------|------|-------------|-------------|
|
||||||
|
| id | UUID | PK, auto-generated | Unique identifier |
|
||||||
|
| googleId | string | UNIQUE, NOT NULL, indexed | Google account identifier |
|
||||||
|
| email | string | UNIQUE, NOT NULL, indexed | User's email from Google |
|
||||||
|
| displayName | string | NOT NULL | User's display name from Google |
|
||||||
|
| avatarUrl | string | NULLABLE | Profile picture URL from Google |
|
||||||
|
| createdAt | timestamp | NOT NULL, auto | Account creation timestamp |
|
||||||
|
| lastLoginAt | timestamp | NOT NULL | Last successful login timestamp |
|
||||||
|
|
||||||
|
**Indexes**:
|
||||||
|
- `idx_user_google_id` on `googleId` (OAuth lookup)
|
||||||
|
- `idx_user_email` on `email` (user queries)
|
||||||
|
|
||||||
|
**Validation Rules**:
|
||||||
|
- `googleId`: Non-empty string from Google profile
|
||||||
|
- `email`: Valid email format (validated by Google)
|
||||||
|
- `displayName`: Non-empty string, max 255 characters
|
||||||
|
|
||||||
|
### RefreshToken
|
||||||
|
|
||||||
|
Tracks issued refresh tokens for session management and revocation.
|
||||||
|
|
||||||
|
| Field | Type | Constraints | Description |
|
||||||
|
|-------|------|-------------|-------------|
|
||||||
|
| id | UUID | PK, auto-generated | Unique identifier |
|
||||||
|
| userId | UUID | FK → User.id, NOT NULL | Associated user |
|
||||||
|
| token | string | UNIQUE, NOT NULL, indexed | Hashed refresh token |
|
||||||
|
| expiresAt | timestamp | NOT NULL | Token expiration time |
|
||||||
|
| createdAt | timestamp | NOT NULL, auto | Token creation timestamp |
|
||||||
|
| revokedAt | timestamp | NULLABLE | Revocation timestamp (soft delete) |
|
||||||
|
|
||||||
|
**Indexes**:
|
||||||
|
- `idx_refresh_token_token` on `token` (token lookup)
|
||||||
|
- `idx_refresh_token_user_id` on `userId` (user token queries)
|
||||||
|
|
||||||
|
**Validation Rules**:
|
||||||
|
- `expiresAt`: Must be in the future when created
|
||||||
|
- `token`: Hashed with bcrypt or similar before storage
|
||||||
|
|
||||||
|
**Lifecycle**:
|
||||||
|
- Created: On successful OAuth login
|
||||||
|
- Active: `revokedAt` is NULL and `expiresAt` > now
|
||||||
|
- Revoked: When user logs out or token is rotated
|
||||||
|
- Expired: `expiresAt` <= now (cleaned up periodically)
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
```
|
||||||
|
User 1 ──────< RefreshToken
|
||||||
|
(one-to-many)
|
||||||
|
```
|
||||||
|
|
||||||
|
- One User can have multiple RefreshTokens (multiple devices/sessions)
|
||||||
|
- When User is deleted, cascade delete all RefreshTokens
|
||||||
|
|
||||||
|
## State Transitions
|
||||||
|
|
||||||
|
### User Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
[New] ──(first OAuth login)──> [Active]
|
||||||
|
│
|
||||||
|
(subsequent logins)
|
||||||
|
↓
|
||||||
|
[Active] (lastLoginAt updated)
|
||||||
|
```
|
||||||
|
|
||||||
|
### RefreshToken Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
[Created] ──(user logout)──> [Revoked]
|
||||||
|
│
|
||||||
|
├──(token refresh)──> [Rotated/New Created]
|
||||||
|
│
|
||||||
|
└──(time passes)──> [Expired]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Volume Assumptions
|
||||||
|
|
||||||
|
- Users: Expected < 10,000 initially
|
||||||
|
- RefreshTokens: ~1-3 per user (multiple devices)
|
||||||
|
- Token cleanup: Daily job to remove expired/revoked tokens older than 30 days
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
New tables required:
|
||||||
|
1. `users` - Core user table
|
||||||
|
2. `refresh_tokens` - Token tracking for session management
|
||||||
|
|
||||||
|
No modifications to existing tables (`thumbnails`, `youtube_cache`).
|
||||||
126
specs/001-google-oauth-auth/plan.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# Implementation Plan: Google OAuth Authentication Screen
|
||||||
|
|
||||||
|
**Branch**: `001-google-oauth-auth` | **Date**: 2026-01-29 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/001-google-oauth-auth/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Implement a Google OAuth 2.0 authentication screen that gates access to the ThumbPreview tool. Users must authenticate via Google before accessing protected routes (/tool). The implementation uses redirect-based OAuth flow with session persistence (7-day validity), preserving the user's originally requested destination after login. Backend handles OAuth callback and session management; frontend provides auth UI and route protection.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.x (frontend + backend)
|
||||||
|
**Primary Dependencies**:
|
||||||
|
- Frontend: React 19.x, React Router 7.x, Zustand 5.x, @tanstack/react-query 5.x, shadcn/ui
|
||||||
|
- Backend: NestJS 11.x, TypeORM 0.3.x, @nestjs/passport, passport-google-oauth20
|
||||||
|
**Storage**: PostgreSQL (users, sessions via TypeORM)
|
||||||
|
**Testing**: Jest (backend), manual testing (frontend)
|
||||||
|
**Target Platform**: Web (desktop + mobile browsers)
|
||||||
|
**Project Type**: Web application (frontend + backend monorepo)
|
||||||
|
**Performance Goals**: OAuth flow completion < 30 seconds, session validation < 100ms
|
||||||
|
**Constraints**: Must work with existing Vite proxy setup, no .env in frontend
|
||||||
|
**Scale/Scope**: Single user role, 7-day session validity with refresh
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Tech Stack | ✅ PASS | Using React 19, NestJS 11, TypeORM, PostgreSQL per constitution |
|
||||||
|
| II. Architecture | ✅ PASS | Following monorepo structure: frontend/src/, backend/src/modules/ |
|
||||||
|
| III. Styling & UI | ✅ PASS | Will use shadcn/ui Button, Card components with Tailwind |
|
||||||
|
| IV. Data Management | ✅ PASS | Zustand for auth state, React Query for user data, TypeORM for persistence |
|
||||||
|
| V. Development Practices | ✅ PASS | TypeScript strict mode, class-validator DTOs, ESLint |
|
||||||
|
|
||||||
|
**New Dependencies Required**:
|
||||||
|
- `@nestjs/passport` + `passport` + `passport-google-oauth20` - OAuth authentication (NestJS official, MIT license)
|
||||||
|
- `@types/passport-google-oauth20` - TypeScript types
|
||||||
|
|
||||||
|
**Justification**: Passport.js is the de facto standard for Node.js authentication. @nestjs/passport provides official NestJS integration. These are well-maintained, MIT-licensed, and have minimal bundle impact (backend only).
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/001-google-oauth-auth/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
├── quickstart.md # Phase 1 output
|
||||||
|
├── contracts/ # Phase 1 output
|
||||||
|
│ └── auth-api.yaml # OpenAPI spec for auth endpoints
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks command)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/
|
||||||
|
├── src/
|
||||||
|
│ ├── modules/
|
||||||
|
│ │ ├── auth/ # NEW: Authentication module
|
||||||
|
│ │ │ ├── auth.module.ts
|
||||||
|
│ │ │ ├── auth.controller.ts
|
||||||
|
│ │ │ ├── auth.service.ts
|
||||||
|
│ │ │ ├── google.strategy.ts
|
||||||
|
│ │ │ ├── jwt.strategy.ts
|
||||||
|
│ │ │ ├── guards/
|
||||||
|
│ │ │ │ ├── jwt-auth.guard.ts
|
||||||
|
│ │ │ │ └── google-auth.guard.ts
|
||||||
|
│ │ │ └── dto/
|
||||||
|
│ │ │ └── auth-response.dto.ts
|
||||||
|
│ │ ├── thumbnails/ # Existing
|
||||||
|
│ │ └── youtube/ # Existing
|
||||||
|
│ └── entities/
|
||||||
|
│ ├── user.entity.ts # NEW
|
||||||
|
│ ├── thumbnail.entity.ts # Existing
|
||||||
|
│ └── youtube-cache.entity.ts # Existing
|
||||||
|
└── tests/
|
||||||
|
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── AuthGuard.tsx # NEW: Route protection wrapper
|
||||||
|
│ │ └── UserMenu.tsx # NEW: User avatar + sign out
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── LoginPage.tsx # NEW: Auth screen
|
||||||
|
│ │ ├── AuthCallbackPage.tsx # NEW: OAuth callback handler
|
||||||
|
│ │ ├── LandingPage.tsx # Existing (public)
|
||||||
|
│ │ └── ToolPage.tsx # Existing (protected)
|
||||||
|
│ ├── store/
|
||||||
|
│ │ └── authStore.ts # NEW: Auth state (Zustand)
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── auth.ts # NEW: Auth API client
|
||||||
|
│ └── hooks/
|
||||||
|
│ └── useAuth.ts # NEW: Auth hook
|
||||||
|
└── tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Web application structure with frontend/backend separation per constitution. Auth module added to backend following NestJS module pattern. Frontend gets new pages for login flow and components for route protection.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations. All implementation follows established patterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-Design Constitution Re-Check
|
||||||
|
|
||||||
|
*Re-validated after Phase 1 design completion.*
|
||||||
|
|
||||||
|
| Principle | Status | Validation |
|
||||||
|
|-----------|--------|------------|
|
||||||
|
| I. Tech Stack | ✅ PASS | Dependencies align: @nestjs/passport, passport-google-oauth20 (MIT, maintained) |
|
||||||
|
| II. Architecture | ✅ PASS | Auth module follows NestJS pattern, frontend follows components/pages/store structure |
|
||||||
|
| III. Styling & UI | ✅ PASS | LoginPage will use shadcn/ui Button, Card; Tailwind for layout |
|
||||||
|
| IV. Data Management | ✅ PASS | User entity uses UUID PK, Zustand for auth state, TypeORM for persistence |
|
||||||
|
| V. Development Practices | ✅ PASS | DTOs with class-validator, explicit TypeScript types, ESLint compliance |
|
||||||
|
|
||||||
|
**Phase 1 Artifacts Generated**:
|
||||||
|
- ✅ `research.md` - OAuth implementation patterns documented
|
||||||
|
- ✅ `data-model.md` - User and RefreshToken entities defined
|
||||||
|
- ✅ `contracts/auth-api.yaml` - OpenAPI spec for 5 auth endpoints
|
||||||
|
- ✅ `quickstart.md` - Setup and testing guide
|
||||||
|
- ✅ `CLAUDE.md` - Agent context updated with new technologies
|
||||||
127
specs/001-google-oauth-auth/quickstart.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Quickstart: Google OAuth Authentication
|
||||||
|
|
||||||
|
**Feature**: 001-google-oauth-auth
|
||||||
|
**Date**: 2026-01-29
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Google Cloud Console Setup**
|
||||||
|
- Create a project at https://console.cloud.google.com
|
||||||
|
- Enable "Google+ API" or "Google Identity" API
|
||||||
|
- Configure OAuth consent screen (External, if not Google Workspace)
|
||||||
|
- Create OAuth 2.0 credentials (Web application type)
|
||||||
|
- Add authorized redirect URI: `http://localhost:3000/api/auth/google/callback`
|
||||||
|
|
||||||
|
2. **Environment Variables**
|
||||||
|
Add to `backend/.env`:
|
||||||
|
```env
|
||||||
|
# Google OAuth
|
||||||
|
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=your-client-secret
|
||||||
|
GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET=your-secure-random-secret-min-32-chars
|
||||||
|
JWT_ACCESS_EXPIRATION=15m
|
||||||
|
JWT_REFRESH_EXPIRATION=7d
|
||||||
|
|
||||||
|
# Frontend URL (for redirects)
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Backend Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install @nestjs/passport @nestjs/jwt passport passport-google-oauth20 bcrypt
|
||||||
|
npm install -D @types/passport-google-oauth20 @types/bcrypt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Dependencies
|
||||||
|
|
||||||
|
No additional dependencies required. Uses existing:
|
||||||
|
- react-router-dom (routing)
|
||||||
|
- zustand (auth state)
|
||||||
|
- axios (API calls)
|
||||||
|
- shadcn/ui (UI components)
|
||||||
|
|
||||||
|
## Database Migration
|
||||||
|
|
||||||
|
Create and run migration for new tables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm run typeorm:generate -- -n CreateAuthTables
|
||||||
|
npm run typeorm:run
|
||||||
|
```
|
||||||
|
|
||||||
|
Tables created:
|
||||||
|
- `users` (id, googleId, email, displayName, avatarUrl, createdAt, lastLoginAt)
|
||||||
|
- `refresh_tokens` (id, userId, token, expiresAt, createdAt, revokedAt)
|
||||||
|
|
||||||
|
## Quick Test Flow
|
||||||
|
|
||||||
|
1. **Start the backend**:
|
||||||
|
```bash
|
||||||
|
cd backend && npm run start:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Start the frontend**:
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Test OAuth flow**:
|
||||||
|
- Navigate to `http://localhost:5173/tool`
|
||||||
|
- Should redirect to login page
|
||||||
|
- Click "Sign in with Google"
|
||||||
|
- Complete Google consent
|
||||||
|
- Should redirect back to `/tool` with active session
|
||||||
|
|
||||||
|
4. **Verify session persistence**:
|
||||||
|
- Refresh the page
|
||||||
|
- Should remain on `/tool` without re-authenticating
|
||||||
|
|
||||||
|
5. **Test sign out**:
|
||||||
|
- Click user menu → Sign out
|
||||||
|
- Should redirect to login page
|
||||||
|
- Navigating to `/tool` should redirect to login
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/auth/google` | Initiate OAuth (browser redirect) |
|
||||||
|
| GET | `/api/auth/google/callback` | OAuth callback handler |
|
||||||
|
| POST | `/api/auth/refresh` | Refresh access token |
|
||||||
|
| GET | `/api/auth/me` | Get current user (requires auth) |
|
||||||
|
| POST | `/api/auth/logout` | Sign out (requires auth) |
|
||||||
|
|
||||||
|
## Frontend Routes
|
||||||
|
|
||||||
|
| Path | Component | Protected |
|
||||||
|
|------|-----------|-----------|
|
||||||
|
| `/` | LandingPage | No |
|
||||||
|
| `/login` | LoginPage | No |
|
||||||
|
| `/auth/callback` | AuthCallbackPage | No |
|
||||||
|
| `/tool` | ToolPage | Yes |
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**"redirect_uri_mismatch" error**:
|
||||||
|
- Ensure callback URL in Google Console matches `GOOGLE_CALLBACK_URL` exactly
|
||||||
|
- Include protocol (http/https) and port
|
||||||
|
|
||||||
|
**"Invalid token" after refresh**:
|
||||||
|
- Check `JWT_SECRET` is set and consistent
|
||||||
|
- Verify refresh token cookie is being sent (check browser DevTools)
|
||||||
|
|
||||||
|
**CORS errors**:
|
||||||
|
- Ensure `FRONTEND_URL` matches exactly (including port)
|
||||||
|
- Check `credentials: true` in CORS config
|
||||||
|
|
||||||
|
**Session not persisting**:
|
||||||
|
- Verify `httpOnly` cookie is set (check Application → Cookies in DevTools)
|
||||||
|
- Check `sameSite` and `secure` settings match environment
|
||||||
114
specs/001-google-oauth-auth/research.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Research: Google OAuth Authentication
|
||||||
|
|
||||||
|
**Feature**: 001-google-oauth-auth
|
||||||
|
**Date**: 2026-01-29
|
||||||
|
|
||||||
|
## 1. NestJS Passport Google OAuth Setup
|
||||||
|
|
||||||
|
**Decision**: Use `@nestjs/passport` with `passport-google-oauth20` strategy
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Official NestJS integration provides decorator-based guards and strategies
|
||||||
|
- `passport-google-oauth20` is the actively maintained Google OAuth package
|
||||||
|
- Seamless integration with NestJS dependency injection and module system
|
||||||
|
- Built-in validate method simplifies user profile handling
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- `passport-google-oauth2`: Older package, less TypeScript support
|
||||||
|
- Custom OAuth implementation: Too much boilerplate, harder to maintain
|
||||||
|
|
||||||
|
## 2. Authentication Strategy for SPAs
|
||||||
|
|
||||||
|
**Decision**: Hybrid approach - JWT access tokens (short-lived, in-memory) + HttpOnly cookie refresh tokens (7-day)
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Access tokens in memory provide stateless, scalable API authentication
|
||||||
|
- HttpOnly refresh tokens prevent XSS attacks while enabling token rotation
|
||||||
|
- Best balance between security, UX, and distributed system requirements
|
||||||
|
- Industry standard for modern SPAs
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- Pure JWT (stateless): Can't invalidate tokens on logout/compromise
|
||||||
|
- Pure sessions: Doesn't scale well, requires sticky sessions
|
||||||
|
- localStorage for tokens: Vulnerable to XSS attacks
|
||||||
|
|
||||||
|
## 3. Token Storage in React
|
||||||
|
|
||||||
|
**Decision**: Access tokens in Zustand store (memory), refresh tokens in HttpOnly cookies
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- HttpOnly cookies are inaccessible to JavaScript, preventing XSS token theft
|
||||||
|
- In-memory storage for short-lived access tokens (15-30 min)
|
||||||
|
- CSRF protection via `sameSite: 'strict'` and CORS configuration
|
||||||
|
- Silent refresh mechanism restores access token on page reload
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- localStorage: Vulnerable to XSS attacks
|
||||||
|
- sessionStorage: Data lost on tab close, still XSS vulnerable
|
||||||
|
|
||||||
|
## 4. React Router Protected Routes
|
||||||
|
|
||||||
|
**Decision**: Layout-based protection using AuthGuard wrapper component with Navigate
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- React Router v7 provides clean, declarative route protection
|
||||||
|
- Easy to preserve intended destination via `location.state.from`
|
||||||
|
- Simple loading state handling during auth check
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- Higher-Order Component (HOC): More verbose in modern React
|
||||||
|
- Route-level guards: Duplicates auth logic across routes
|
||||||
|
|
||||||
|
## 5. OAuth Redirect Flow Implementation
|
||||||
|
|
||||||
|
**Decision**: Backend-initiated flow with frontend callback handler
|
||||||
|
|
||||||
|
**Flow**:
|
||||||
|
1. Frontend links to `/api/auth/google` (backend initiates OAuth)
|
||||||
|
2. NestJS redirects to Google consent screen
|
||||||
|
3. Google redirects to `/api/auth/google/callback`
|
||||||
|
4. Backend validates tokens, creates/updates user, issues JWT
|
||||||
|
5. Backend redirects to frontend `/auth/callback?token=...`
|
||||||
|
6. Frontend stores token, clears URL, redirects to intended destination
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- NestJS handles OAuth state management and token exchange securely
|
||||||
|
- Supports TypeORM user creation/lookup with transaction safety
|
||||||
|
- Token cleared from URL immediately for security
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- Frontend-initiated PKCE flow: More complex state management
|
||||||
|
- Popup-based flow: Poor UX on mobile, blocked by some browsers
|
||||||
|
|
||||||
|
## 6. Session Validity & Token Expiration
|
||||||
|
|
||||||
|
**Decision**:
|
||||||
|
- Access tokens: 15 minutes (short-lived for security)
|
||||||
|
- Refresh tokens: 7 days (per spec assumptions)
|
||||||
|
- Automatic token refresh via 401 interceptor in Axios
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Short access tokens limit exposure window if compromised
|
||||||
|
- 7-day refresh aligns with spec requirements
|
||||||
|
- Auto-refresh provides seamless UX
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
**CORS Configuration Required**:
|
||||||
|
```
|
||||||
|
origin: FRONTEND_URL
|
||||||
|
credentials: true (allow cookies)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Environment Variables Needed**:
|
||||||
|
- GOOGLE_CLIENT_ID
|
||||||
|
- GOOGLE_CLIENT_SECRET
|
||||||
|
- GOOGLE_CALLBACK_URL
|
||||||
|
- JWT_SECRET
|
||||||
|
- JWT_ACCESS_EXPIRATION (15m)
|
||||||
|
- JWT_REFRESH_EXPIRATION (7d)
|
||||||
|
- FRONTEND_URL
|
||||||
|
|
||||||
|
**TypeORM Indexes**:
|
||||||
|
- Index `googleId` for OAuth lookups
|
||||||
|
- Index `email` for user queries
|
||||||
106
specs/001-google-oauth-auth/spec.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Feature Specification: Google OAuth Authentication Screen
|
||||||
|
|
||||||
|
**Feature Branch**: `001-google-oauth-auth`
|
||||||
|
**Created**: 2026-01-29
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "реализовать экран авторизации для того чтобы начать пользоваться инструментом, авторизация должна быть через кнопку google oauth"
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-01-29
|
||||||
|
|
||||||
|
- Q: Which OAuth flow type should be used (popup vs redirect)? → A: Redirect flow (full page redirect to Google, then back)
|
||||||
|
- Q: Post-login redirect behavior for deep links? → A: Return to originally requested page (preserve intended destination)
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - First-Time User Sign In (Priority: P1)
|
||||||
|
|
||||||
|
A new user visits the ThumbPreview tool for the first time. Before accessing the main functionality (thumbnail preview tool), they are presented with a clean authentication screen. The user clicks the "Sign in with Google" button, completes the Google OAuth flow via full-page redirect to Google and back, and upon successful authentication is automatically redirected to the tool page.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core functionality - without authentication, users cannot access the tool. It gates the entire application experience.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by visiting the app as a logged-out user, clicking the Google sign-in button, completing OAuth, and verifying access to the tool page is granted.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a user is not authenticated, **When** they navigate to any protected page, **Then** they are redirected to the authentication screen
|
||||||
|
2. **Given** a user is on the authentication screen, **When** they click "Sign in with Google", **Then** the Google OAuth consent flow initiates
|
||||||
|
3. **Given** a user completes Google OAuth successfully, **When** the callback is processed, **Then** the user is redirected to their originally requested page (or /tool by default) with an active session
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Returning User Session (Priority: P2)
|
||||||
|
|
||||||
|
A previously authenticated user returns to the application. If their session is still valid, they bypass the authentication screen and go directly to the tool. If the session has expired, they are shown the authentication screen to sign in again.
|
||||||
|
|
||||||
|
**Why this priority**: Provides a seamless experience for returning users while maintaining security through session management.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by authenticating, closing the browser, returning within session validity period, and verifying automatic access without re-authentication.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a user has an active session, **When** they navigate to the application, **Then** they are taken directly to the tool page without seeing the auth screen
|
||||||
|
2. **Given** a user's session has expired, **When** they navigate to the application, **Then** they are redirected to the authentication screen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - User Sign Out (Priority: P3)
|
||||||
|
|
||||||
|
An authenticated user wants to sign out of the application. They can access a sign-out option which clears their session and returns them to the authentication screen.
|
||||||
|
|
||||||
|
**Why this priority**: Essential for security and multi-user scenarios, but secondary to the core sign-in flow.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by signing in, clicking sign out, and verifying the session is cleared and the user is returned to the auth screen.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a user is authenticated, **When** they click the sign-out option, **Then** their session is terminated and they are redirected to the authentication screen
|
||||||
|
2. **Given** a user has signed out, **When** they try to access the tool page directly, **Then** they are redirected to the authentication screen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when Google OAuth is cancelled or fails mid-flow? User should remain on the auth screen with an error message indicating the sign-in was not completed.
|
||||||
|
- What happens when a user's Google account is deactivated or access is revoked? Session should be invalidated and user redirected to auth screen on next request.
|
||||||
|
- What happens if the network connection is lost during OAuth? User should see an appropriate error message and be able to retry.
|
||||||
|
- What happens if user denies Google permissions? User remains on auth screen with a message explaining that permissions are required.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST display an authentication screen to unauthenticated users before allowing access to the tool
|
||||||
|
- **FR-002**: System MUST provide a "Sign in with Google" button on the authentication screen
|
||||||
|
- **FR-003**: System MUST initiate Google OAuth 2.0 authorization flow when the sign-in button is clicked
|
||||||
|
- **FR-004**: System MUST create a user session upon successful Google OAuth callback
|
||||||
|
- **FR-005**: System MUST redirect authenticated users to their originally requested page after successful sign-in (defaulting to /tool if no prior destination)
|
||||||
|
- **FR-006**: System MUST persist user sessions to allow returning users to bypass authentication
|
||||||
|
- **FR-007**: System MUST provide a sign-out mechanism that terminates the user session
|
||||||
|
- **FR-008**: System MUST protect all tool routes, redirecting unauthenticated requests to the auth screen
|
||||||
|
- **FR-009**: System MUST display appropriate error messages when authentication fails
|
||||||
|
- **FR-010**: System MUST store basic user profile information from Google (name, email, profile picture) for display purposes
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **User**: Represents an authenticated user. Key attributes: unique identifier, Google ID, email address, display name, profile picture URL, created timestamp, last login timestamp.
|
||||||
|
- **Session**: Represents an active authentication session. Key attributes: session identifier, associated user, creation time, expiration time, validity status.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Users can complete the full sign-in flow (from auth screen to tool access) in under 30 seconds
|
||||||
|
- **SC-002**: 95% of authentication attempts complete successfully on first try (excluding user-cancelled flows)
|
||||||
|
- **SC-003**: Returning users with valid sessions access the tool without seeing the auth screen
|
||||||
|
- **SC-004**: All tool pages are inaccessible to unauthenticated users
|
||||||
|
- **SC-005**: Sign-out action fully terminates the session, preventing access without re-authentication
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Google Cloud OAuth credentials will be configured as part of implementation
|
||||||
|
- Session validity period follows industry standard (reasonable default: 7 days with refresh capability)
|
||||||
|
- The landing page (/) remains publicly accessible; only the tool page (/tool) requires authentication
|
||||||
|
- User data stored is limited to what Google provides in the standard OAuth profile scope
|
||||||
|
- Email verification is handled by Google (only verified Google accounts can sign in)
|
||||||
221
specs/001-google-oauth-auth/tasks.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# Tasks: Google OAuth Authentication Screen
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/001-google-oauth-auth/`
|
||||||
|
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/ ✅
|
||||||
|
|
||||||
|
**Tests**: Not explicitly requested - manual testing via quickstart.md
|
||||||
|
|
||||||
|
**Organization**: Tasks grouped by user story to enable independent implementation and testing.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||||
|
- Include exact file paths in descriptions
|
||||||
|
|
||||||
|
## Path Conventions
|
||||||
|
|
||||||
|
- **Web app**: `backend/src/`, `frontend/src/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Install dependencies and configure project for OAuth authentication
|
||||||
|
|
||||||
|
- [x] T001 Install backend auth dependencies: `cd backend && npm install @nestjs/passport @nestjs/jwt passport passport-google-oauth20 bcrypt`
|
||||||
|
- [x] T002 Install backend auth type definitions: `cd backend && npm install -D @types/passport-google-oauth20 @types/bcrypt`
|
||||||
|
- [x] T003 [P] Add OAuth environment variables template to backend/.env.example (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_CALLBACK_URL, JWT_SECRET, JWT_ACCESS_EXPIRATION, JWT_REFRESH_EXPIRATION, FRONTEND_URL)
|
||||||
|
- [x] T004 [P] Update backend/src/app.module.ts to import ConfigModule with .env support
|
||||||
|
- [x] T005 Configure CORS in backend/src/main.ts with credentials: true and FRONTEND_URL origin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Core entities, auth module structure, and database schema that ALL user stories depend on
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||||
|
|
||||||
|
- [x] T006 Create User entity in backend/src/entities/user.entity.ts with fields: id (UUID), googleId, email, displayName, avatarUrl, createdAt, lastLoginAt
|
||||||
|
- [x] T007 [P] Create RefreshToken entity in backend/src/entities/refresh-token.entity.ts with fields: id (UUID), userId (FK), token, expiresAt, createdAt, revokedAt
|
||||||
|
- [x] T008 Register User and RefreshToken entities in backend/src/app.module.ts TypeORM configuration
|
||||||
|
- [x] T009 Create auth module scaffold in backend/src/modules/auth/ with auth.module.ts, auth.controller.ts, auth.service.ts
|
||||||
|
- [x] T010 [P] Create DTOs in backend/src/modules/auth/dto/: auth-response.dto.ts, user-response.dto.ts
|
||||||
|
- [x] T011 [P] Create auth types in frontend/src/types/auth.ts: User interface, AuthState interface
|
||||||
|
- [x] T012 Create auth store in frontend/src/store/authStore.ts using Zustand with: user, accessToken, isLoading, setAuth, clearAuth, setLoading
|
||||||
|
- [x] T013 [P] Create auth API client in frontend/src/api/auth.ts with axios instance and methods: refreshToken, getMe, logout
|
||||||
|
|
||||||
|
**Checkpoint**: Foundation ready - user story implementation can now begin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - First-Time User Sign In (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: New user can authenticate via Google OAuth and access the tool page
|
||||||
|
|
||||||
|
**Independent Test**: Visit /tool as logged-out user → redirected to login → click "Sign in with Google" → complete Google OAuth → redirected to /tool with active session
|
||||||
|
|
||||||
|
### Backend Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T014 [US1] Implement GoogleStrategy in backend/src/modules/auth/google.strategy.ts using passport-google-oauth20
|
||||||
|
- [x] T015 [US1] Implement JwtStrategy in backend/src/modules/auth/jwt.strategy.ts for access token validation
|
||||||
|
- [x] T016 [P] [US1] Create GoogleAuthGuard in backend/src/modules/auth/guards/google-auth.guard.ts
|
||||||
|
- [x] T017 [P] [US1] Create JwtAuthGuard in backend/src/modules/auth/guards/jwt-auth.guard.ts
|
||||||
|
- [x] T018 [US1] Implement AuthService.validateOAuthUser() in backend/src/modules/auth/auth.service.ts - creates/updates user, issues tokens
|
||||||
|
- [x] T019 [US1] Implement AuthService.generateTokens() for JWT access token and refresh token generation with bcrypt hashing
|
||||||
|
- [x] T020 [US1] Add GET /auth/google route in backend/src/modules/auth/auth.controller.ts with GoogleAuthGuard
|
||||||
|
- [x] T021 [US1] Add GET /auth/google/callback route in backend/src/modules/auth/auth.controller.ts - handle OAuth callback, set HttpOnly cookie, redirect to frontend
|
||||||
|
- [x] T022 [US1] Add GET /auth/me route in backend/src/modules/auth/auth.controller.ts with JwtAuthGuard
|
||||||
|
- [x] T023 [US1] Register AuthModule in backend/src/app.module.ts imports
|
||||||
|
|
||||||
|
### Frontend Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T024 [P] [US1] Create LoginPage component in frontend/src/pages/LoginPage.tsx with "Sign in with Google" button using shadcn/ui Button and Card
|
||||||
|
- [x] T025 [P] [US1] Create AuthCallbackPage component in frontend/src/pages/AuthCallbackPage.tsx to handle OAuth callback token from URL
|
||||||
|
- [x] T026 [US1] Implement useAuth hook in frontend/src/hooks/useAuth.ts with checkAuth, login redirect, and loading state
|
||||||
|
- [x] T027 [US1] Create AuthGuard component in frontend/src/components/AuthGuard.tsx - redirects to /login if not authenticated, preserves intended destination in location.state
|
||||||
|
- [x] T028 [US1] Update frontend/src/App.tsx - add /login route, /auth/callback route, wrap /tool with AuthGuard
|
||||||
|
- [x] T029 [US1] Handle OAuth errors in LoginPage - display error message from URL params if authentication failed
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 complete - new users can sign in via Google and access protected routes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Returning User Session (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Returning user with valid session bypasses login; expired session redirects to login
|
||||||
|
|
||||||
|
**Independent Test**: Sign in → close browser → return within 7 days → automatically access /tool without login prompt
|
||||||
|
|
||||||
|
### Backend Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T030 [US2] Add POST /auth/refresh route in backend/src/modules/auth/auth.controller.ts - validate HttpOnly refresh cookie, rotate token, return new access token
|
||||||
|
- [x] T031 [US2] Implement AuthService.refreshTokens() in backend/src/modules/auth/auth.service.ts - validate stored token, check expiry, issue new pair
|
||||||
|
- [x] T032 [US2] Implement AuthService.revokeRefreshToken() for token rotation (mark old token as revoked)
|
||||||
|
|
||||||
|
### Frontend Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T033 [US2] Add silent token refresh on app mount in frontend/src/hooks/useAuth.ts - call /auth/refresh on startup
|
||||||
|
- [x] T034 [US2] Add Axios response interceptor in frontend/src/api/auth.ts - on 401, attempt refresh, retry original request
|
||||||
|
- [x] T035 [US2] Update AuthGuard in frontend/src/components/AuthGuard.tsx - show loading state during session check, then redirect or render
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 2 complete - returning users have seamless session persistence
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - User Sign Out (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Authenticated user can sign out, which clears session and returns to login
|
||||||
|
|
||||||
|
**Independent Test**: Sign in → click sign out in user menu → redirected to login → cannot access /tool
|
||||||
|
|
||||||
|
### Backend Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T036 [US3] Add POST /auth/logout route in backend/src/modules/auth/auth.controller.ts with JwtAuthGuard - revoke refresh token, clear cookie
|
||||||
|
|
||||||
|
### Frontend Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T037 [P] [US3] Create UserMenu component in frontend/src/components/UserMenu.tsx - display user avatar, name, sign out button using shadcn/ui
|
||||||
|
- [x] T038 [US3] Implement logout function in frontend/src/store/authStore.ts - call /auth/logout, clear local state
|
||||||
|
- [x] T039 [US3] Add UserMenu to ToolPage header in frontend/src/pages/ToolPage.tsx
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 3 complete - users can sign out securely
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Error handling, edge cases, and final validation
|
||||||
|
|
||||||
|
- [x] T040 [P] Add error handling for OAuth failures in backend/src/modules/auth/auth.controller.ts - redirect to frontend with error param
|
||||||
|
- [x] T041 [P] Add error display component for auth errors in frontend/src/pages/LoginPage.tsx - show user-friendly messages
|
||||||
|
- [x] T042 Update Vite proxy configuration in frontend/vite.config.ts if needed for /api/auth routes
|
||||||
|
- [x] T043 Validate implementation against quickstart.md test scenarios
|
||||||
|
- [x] T044 Run ESLint and fix any linting errors in new files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies - can start immediately
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
|
||||||
|
- **User Stories (Phase 3-5)**: All depend on Foundational phase completion
|
||||||
|
- **Polish (Phase 6)**: Depends on all user stories being complete
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - Core sign-in flow
|
||||||
|
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - Extends US1 with token refresh, but independently testable
|
||||||
|
- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - Adds logout to US1, independently testable
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Backend before frontend (API must exist before UI calls it)
|
||||||
|
- Entity/strategy before service
|
||||||
|
- Service before controller
|
||||||
|
- Controller before frontend integration
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
**Phase 1 (Setup)**:
|
||||||
|
```
|
||||||
|
T003 [P] + T004 [P] can run in parallel
|
||||||
|
```
|
||||||
|
|
||||||
|
**Phase 2 (Foundational)**:
|
||||||
|
```
|
||||||
|
T006 + T007 [P] can run in parallel (different entity files)
|
||||||
|
T010 [P] + T011 [P] + T013 [P] can run in parallel (DTOs, types, API client)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Phase 3 (User Story 1)**:
|
||||||
|
```
|
||||||
|
T016 [P] + T017 [P] can run in parallel (different guard files)
|
||||||
|
T024 [P] + T025 [P] can run in parallel (different page files)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup (T001-T005)
|
||||||
|
2. Complete Phase 2: Foundational (T006-T013)
|
||||||
|
3. Complete Phase 3: User Story 1 (T014-T029)
|
||||||
|
4. **STOP and VALIDATE**: Test sign-in flow per quickstart.md
|
||||||
|
5. Deploy/demo if ready - users can now sign in!
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Complete Setup + Foundational → Foundation ready
|
||||||
|
2. Add User Story 1 → Test independently → Deploy (MVP with sign-in)
|
||||||
|
3. Add User Story 2 → Test independently → Deploy (session persistence)
|
||||||
|
4. Add User Story 3 → Test independently → Deploy (full auth with logout)
|
||||||
|
5. Complete Polish → Final validation → Production ready
|
||||||
|
|
||||||
|
### Task Count by Story
|
||||||
|
|
||||||
|
| Phase | Task Count |
|
||||||
|
|-------|------------|
|
||||||
|
| Setup | 5 |
|
||||||
|
| Foundational | 8 |
|
||||||
|
| User Story 1 | 16 |
|
||||||
|
| User Story 2 | 6 |
|
||||||
|
| User Story 3 | 4 |
|
||||||
|
| Polish | 5 |
|
||||||
|
| **Total** | **44** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- [P] tasks = different files, no dependencies
|
||||||
|
- [Story] label maps task to specific user story for traceability
|
||||||
|
- Each user story is independently completable and testable
|
||||||
|
- Backend entities use UUID primary keys per constitution
|
||||||
|
- Frontend uses shadcn/ui components per constitution
|
||||||
|
- HttpOnly cookies for refresh tokens per research.md security recommendations
|
||||||
169
specs/002-youtube-design-preview/analysis.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Cross-Artifact Consistency Analysis
|
||||||
|
|
||||||
|
**Feature**: `002-youtube-design-preview`
|
||||||
|
**Analyzed**: 2026-01-29
|
||||||
|
**Artifacts**: spec.md, plan.md, research.md, data-model.md, contracts/types.ts, tasks.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Total Requirements (FR) | 20 |
|
||||||
|
| Total User Stories | 4 |
|
||||||
|
| Total Tasks | 50 |
|
||||||
|
| Requirements Covered | 20/20 (100%) |
|
||||||
|
| Constitution Alignment | PASS |
|
||||||
|
| Critical Issues | 0 |
|
||||||
|
| Warnings | 3 |
|
||||||
|
| Suggestions | 4 |
|
||||||
|
|
||||||
|
**Overall Status**: Ready for implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
| ID | Category | Severity | Location | Summary | Recommendation |
|
||||||
|
|----|----------|----------|----------|---------|----------------|
|
||||||
|
| A-001 | Underspecification | Warning | spec.md:FR-017 | Weekly screenshot capture mechanism not detailed | Add cron/scheduler details to plan.md or tasks.md |
|
||||||
|
| A-002 | Inconsistency | Warning | tasks.md:T041 | Script path uses `.ts` but may need to be executable | Verify ts-node setup or use compiled JS for scheduler |
|
||||||
|
| A-003 | Coverage Gap | Warning | tasks.md | FR-009 (responsive breakpoints) only partially addressed by T043 | Consider adding explicit breakpoint tests |
|
||||||
|
| A-004 | Suggestion | Info | data-model.md | `publishedAt` stored as Date but templates show "X days ago" | Add utility function for relative time formatting |
|
||||||
|
| A-005 | Suggestion | Info | contracts/types.ts | `VALIDATION_CONSTANTS.DURATION_REGEX` allows single-digit minutes | Consider stricter format `^\d{1,3}:\d{2}$` for edge cases |
|
||||||
|
| A-006 | Suggestion | Info | research.md | Sidebar dimensions (168x94) have different ratio than 16:9 | Document this intentional deviation from standard 16:9 |
|
||||||
|
| A-007 | Suggestion | Info | tasks.md | T017 hover states may conflict with mobile (no hover on touch) | Add note to disable hover states in mobile variant |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirement Coverage Matrix
|
||||||
|
|
||||||
|
| Requirement | User Story | Tasks | Status |
|
||||||
|
|-------------|------------|-------|--------|
|
||||||
|
| FR-001 (Desktop pixel-perfect) | US1 | T012-T020 | Covered |
|
||||||
|
| FR-002 (Mobile pixel-perfect) | US2 | T021-T026 | Covered |
|
||||||
|
| FR-003 (Sidebar pixel-perfect) | US3 | T027-T033 | Covered |
|
||||||
|
| FR-004 (Real-time upload) | US1-3 | T010-T011 | Covered |
|
||||||
|
| FR-005 (Metadata elements) | US1-3 | T012-T016, T020-T032 | Covered |
|
||||||
|
| FR-006 (Typography) | US1-3 | T013-T014, T023, T029 | Covered |
|
||||||
|
| FR-007 (Spacing/padding) | US1-3 | T018, T024, T031 | Covered |
|
||||||
|
| FR-008 (Hover states) | US1 | T017 | Covered |
|
||||||
|
| FR-009 (Responsive breakpoints) | - | T043 | Partial |
|
||||||
|
| FR-010 (Playwright integration) | US4 | T034-T042 | Covered |
|
||||||
|
| FR-011 (Visual diff reports) | US4 | T035 | Covered |
|
||||||
|
| FR-012 (Customizable metadata) | US1 | T020 | Covered |
|
||||||
|
| FR-013 (Duration badge) | US1-3 | T016 | Covered |
|
||||||
|
| FR-014 (Channel avatar) | US1-2 | T015, T022, T032 | Covered |
|
||||||
|
| FR-015 (Local storage) | - | T004, T006-T007, T047 | Covered |
|
||||||
|
| FR-016 (Theme toggle) | - | T008, T019 | Covered |
|
||||||
|
| FR-017 (Weekly capture) | US4 | T041 | Covered |
|
||||||
|
| FR-018 (Historical screenshots) | US4 | T036 | Covered |
|
||||||
|
| FR-019 (5MB limit) | - | T010-T011 | Covered |
|
||||||
|
| FR-020 (Image formats) | - | T010-T011 | Covered |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constitution Alignment Check
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Tech Stack | PASS | React 19.x, TS 5.x, Tailwind 4.x, shadcn/ui, Zustand - all aligned |
|
||||||
|
| II. Architecture | PASS | Feature code in `frontend/src/`, correct directory structure |
|
||||||
|
| III. Styling | PASS | YouTube CSS variables defined, Tailwind utilities used |
|
||||||
|
| IV. Data Management | PASS | Zustand for state, localStorage for persistence |
|
||||||
|
| V. Development Practices | PASS | TypeScript strict, ESLint, explicit types in contracts |
|
||||||
|
|
||||||
|
**No constitution violations detected.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Duplication Analysis
|
||||||
|
|
||||||
|
| Area | Finding |
|
||||||
|
|------|---------|
|
||||||
|
| Type Definitions | `contracts/types.ts` and `data-model.md` contain same interfaces - **Acceptable** (one is documentation, one is code) |
|
||||||
|
| CSS Values | `research.md` and `quickstart.md` both list CSS values - **Minor overlap**, quickstart is intentional quick reference |
|
||||||
|
| Default Values | `data-model.md` and `contracts/types.ts` both define defaults - **Need to keep in sync** |
|
||||||
|
|
||||||
|
**Recommendation**: Consider generating `data-model.md` from `contracts/types.ts` to prevent drift.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ambiguity Detection
|
||||||
|
|
||||||
|
| Location | Ambiguity | Resolution |
|
||||||
|
|----------|-----------|------------|
|
||||||
|
| spec.md:FR-017 | "Automated weekly capture" - when exactly? | Suggest: Sunday 00:00 UTC, document in tasks.md |
|
||||||
|
| spec.md:SC-004 | "90% of users" - how to measure in blind test? | This is a qualitative metric, may be hard to automate |
|
||||||
|
| tasks.md:T036 | "Copy reference screenshots" - from where? | From `specs/002-youtube-design-preview/reference/` (clarified in research.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task Dependency Analysis
|
||||||
|
|
||||||
|
### Critical Path
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 (Setup) → Phase 2 (Foundational) → Phase 3-6 (User Stories) → Phase 7 (Polish)
|
||||||
|
T001-T005 [parallel] T006-T011 [sequential] US1-US4 [parallel] T043-T050 [parallel]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blocking Dependencies
|
||||||
|
|
||||||
|
| Task | Blocked By | Blocks |
|
||||||
|
|------|------------|--------|
|
||||||
|
| T006-T011 | T001-T005 | T012-T050 (all user stories) |
|
||||||
|
| T037-T040 | T034-T036 | None (test tasks) |
|
||||||
|
| T049 (lint) | All implementation | T050 (validation) |
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- **Phase 1**: T001, T002, T003, T004 can all run in parallel
|
||||||
|
- **Phase 2**: T008, T009 can run in parallel after T006-T007
|
||||||
|
- **Phase 3-5**: US1, US2, US3 can be implemented in parallel by different developers
|
||||||
|
- **Phase 6**: T034, T035, T036 can run in parallel (test setup)
|
||||||
|
- **Phase 7**: T043, T044, T045, T046, T047 can all run in parallel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|------|------------|--------|------------|
|
||||||
|
| YouTube design changes during development | Medium | High | Reference screenshots versioned, weekly updates |
|
||||||
|
| Visual test flakiness | Medium | Medium | 98% threshold allows minor variance |
|
||||||
|
| localStorage quota exceeded | Low | Medium | T047 adds 10-thumbnail limit |
|
||||||
|
| Playwright MCP unavailable | Low | High | Manual screenshot capture as fallback |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
|
||||||
|
1. **Clarify weekly capture schedule**: Add specific timing (e.g., "Sundays at 00:00 UTC") to avoid ambiguity
|
||||||
|
2. **Add responsive test coverage**: T043 covers responsive breakpoints but no visual tests for intermediate sizes
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
|
||||||
|
3. **Consider relative time utility**: Add `formatRelativeTime(date: Date): string` to lib functions
|
||||||
|
4. **Document sidebar ratio**: Note in research.md that sidebar uses slightly different aspect ratio
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
|
||||||
|
5. **Sync default values**: Ensure `data-model.md` defaults match `contracts/types.ts`
|
||||||
|
6. **Mobile hover handling**: Explicitly disable hover states for mobile variant to avoid touch UX issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Actions
|
||||||
|
|
||||||
|
1. Address Warning items (A-001, A-002, A-003) before implementation begins
|
||||||
|
2. Proceed with Phase 1 (Setup) - all tasks can run in parallel
|
||||||
|
3. After Phase 2 completion, checkpoint to verify foundation before user stories
|
||||||
|
4. Consider running US1-US3 in parallel if multiple developers available
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Analysis complete. Feature is ready for implementation with minor clarifications recommended.**
|
||||||
37
specs/002-youtube-design-preview/checklists/requirements.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Specification Quality Checklist: YouTube Design Preview Replica
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-01-29
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All items passed validation
|
||||||
|
- Spec is ready for `/speckit.clarify` or `/speckit.plan`
|
||||||
|
- Playwright MCP referenced for visual testing methodology (not implementation detail)
|
||||||
|
- Reference baseline defined as YouTube's current design (January 2026)
|
||||||