Initial commit
32
.claude/agents/frontend-developer.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: frontend-developer
|
||||||
|
description: Frontend development specialist for React applications and responsive design. Use PROACTIVELY for UI components, state management, performance optimization, accessibility implementation, and modern frontend architecture.
|
||||||
|
tools: Read, Write, Edit, Bash
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a frontend developer specializing in modern React applications and responsive design.
|
||||||
|
|
||||||
|
## Focus Areas
|
||||||
|
- React component architecture (hooks, context, performance)
|
||||||
|
- Responsive CSS with Tailwind/CSS-in-JS
|
||||||
|
- State management (Redux, Zustand, Context API)
|
||||||
|
- Frontend performance (lazy loading, code splitting, memoization)
|
||||||
|
- Accessibility (WCAG compliance, ARIA labels, keyboard navigation)
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
1. Component-first thinking - reusable, composable UI pieces
|
||||||
|
2. Mobile-first responsive design
|
||||||
|
3. Performance budgets - aim for sub-3s load times
|
||||||
|
4. Semantic HTML and proper ARIA attributes
|
||||||
|
5. Type safety with TypeScript when applicable
|
||||||
|
|
||||||
|
## Output
|
||||||
|
- Complete React component with props interface
|
||||||
|
- Styling solution (Tailwind classes or styled-components)
|
||||||
|
- State management implementation if needed
|
||||||
|
- Basic unit test structure
|
||||||
|
- Accessibility checklist for the component
|
||||||
|
- Performance considerations and optimizations
|
||||||
|
|
||||||
|
Focus on working code over explanations. Include usage examples in comments.
|
||||||
194
.claude/agents/nextjs-architecture-expert.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
---
|
||||||
|
name: nextjs-architecture-expert
|
||||||
|
description: Master of Next.js best practices, App Router, Server Components, and performance optimization. Use PROACTIVELY for Next.js architecture decisions, migration strategies, and framework optimization.
|
||||||
|
tools: Read, Write, Edit, Bash, Grep, Glob
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Next.js Architecture Expert with deep expertise in modern Next.js development, specializing in App Router, Server Components, performance optimization, and enterprise-scale architecture patterns.
|
||||||
|
|
||||||
|
Your core expertise areas:
|
||||||
|
- **Next.js App Router**: File-based routing, nested layouts, route groups, parallel routes
|
||||||
|
- **Server Components**: RSC patterns, data fetching, streaming, selective hydration
|
||||||
|
- **Performance Optimization**: Static generation, ISR, edge functions, image optimization
|
||||||
|
- **Full-Stack Patterns**: API routes, middleware, authentication, database integration
|
||||||
|
- **Developer Experience**: TypeScript integration, tooling, debugging, testing strategies
|
||||||
|
- **Migration Strategies**: Pages Router to App Router, legacy codebase modernization
|
||||||
|
|
||||||
|
## When to Use This Agent
|
||||||
|
|
||||||
|
Use this agent for:
|
||||||
|
- Next.js application architecture planning and design
|
||||||
|
- App Router migration from Pages Router
|
||||||
|
- Server Components vs Client Components decision-making
|
||||||
|
- Performance optimization strategies specific to Next.js
|
||||||
|
- Full-stack Next.js application development guidance
|
||||||
|
- Enterprise-scale Next.js architecture patterns
|
||||||
|
- Next.js best practices enforcement and code reviews
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### App Router Structure
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── (auth)/ # Route group for auth pages
|
||||||
|
│ ├── login/
|
||||||
|
│ │ └── page.tsx # /login
|
||||||
|
│ └── register/
|
||||||
|
│ └── page.tsx # /register
|
||||||
|
├── dashboard/
|
||||||
|
│ ├── layout.tsx # Nested layout for dashboard
|
||||||
|
│ ├── page.tsx # /dashboard
|
||||||
|
│ ├── analytics/
|
||||||
|
│ │ └── page.tsx # /dashboard/analytics
|
||||||
|
│ └── settings/
|
||||||
|
│ └── page.tsx # /dashboard/settings
|
||||||
|
├── api/
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ └── route.ts # API endpoint
|
||||||
|
│ └── users/
|
||||||
|
│ └── route.ts
|
||||||
|
├── globals.css
|
||||||
|
├── layout.tsx # Root layout
|
||||||
|
└── page.tsx # Home page
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Components Data Fetching
|
||||||
|
```typescript
|
||||||
|
// Server Component - runs on server
|
||||||
|
async function UserDashboard({ userId }: { userId: string }) {
|
||||||
|
// Direct database access in Server Components
|
||||||
|
const user = await getUserById(userId);
|
||||||
|
const posts = await getPostsByUser(userId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<UserProfile user={user} />
|
||||||
|
<PostList posts={posts} />
|
||||||
|
<InteractiveWidget userId={userId} /> {/* Client Component */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client Component boundary
|
||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
function InteractiveWidget({ userId }: { userId: string }) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
|
||||||
|
// Client-side interactions and state
|
||||||
|
return <div>Interactive content...</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Streaming with Suspense
|
||||||
|
```typescript
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<Suspense fallback={<AnalyticsSkeleton />}>
|
||||||
|
<AnalyticsData />
|
||||||
|
</Suspense>
|
||||||
|
<Suspense fallback={<PostsSkeleton />}>
|
||||||
|
<RecentPosts />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function AnalyticsData() {
|
||||||
|
const analytics = await fetchAnalytics(); // Slow query
|
||||||
|
return <AnalyticsChart data={analytics} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Optimization Strategies
|
||||||
|
|
||||||
|
### Static Generation with Dynamic Segments
|
||||||
|
```typescript
|
||||||
|
// Generate static params for dynamic routes
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const posts = await getPosts();
|
||||||
|
return posts.map((post) => ({
|
||||||
|
slug: post.slug,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static generation with ISR
|
||||||
|
export const revalidate = 3600; // Revalidate every hour
|
||||||
|
|
||||||
|
export default async function PostPage({ params }: { params: { slug: string } }) {
|
||||||
|
const post = await getPost(params.slug);
|
||||||
|
return <PostContent post={post} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Middleware for Authentication
|
||||||
|
```typescript
|
||||||
|
// middleware.ts
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const token = request.cookies.get('auth-token');
|
||||||
|
|
||||||
|
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
|
||||||
|
return NextResponse.redirect(new URL('/login', request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: '/dashboard/:path*',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Strategies
|
||||||
|
|
||||||
|
### Pages Router to App Router Migration
|
||||||
|
1. **Gradual Migration**: Use both routers simultaneously
|
||||||
|
2. **Layout Conversion**: Transform `_app.js` to `layout.tsx`
|
||||||
|
3. **API Routes**: Move from `pages/api/` to `app/api/*/route.ts`
|
||||||
|
4. **Data Fetching**: Convert `getServerSideProps` to Server Components
|
||||||
|
5. **Client Components**: Add 'use client' directive where needed
|
||||||
|
|
||||||
|
### Data Fetching Migration
|
||||||
|
```typescript
|
||||||
|
// Before (Pages Router)
|
||||||
|
export async function getServerSideProps(context) {
|
||||||
|
const data = await fetchData(context.params.id);
|
||||||
|
return { props: { data } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// After (App Router)
|
||||||
|
async function Page({ params }: { params: { id: string } }) {
|
||||||
|
const data = await fetchData(params.id);
|
||||||
|
return <ComponentWithData data={data} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Decision Framework
|
||||||
|
|
||||||
|
When architecting Next.js applications, consider:
|
||||||
|
|
||||||
|
1. **Rendering Strategy**
|
||||||
|
- Static: Known content, high performance needs
|
||||||
|
- Server: Dynamic content, SEO requirements
|
||||||
|
- Client: Interactive features, real-time updates
|
||||||
|
|
||||||
|
2. **Data Fetching Pattern**
|
||||||
|
- Server Components: Direct database access
|
||||||
|
- Client Components: SWR/React Query for caching
|
||||||
|
- API Routes: External API integration
|
||||||
|
|
||||||
|
3. **Performance Requirements**
|
||||||
|
- Static generation for marketing pages
|
||||||
|
- ISR for frequently changing content
|
||||||
|
- Streaming for slow queries
|
||||||
|
|
||||||
|
Always provide specific architectural recommendations based on project requirements, performance constraints, and team expertise level.
|
||||||
479
.claude/commands/nextjs-api-tester.md
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
---
|
||||||
|
allowed-tools: Read, Write, Edit, Bash
|
||||||
|
argument-hint: [route-path] [--method=GET] [--data='{}'] [--headers='{}']
|
||||||
|
description: Test and validate Next.js API routes with comprehensive test scenarios
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next.js API Route Tester
|
||||||
|
|
||||||
|
**API Route**: $ARGUMENTS
|
||||||
|
|
||||||
|
## Current Project Analysis
|
||||||
|
|
||||||
|
### API Routes Detection
|
||||||
|
- App Router API: @app/api/
|
||||||
|
- Pages Router API: @pages/api/
|
||||||
|
- API configuration: @next.config.js
|
||||||
|
- Environment variables: @.env.local
|
||||||
|
|
||||||
|
### Project Context
|
||||||
|
- Next.js version: !`grep '"next"' package.json | head -1`
|
||||||
|
- TypeScript config: @tsconfig.json (if exists)
|
||||||
|
- Testing framework: @jest.config.js or @vitest.config.js (if exists)
|
||||||
|
|
||||||
|
## API Route Analysis
|
||||||
|
|
||||||
|
### Route Discovery
|
||||||
|
Based on the provided route path, analyze:
|
||||||
|
- **Route File**: Locate the actual route file
|
||||||
|
- **HTTP Methods**: Supported methods (GET, POST, PUT, DELETE, PATCH)
|
||||||
|
- **Route Parameters**: Dynamic segments and query parameters
|
||||||
|
- **Middleware**: Applied middleware functions
|
||||||
|
- **Authentication**: Required authentication/authorization
|
||||||
|
|
||||||
|
### Route Implementation Review
|
||||||
|
- Route handler implementation: @app/api/[route-path]/route.ts or @pages/api/[route-path].ts
|
||||||
|
- Type definitions: @types/ or inline types
|
||||||
|
- Validation schemas: @lib/validations/ or inline validation
|
||||||
|
- Database models: @lib/models/ or @models/
|
||||||
|
|
||||||
|
## Test Generation Strategy
|
||||||
|
|
||||||
|
### 1. Basic Functionality Tests
|
||||||
|
```javascript
|
||||||
|
// Basic API route test template
|
||||||
|
describe('API Route: /api/[route-path]', () => {
|
||||||
|
describe('GET requests', () => {
|
||||||
|
test('should return 200 for valid request', async () => {
|
||||||
|
const response = await fetch('/api/[route-path]');
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return valid JSON response', async () => {
|
||||||
|
const response = await fetch('/api/[route-path]');
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data).toBeDefined();
|
||||||
|
expect(typeof data).toBe('object');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST requests', () => {
|
||||||
|
test('should create resource with valid data', async () => {
|
||||||
|
const testData = { name: 'Test', email: 'test@example.com' };
|
||||||
|
const response = await fetch('/api/[route-path]', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(testData)
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
const result = await response.json();
|
||||||
|
expect(result.name).toBe(testData.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject invalid data', async () => {
|
||||||
|
const invalidData = { invalid: 'field' };
|
||||||
|
const response = await fetch('/api/[route-path]', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(invalidData)
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Authentication Tests
|
||||||
|
```javascript
|
||||||
|
describe('Authentication', () => {
|
||||||
|
test('should require authentication for protected routes', async () => {
|
||||||
|
const response = await fetch('/api/protected-route');
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow authenticated requests', async () => {
|
||||||
|
const token = 'valid-jwt-token';
|
||||||
|
const response = await fetch('/api/protected-route', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
expect(response.status).not.toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate JWT token format', async () => {
|
||||||
|
const invalidToken = 'invalid-token';
|
||||||
|
const response = await fetch('/api/protected-route', {
|
||||||
|
headers: { 'Authorization': `Bearer ${invalidToken}` }
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Input Validation Tests
|
||||||
|
```javascript
|
||||||
|
describe('Input Validation', () => {
|
||||||
|
const validationTests = [
|
||||||
|
{ field: 'email', invalid: 'not-an-email', valid: 'test@example.com' },
|
||||||
|
{ field: 'phone', invalid: '123', valid: '+1234567890' },
|
||||||
|
{ field: 'age', invalid: -1, valid: 25 },
|
||||||
|
{ field: 'name', invalid: '', valid: 'John Doe' }
|
||||||
|
];
|
||||||
|
|
||||||
|
validationTests.forEach(({ field, invalid, valid }) => {
|
||||||
|
test(`should validate ${field} field`, async () => {
|
||||||
|
const invalidData = { [field]: invalid };
|
||||||
|
const validData = { [field]: valid };
|
||||||
|
|
||||||
|
// Test invalid data
|
||||||
|
const invalidResponse = await fetch('/api/[route-path]', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(invalidData)
|
||||||
|
});
|
||||||
|
expect(invalidResponse.status).toBe(400);
|
||||||
|
|
||||||
|
// Test valid data
|
||||||
|
const validResponse = await fetch('/api/[route-path]', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(validData)
|
||||||
|
});
|
||||||
|
expect(validResponse.status).not.toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Error Handling Tests
|
||||||
|
```javascript
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
test('should handle malformed JSON', async () => {
|
||||||
|
const response = await fetch('/api/[route-path]', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: 'invalid-json'
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle missing Content-Type header', async () => {
|
||||||
|
const response = await fetch('/api/[route-path]', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ test: 'data' })
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle request timeout', async () => {
|
||||||
|
// Mock slow endpoint
|
||||||
|
jest.setTimeout(5000);
|
||||||
|
const response = await fetch('/api/slow-endpoint');
|
||||||
|
// Test appropriate timeout handling
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
test('should handle database connection errors', async () => {
|
||||||
|
// Mock database failure
|
||||||
|
const mockDbError = jest.spyOn(db, 'connect').mockRejectedValue(new Error('DB Error'));
|
||||||
|
|
||||||
|
const response = await fetch('/api/[route-path]');
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
|
||||||
|
mockDbError.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Performance Tests
|
||||||
|
```javascript
|
||||||
|
describe('Performance', () => {
|
||||||
|
test('should respond within acceptable time', async () => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const response = await fetch('/api/[route-path]');
|
||||||
|
const endTime = Date.now();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(endTime - startTime).toBeLessThan(1000); // 1 second
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle concurrent requests', async () => {
|
||||||
|
const promises = Array.from({ length: 10 }, () =>
|
||||||
|
fetch('/api/[route-path]')
|
||||||
|
);
|
||||||
|
|
||||||
|
const responses = await Promise.all(promises);
|
||||||
|
responses.forEach(response => {
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should implement rate limiting', async () => {
|
||||||
|
const requests = Array.from({ length: 100 }, () =>
|
||||||
|
fetch('/api/[route-path]')
|
||||||
|
);
|
||||||
|
|
||||||
|
const responses = await Promise.all(requests);
|
||||||
|
const rateLimitedResponses = responses.filter(r => r.status === 429);
|
||||||
|
expect(rateLimitedResponses.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Testing Commands
|
||||||
|
|
||||||
|
### cURL Commands Generation
|
||||||
|
```bash
|
||||||
|
# GET request
|
||||||
|
curl -X GET "http://localhost:3000/api/[route-path]" \
|
||||||
|
-H "Accept: application/json"
|
||||||
|
|
||||||
|
# POST request with data
|
||||||
|
curl -X POST "http://localhost:3000/api/[route-path]" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
-d '{"key": "value"}'
|
||||||
|
|
||||||
|
# Authenticated request
|
||||||
|
curl -X GET "http://localhost:3000/api/protected-route" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||||
|
-H "Accept: application/json"
|
||||||
|
|
||||||
|
# Upload file
|
||||||
|
curl -X POST "http://localhost:3000/api/upload" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||||
|
-F "file=@path/to/file.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTPie Commands
|
||||||
|
```bash
|
||||||
|
# GET request
|
||||||
|
http GET localhost:3000/api/[route-path]
|
||||||
|
|
||||||
|
# POST request with JSON
|
||||||
|
http POST localhost:3000/api/[route-path] key=value
|
||||||
|
|
||||||
|
# Authenticated request
|
||||||
|
http GET localhost:3000/api/protected-route Authorization:"Bearer TOKEN"
|
||||||
|
|
||||||
|
# Custom headers
|
||||||
|
http GET localhost:3000/api/[route-path] X-Custom-Header:value
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interactive Testing Tools
|
||||||
|
|
||||||
|
### Postman Collection Generation
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"name": "Next.js API Tests",
|
||||||
|
"description": "Generated API tests for [route-path]"
|
||||||
|
},
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "GET [route-path]",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/[route-path]",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "[route-path]"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "POST [route-path]",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"key\": \"value\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/[route-path]",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "[route-path]"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Thunder Client Collection
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client": "Thunder Client",
|
||||||
|
"collectionName": "Next.js API Tests",
|
||||||
|
"dateExported": "2024-01-01",
|
||||||
|
"version": "1.1",
|
||||||
|
"folders": [],
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"name": "Test API Route",
|
||||||
|
"url": "localhost:3000/api/[route-path]",
|
||||||
|
"method": "GET",
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Data Management
|
||||||
|
|
||||||
|
### Test Fixtures
|
||||||
|
```typescript
|
||||||
|
// test/fixtures/apiTestData.ts
|
||||||
|
export const validUserData = {
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
age: 30,
|
||||||
|
role: 'user'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const invalidUserData = {
|
||||||
|
name: '',
|
||||||
|
email: 'invalid-email',
|
||||||
|
age: -1,
|
||||||
|
role: 'invalid-role'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const testHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'User-Agent': 'API-Test-Suite/1.0'
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mock Data Generation
|
||||||
|
```typescript
|
||||||
|
// test/utils/mockData.ts
|
||||||
|
export function generateMockUser() {
|
||||||
|
return {
|
||||||
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
|
name: `User ${Math.floor(Math.random() * 1000)}`,
|
||||||
|
email: `user${Date.now()}@example.com`,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateBulkTestData(count: number) {
|
||||||
|
return Array.from({ length: count }, generateMockUser);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Environment Setup
|
||||||
|
|
||||||
|
### Jest Configuration
|
||||||
|
```javascript
|
||||||
|
// jest.config.js for API testing
|
||||||
|
module.exports = {
|
||||||
|
testEnvironment: 'node',
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/test/setup.js'],
|
||||||
|
testMatch: ['**/__tests__/**/*.test.js', '**/?(*.)+(spec|test).js'],
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'pages/api/**/*.{js,ts}',
|
||||||
|
'app/api/**/*.{js,ts}',
|
||||||
|
'!**/*.d.ts',
|
||||||
|
],
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
branches: 70,
|
||||||
|
functions: 70,
|
||||||
|
lines: 70,
|
||||||
|
statements: 70
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Setup
|
||||||
|
```javascript
|
||||||
|
// test/setup.js
|
||||||
|
import { createMocks } from 'node-mocks-http';
|
||||||
|
import { testDb } from './testDatabase';
|
||||||
|
|
||||||
|
// Global test setup
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Setup test database
|
||||||
|
await testDb.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Cleanup test database
|
||||||
|
await testDb.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Reset database state
|
||||||
|
await testDb.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function for API testing
|
||||||
|
global.createAPITest = (handler) => {
|
||||||
|
return (method, url, options = {}) => {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
return handler(req, res);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Automated Testing Integration
|
||||||
|
|
||||||
|
### GitHub Actions Workflow
|
||||||
|
```yaml
|
||||||
|
name: API Tests
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-api:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run test:api
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Continuous Testing
|
||||||
|
```bash
|
||||||
|
# Watch mode for development
|
||||||
|
npm run test:api -- --watch
|
||||||
|
|
||||||
|
# Coverage reporting
|
||||||
|
npm run test:api -- --coverage
|
||||||
|
|
||||||
|
# Specific route testing
|
||||||
|
npm run test:api -- --testNamePattern="api/users"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Results Analysis
|
||||||
|
|
||||||
|
Generate comprehensive test report including:
|
||||||
|
1. **Test Coverage**: Line, branch, function coverage percentages
|
||||||
|
2. **Performance Metrics**: Response times, throughput
|
||||||
|
3. **Security Analysis**: Authentication, authorization, input validation
|
||||||
|
4. **Error Handling**: Exception scenarios and error responses
|
||||||
|
5. **Compatibility**: Cross-environment testing results
|
||||||
|
|
||||||
|
Provide actionable recommendations for improving API reliability, performance, and security.
|
||||||
488
.claude/commands/nextjs-component-generator.md
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
---
|
||||||
|
allowed-tools: Read, Write, Edit
|
||||||
|
argument-hint: [component-name] [--client] [--server] [--page] [--layout]
|
||||||
|
description: Generate optimized React components for Next.js with TypeScript and best practices
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next.js Component Generator
|
||||||
|
|
||||||
|
**Component Name**: $ARGUMENTS
|
||||||
|
|
||||||
|
## Project Context Analysis
|
||||||
|
|
||||||
|
### Framework Detection
|
||||||
|
- Next.js config: @next.config.js
|
||||||
|
- TypeScript config: @tsconfig.json (if exists)
|
||||||
|
- Tailwind config: @tailwind.config.js (if exists)
|
||||||
|
- Package.json: @package.json
|
||||||
|
|
||||||
|
### Existing Component Patterns
|
||||||
|
- Components directory: @components/
|
||||||
|
- App directory: @app/ (if App Router)
|
||||||
|
- Pages directory: @pages/ (if Pages Router)
|
||||||
|
- Styles directory: @styles/
|
||||||
|
|
||||||
|
## Component Generation Requirements
|
||||||
|
|
||||||
|
### 1. Component Type Detection
|
||||||
|
Based on arguments and context, determine component type:
|
||||||
|
- **Client Component**: Interactive UI with state/events (`--client` or default for interactive components)
|
||||||
|
- **Server Component**: Static rendering, data fetching (`--server` or default for Next.js 13+)
|
||||||
|
- **Page Component**: Route-level component (`--page`)
|
||||||
|
- **Layout Component**: Shared layout wrapper (`--layout`)
|
||||||
|
|
||||||
|
### 2. File Structure Creation
|
||||||
|
Generate comprehensive component structure:
|
||||||
|
```
|
||||||
|
components/[ComponentName]/
|
||||||
|
├── index.ts # Barrel export
|
||||||
|
├── [ComponentName].tsx # Main component
|
||||||
|
├── [ComponentName].module.css # Component styles
|
||||||
|
├── [ComponentName].test.tsx # Unit tests
|
||||||
|
├── [ComponentName].stories.tsx # Storybook story (if detected)
|
||||||
|
└── types.ts # TypeScript types
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Component Templates
|
||||||
|
|
||||||
|
#### Server Component Template
|
||||||
|
```typescript
|
||||||
|
import { FC } from 'react';
|
||||||
|
import styles from './ComponentName.module.css';
|
||||||
|
|
||||||
|
interface ComponentNameProps {
|
||||||
|
/**
|
||||||
|
* Component description
|
||||||
|
*/
|
||||||
|
children?: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* Additional CSS classes
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ComponentName - Server Component
|
||||||
|
*
|
||||||
|
* @description Brief description of component purpose
|
||||||
|
* @example
|
||||||
|
* <ComponentName>Content</ComponentName>
|
||||||
|
*/
|
||||||
|
export const ComponentName: FC<ComponentNameProps> = ({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={`${styles.container} ${className}`} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComponentName;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Client Component Template
|
||||||
|
```typescript
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import styles from './ComponentName.module.css';
|
||||||
|
|
||||||
|
interface ComponentNameProps {
|
||||||
|
/**
|
||||||
|
* Component description
|
||||||
|
*/
|
||||||
|
children?: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* Click event handler
|
||||||
|
*/
|
||||||
|
onClick?: () => void;
|
||||||
|
/**
|
||||||
|
* Additional CSS classes
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ComponentName - Client Component
|
||||||
|
*
|
||||||
|
* @description Interactive component with client-side functionality
|
||||||
|
* @example
|
||||||
|
* <ComponentName onClick={() => console.log('clicked')}>
|
||||||
|
* Content
|
||||||
|
* </ComponentName>
|
||||||
|
*/
|
||||||
|
export const ComponentName: FC<ComponentNameProps> = ({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
className = '',
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [isActive, setIsActive] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
setIsActive(!isActive);
|
||||||
|
onClick?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${styles.button} ${isActive ? styles.active : ''} ${className}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComponentName;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Page Component Template
|
||||||
|
```typescript
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import ComponentName from '@/components/ComponentName';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Page Title',
|
||||||
|
description: 'Page description',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: { id: string };
|
||||||
|
searchParams: { [key: string]: string | string[] | undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page({ params, searchParams }: PageProps) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>Page Title</h1>
|
||||||
|
<ComponentName />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Layout Component Template
|
||||||
|
```typescript
|
||||||
|
import { FC } from 'react';
|
||||||
|
import styles from './Layout.module.css';
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* Page title
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout - Shared layout component
|
||||||
|
*
|
||||||
|
* @description Provides consistent layout structure across pages
|
||||||
|
*/
|
||||||
|
export const Layout: FC<LayoutProps> = ({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.layout}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
{title && <h1 className={styles.title}>{title}</h1>}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className={styles.main}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className={styles.footer}>
|
||||||
|
<p>© 2024 Your App</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. CSS Module Templates
|
||||||
|
|
||||||
|
#### Basic Component Styles
|
||||||
|
```css
|
||||||
|
/* ComponentName.module.css */
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:focus {
|
||||||
|
outline: 2px solid #3b82f6;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.active {
|
||||||
|
background-color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Layout Styles
|
||||||
|
```css
|
||||||
|
/* Layout.module.css */
|
||||||
|
.layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
text-align: center;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. TypeScript Types
|
||||||
|
```typescript
|
||||||
|
// types.ts
|
||||||
|
export interface BaseComponentProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
'data-testid'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ButtonProps extends BaseComponentProps {
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutProps extends BaseComponentProps {
|
||||||
|
title?: string;
|
||||||
|
sidebar?: React.ReactNode;
|
||||||
|
breadcrumbs?: BreadcrumbItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
current?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Unit Tests
|
||||||
|
```typescript
|
||||||
|
// ComponentName.test.tsx
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import ComponentName from './ComponentName';
|
||||||
|
|
||||||
|
describe('ComponentName', () => {
|
||||||
|
it('renders children correctly', () => {
|
||||||
|
render(<ComponentName>Test Content</ComponentName>);
|
||||||
|
expect(screen.getByText('Test Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
render(<ComponentName className="custom-class">Test</ComponentName>);
|
||||||
|
const element = screen.getByText('Test');
|
||||||
|
expect(element).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles click events', () => {
|
||||||
|
const handleClick = jest.fn();
|
||||||
|
render(<ComponentName onClick={handleClick}>Click me</ComponentName>);
|
||||||
|
|
||||||
|
const button = screen.getByText('Click me');
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles active state on click', () => {
|
||||||
|
render(<ComponentName>Toggle</ComponentName>);
|
||||||
|
const button = screen.getByText('Toggle');
|
||||||
|
|
||||||
|
expect(button).not.toHaveClass('active');
|
||||||
|
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(button).toHaveClass('active');
|
||||||
|
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(button).not.toHaveClass('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is accessible', () => {
|
||||||
|
render(<ComponentName>Accessible Button</ComponentName>);
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
expect(button).toHaveAccessibleName('Accessible Button');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Storybook Stories (if detected)
|
||||||
|
```typescript
|
||||||
|
// ComponentName.stories.tsx
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import ComponentName from './ComponentName';
|
||||||
|
|
||||||
|
const meta: Meta<typeof ComponentName> = {
|
||||||
|
title: 'Components/ComponentName',
|
||||||
|
component: ComponentName,
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: 'A reusable component built for Next.js applications.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: ['autodocs'],
|
||||||
|
argTypes: {
|
||||||
|
onClick: { action: 'clicked' },
|
||||||
|
className: { control: 'text' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Default Component',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithCustomClass: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Custom Styled',
|
||||||
|
className: 'custom-style',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Interactive: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Click me',
|
||||||
|
onClick: () => alert('Component clicked!'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Barrel Export
|
||||||
|
```typescript
|
||||||
|
// index.ts
|
||||||
|
export { default } from './ComponentName';
|
||||||
|
export type { ComponentNameProps } from './ComponentName';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Framework-Specific Optimizations
|
||||||
|
|
||||||
|
### Tailwind CSS Integration (if detected)
|
||||||
|
Replace CSS modules with Tailwind classes:
|
||||||
|
```typescript
|
||||||
|
export const ComponentName: FC<ComponentNameProps> = ({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col p-4 rounded-lg border border-slate-200 bg-white ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Next.js App Router Optimizations
|
||||||
|
- **Server Components**: Default for non-interactive components
|
||||||
|
- **Client Components**: Explicit 'use client' directive
|
||||||
|
- **Metadata**: Include metadata for page components
|
||||||
|
- **Loading States**: Implement loading.tsx for async components
|
||||||
|
|
||||||
|
### Accessibility Features
|
||||||
|
- **ARIA Labels**: Proper labeling for screen readers
|
||||||
|
- **Keyboard Navigation**: Tab order and keyboard shortcuts
|
||||||
|
- **Focus Management**: Visible focus indicators
|
||||||
|
- **Semantic HTML**: Proper semantic elements
|
||||||
|
|
||||||
|
## Component Generation Process
|
||||||
|
|
||||||
|
1. **Analysis**: Analyze existing project structure and patterns
|
||||||
|
2. **Template Selection**: Choose appropriate template based on component type
|
||||||
|
3. **Customization**: Adapt template to project conventions
|
||||||
|
4. **File Creation**: Generate all component files
|
||||||
|
5. **Integration**: Update index files and exports
|
||||||
|
6. **Validation**: Verify component compiles and tests pass
|
||||||
|
|
||||||
|
## Quality Checklist
|
||||||
|
|
||||||
|
- [ ] Component follows project naming conventions
|
||||||
|
- [ ] TypeScript types are properly defined
|
||||||
|
- [ ] CSS follows established patterns (modules or Tailwind)
|
||||||
|
- [ ] Unit tests cover key functionality
|
||||||
|
- [ ] Component is accessible (ARIA, keyboard navigation)
|
||||||
|
- [ ] Documentation includes usage examples
|
||||||
|
- [ ] Storybook story created (if Storybook detected)
|
||||||
|
- [ ] Component compiles without errors
|
||||||
|
- [ ] Tests pass successfully
|
||||||
|
|
||||||
|
Provide the complete component implementation with all specified files and features.
|
||||||
42
.claude/skills/frontend-design/SKILL.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: frontend-design
|
||||||
|
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
|
||||||
|
license: Complete terms in LICENSE.txt
|
||||||
|
---
|
||||||
|
|
||||||
|
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
|
||||||
|
|
||||||
|
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
|
||||||
|
|
||||||
|
## Design Thinking
|
||||||
|
|
||||||
|
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
||||||
|
- **Purpose**: What problem does this interface solve? Who uses it?
|
||||||
|
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
|
||||||
|
- **Constraints**: Technical requirements (framework, performance, accessibility).
|
||||||
|
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
|
||||||
|
|
||||||
|
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
||||||
|
|
||||||
|
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
||||||
|
- Production-grade and functional
|
||||||
|
- Visually striking and memorable
|
||||||
|
- Cohesive with a clear aesthetic point-of-view
|
||||||
|
- Meticulously refined in every detail
|
||||||
|
|
||||||
|
## Frontend Aesthetics Guidelines
|
||||||
|
|
||||||
|
Focus on:
|
||||||
|
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
|
||||||
|
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
|
||||||
|
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
|
||||||
|
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
||||||
|
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
|
||||||
|
|
||||||
|
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
|
||||||
|
|
||||||
|
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
|
||||||
|
|
||||||
|
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
|
||||||
|
|
||||||
|
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
|
||||||
209
.claude/skills/senior-frontend/SKILL.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
---
|
||||||
|
name: senior-frontend
|
||||||
|
description: Comprehensive frontend development skill for building modern, performant web applications using ReactJS, NextJS, TypeScript, Tailwind CSS. Includes component scaffolding, performance optimization, bundle analysis, and UI best practices. Use when developing frontend features, optimizing performance, implementing UI/UX designs, managing state, or reviewing frontend code.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Senior Frontend
|
||||||
|
|
||||||
|
Complete toolkit for senior frontend with modern tools and best practices.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Main Capabilities
|
||||||
|
|
||||||
|
This skill provides three core capabilities through automated scripts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Script 1: Component Generator
|
||||||
|
python scripts/component_generator.py [options]
|
||||||
|
|
||||||
|
# Script 2: Bundle Analyzer
|
||||||
|
python scripts/bundle_analyzer.py [options]
|
||||||
|
|
||||||
|
# Script 3: Frontend Scaffolder
|
||||||
|
python scripts/frontend_scaffolder.py [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Capabilities
|
||||||
|
|
||||||
|
### 1. Component Generator
|
||||||
|
|
||||||
|
Automated tool for component generator tasks.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Automated scaffolding
|
||||||
|
- Best practices built-in
|
||||||
|
- Configurable templates
|
||||||
|
- Quality checks
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
python scripts/component_generator.py <project-path> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Bundle Analyzer
|
||||||
|
|
||||||
|
Comprehensive analysis and optimization tool.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Deep analysis
|
||||||
|
- Performance metrics
|
||||||
|
- Recommendations
|
||||||
|
- Automated fixes
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
python scripts/bundle_analyzer.py <target-path> [--verbose]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Frontend Scaffolder
|
||||||
|
|
||||||
|
Advanced tooling for specialized tasks.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Expert-level automation
|
||||||
|
- Custom configurations
|
||||||
|
- Integration ready
|
||||||
|
- Production-grade output
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
python scripts/frontend_scaffolder.py [arguments] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference Documentation
|
||||||
|
|
||||||
|
### React Patterns
|
||||||
|
|
||||||
|
Comprehensive guide available in `references/react_patterns.md`:
|
||||||
|
|
||||||
|
- Detailed patterns and practices
|
||||||
|
- Code examples
|
||||||
|
- Best practices
|
||||||
|
- Anti-patterns to avoid
|
||||||
|
- Real-world scenarios
|
||||||
|
|
||||||
|
### Nextjs Optimization Guide
|
||||||
|
|
||||||
|
Complete workflow documentation in `references/nextjs_optimization_guide.md`:
|
||||||
|
|
||||||
|
- Step-by-step processes
|
||||||
|
- Optimization strategies
|
||||||
|
- Tool integrations
|
||||||
|
- Performance tuning
|
||||||
|
- Troubleshooting guide
|
||||||
|
|
||||||
|
### Frontend Best Practices
|
||||||
|
|
||||||
|
Technical reference guide in `references/frontend_best_practices.md`:
|
||||||
|
|
||||||
|
- Technology stack details
|
||||||
|
- Configuration examples
|
||||||
|
- Integration patterns
|
||||||
|
- Security considerations
|
||||||
|
- Scalability guidelines
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
**Languages:** TypeScript, JavaScript, Python, Go, Swift, Kotlin
|
||||||
|
**Frontend:** React, Next.js, React Native, Flutter
|
||||||
|
**Backend:** Node.js, Express, GraphQL, REST APIs
|
||||||
|
**Database:** PostgreSQL, Prisma, NeonDB, Supabase
|
||||||
|
**DevOps:** Docker, Kubernetes, Terraform, GitHub Actions, CircleCI
|
||||||
|
**Cloud:** AWS, GCP, Azure
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### 1. Setup and Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
# or
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Configure environment
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run Quality Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use the analyzer script
|
||||||
|
python scripts/bundle_analyzer.py .
|
||||||
|
|
||||||
|
# Review recommendations
|
||||||
|
# Apply fixes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Implement Best Practices
|
||||||
|
|
||||||
|
Follow the patterns and practices documented in:
|
||||||
|
- `references/react_patterns.md`
|
||||||
|
- `references/nextjs_optimization_guide.md`
|
||||||
|
- `references/frontend_best_practices.md`
|
||||||
|
|
||||||
|
## Best Practices Summary
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- Follow established patterns
|
||||||
|
- Write comprehensive tests
|
||||||
|
- Document decisions
|
||||||
|
- Review regularly
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Measure before optimizing
|
||||||
|
- Use appropriate caching
|
||||||
|
- Optimize critical paths
|
||||||
|
- Monitor in production
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Validate all inputs
|
||||||
|
- Use parameterized queries
|
||||||
|
- Implement proper authentication
|
||||||
|
- Keep dependencies updated
|
||||||
|
|
||||||
|
### Maintainability
|
||||||
|
- Write clear code
|
||||||
|
- Use consistent naming
|
||||||
|
- Add helpful comments
|
||||||
|
- Keep it simple
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
npm run dev
|
||||||
|
npm run build
|
||||||
|
npm run test
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# Analysis
|
||||||
|
python scripts/bundle_analyzer.py .
|
||||||
|
python scripts/frontend_scaffolder.py --analyze
|
||||||
|
|
||||||
|
# Deployment
|
||||||
|
docker build -t app:latest .
|
||||||
|
docker-compose up -d
|
||||||
|
kubectl apply -f k8s/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
Check the comprehensive troubleshooting section in `references/frontend_best_practices.md`.
|
||||||
|
|
||||||
|
### Getting Help
|
||||||
|
|
||||||
|
- Review reference documentation
|
||||||
|
- Check script output messages
|
||||||
|
- Consult tech stack documentation
|
||||||
|
- Review error logs
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- Pattern Reference: `references/react_patterns.md`
|
||||||
|
- Workflow Guide: `references/nextjs_optimization_guide.md`
|
||||||
|
- Technical Guide: `references/frontend_best_practices.md`
|
||||||
|
- Tool Scripts: `scripts/` directory
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Frontend Best Practices
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This reference guide provides comprehensive information for senior frontend.
|
||||||
|
|
||||||
|
## Patterns and Practices
|
||||||
|
|
||||||
|
### Pattern 1: Best Practice Implementation
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Detailed explanation of the pattern.
|
||||||
|
|
||||||
|
**When to Use:**
|
||||||
|
- Scenario 1
|
||||||
|
- Scenario 2
|
||||||
|
- Scenario 3
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```typescript
|
||||||
|
// Example code implementation
|
||||||
|
export class Example {
|
||||||
|
// Implementation details
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Benefit 1
|
||||||
|
- Benefit 2
|
||||||
|
- Benefit 3
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Consider 1
|
||||||
|
- Consider 2
|
||||||
|
- Consider 3
|
||||||
|
|
||||||
|
### Pattern 2: Advanced Technique
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Another important pattern for senior frontend.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```typescript
|
||||||
|
// Advanced example
|
||||||
|
async function advancedExample() {
|
||||||
|
// Code here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
- Clear structure
|
||||||
|
- Logical separation
|
||||||
|
- Consistent naming
|
||||||
|
- Proper documentation
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
- Optimization strategies
|
||||||
|
- Bottleneck identification
|
||||||
|
- Monitoring approaches
|
||||||
|
- Scaling techniques
|
||||||
|
|
||||||
|
### Security Best Practices
|
||||||
|
- Input validation
|
||||||
|
- Authentication
|
||||||
|
- Authorization
|
||||||
|
- Data protection
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Pattern A
|
||||||
|
Implementation details and examples.
|
||||||
|
|
||||||
|
### Pattern B
|
||||||
|
Implementation details and examples.
|
||||||
|
|
||||||
|
### Pattern C
|
||||||
|
Implementation details and examples.
|
||||||
|
|
||||||
|
## Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
### Anti-Pattern 1
|
||||||
|
What not to do and why.
|
||||||
|
|
||||||
|
### Anti-Pattern 2
|
||||||
|
What not to do and why.
|
||||||
|
|
||||||
|
## Tools and Resources
|
||||||
|
|
||||||
|
### Recommended Tools
|
||||||
|
- Tool 1: Purpose
|
||||||
|
- Tool 2: Purpose
|
||||||
|
- Tool 3: Purpose
|
||||||
|
|
||||||
|
### Further Reading
|
||||||
|
- Resource 1
|
||||||
|
- Resource 2
|
||||||
|
- Resource 3
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Key takeaways for using this reference guide effectively.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Nextjs Optimization Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This reference guide provides comprehensive information for senior frontend.
|
||||||
|
|
||||||
|
## Patterns and Practices
|
||||||
|
|
||||||
|
### Pattern 1: Best Practice Implementation
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Detailed explanation of the pattern.
|
||||||
|
|
||||||
|
**When to Use:**
|
||||||
|
- Scenario 1
|
||||||
|
- Scenario 2
|
||||||
|
- Scenario 3
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```typescript
|
||||||
|
// Example code implementation
|
||||||
|
export class Example {
|
||||||
|
// Implementation details
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Benefit 1
|
||||||
|
- Benefit 2
|
||||||
|
- Benefit 3
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Consider 1
|
||||||
|
- Consider 2
|
||||||
|
- Consider 3
|
||||||
|
|
||||||
|
### Pattern 2: Advanced Technique
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Another important pattern for senior frontend.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```typescript
|
||||||
|
// Advanced example
|
||||||
|
async function advancedExample() {
|
||||||
|
// Code here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
- Clear structure
|
||||||
|
- Logical separation
|
||||||
|
- Consistent naming
|
||||||
|
- Proper documentation
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
- Optimization strategies
|
||||||
|
- Bottleneck identification
|
||||||
|
- Monitoring approaches
|
||||||
|
- Scaling techniques
|
||||||
|
|
||||||
|
### Security Best Practices
|
||||||
|
- Input validation
|
||||||
|
- Authentication
|
||||||
|
- Authorization
|
||||||
|
- Data protection
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Pattern A
|
||||||
|
Implementation details and examples.
|
||||||
|
|
||||||
|
### Pattern B
|
||||||
|
Implementation details and examples.
|
||||||
|
|
||||||
|
### Pattern C
|
||||||
|
Implementation details and examples.
|
||||||
|
|
||||||
|
## Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
### Anti-Pattern 1
|
||||||
|
What not to do and why.
|
||||||
|
|
||||||
|
### Anti-Pattern 2
|
||||||
|
What not to do and why.
|
||||||
|
|
||||||
|
## Tools and Resources
|
||||||
|
|
||||||
|
### Recommended Tools
|
||||||
|
- Tool 1: Purpose
|
||||||
|
- Tool 2: Purpose
|
||||||
|
- Tool 3: Purpose
|
||||||
|
|
||||||
|
### Further Reading
|
||||||
|
- Resource 1
|
||||||
|
- Resource 2
|
||||||
|
- Resource 3
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Key takeaways for using this reference guide effectively.
|
||||||
103
.claude/skills/senior-frontend/references/react_patterns.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# React Patterns
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This reference guide provides comprehensive information for senior frontend.
|
||||||
|
|
||||||
|
## Patterns and Practices
|
||||||
|
|
||||||
|
### Pattern 1: Best Practice Implementation
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Detailed explanation of the pattern.
|
||||||
|
|
||||||
|
**When to Use:**
|
||||||
|
- Scenario 1
|
||||||
|
- Scenario 2
|
||||||
|
- Scenario 3
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```typescript
|
||||||
|
// Example code implementation
|
||||||
|
export class Example {
|
||||||
|
// Implementation details
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Benefit 1
|
||||||
|
- Benefit 2
|
||||||
|
- Benefit 3
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Consider 1
|
||||||
|
- Consider 2
|
||||||
|
- Consider 3
|
||||||
|
|
||||||
|
### Pattern 2: Advanced Technique
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Another important pattern for senior frontend.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```typescript
|
||||||
|
// Advanced example
|
||||||
|
async function advancedExample() {
|
||||||
|
// Code here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
- Clear structure
|
||||||
|
- Logical separation
|
||||||
|
- Consistent naming
|
||||||
|
- Proper documentation
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
- Optimization strategies
|
||||||
|
- Bottleneck identification
|
||||||
|
- Monitoring approaches
|
||||||
|
- Scaling techniques
|
||||||
|
|
||||||
|
### Security Best Practices
|
||||||
|
- Input validation
|
||||||
|
- Authentication
|
||||||
|
- Authorization
|
||||||
|
- Data protection
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Pattern A
|
||||||
|
Implementation details and examples.
|
||||||
|
|
||||||
|
### Pattern B
|
||||||
|
Implementation details and examples.
|
||||||
|
|
||||||
|
### Pattern C
|
||||||
|
Implementation details and examples.
|
||||||
|
|
||||||
|
## Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
### Anti-Pattern 1
|
||||||
|
What not to do and why.
|
||||||
|
|
||||||
|
### Anti-Pattern 2
|
||||||
|
What not to do and why.
|
||||||
|
|
||||||
|
## Tools and Resources
|
||||||
|
|
||||||
|
### Recommended Tools
|
||||||
|
- Tool 1: Purpose
|
||||||
|
- Tool 2: Purpose
|
||||||
|
- Tool 3: Purpose
|
||||||
|
|
||||||
|
### Further Reading
|
||||||
|
- Resource 1
|
||||||
|
- Resource 2
|
||||||
|
- Resource 3
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Key takeaways for using this reference guide effectively.
|
||||||
114
.claude/skills/senior-frontend/scripts/bundle_analyzer.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Bundle Analyzer
|
||||||
|
Automated tool for senior frontend tasks
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
class BundleAnalyzer:
|
||||||
|
"""Main class for bundle analyzer functionality"""
|
||||||
|
|
||||||
|
def __init__(self, target_path: str, verbose: bool = False):
|
||||||
|
self.target_path = Path(target_path)
|
||||||
|
self.verbose = verbose
|
||||||
|
self.results = {}
|
||||||
|
|
||||||
|
def run(self) -> Dict:
|
||||||
|
"""Execute the main functionality"""
|
||||||
|
print(f"🚀 Running {self.__class__.__name__}...")
|
||||||
|
print(f"📁 Target: {self.target_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.validate_target()
|
||||||
|
self.analyze()
|
||||||
|
self.generate_report()
|
||||||
|
|
||||||
|
print("✅ Completed successfully!")
|
||||||
|
return self.results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def validate_target(self):
|
||||||
|
"""Validate the target path exists and is accessible"""
|
||||||
|
if not self.target_path.exists():
|
||||||
|
raise ValueError(f"Target path does not exist: {self.target_path}")
|
||||||
|
|
||||||
|
if self.verbose:
|
||||||
|
print(f"✓ Target validated: {self.target_path}")
|
||||||
|
|
||||||
|
def analyze(self):
|
||||||
|
"""Perform the main analysis or operation"""
|
||||||
|
if self.verbose:
|
||||||
|
print("📊 Analyzing...")
|
||||||
|
|
||||||
|
# Main logic here
|
||||||
|
self.results['status'] = 'success'
|
||||||
|
self.results['target'] = str(self.target_path)
|
||||||
|
self.results['findings'] = []
|
||||||
|
|
||||||
|
# Add analysis results
|
||||||
|
if self.verbose:
|
||||||
|
print(f"✓ Analysis complete: {len(self.results.get('findings', []))} findings")
|
||||||
|
|
||||||
|
def generate_report(self):
|
||||||
|
"""Generate and display the report"""
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("REPORT")
|
||||||
|
print("="*50)
|
||||||
|
print(f"Target: {self.results.get('target')}")
|
||||||
|
print(f"Status: {self.results.get('status')}")
|
||||||
|
print(f"Findings: {len(self.results.get('findings', []))}")
|
||||||
|
print("="*50 + "\n")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Bundle Analyzer"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'target',
|
||||||
|
help='Target path to analyze or process'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--verbose', '-v',
|
||||||
|
action='store_true',
|
||||||
|
help='Enable verbose output'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--json',
|
||||||
|
action='store_true',
|
||||||
|
help='Output results as JSON'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--output', '-o',
|
||||||
|
help='Output file path'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
tool = BundleAnalyzer(
|
||||||
|
args.target,
|
||||||
|
verbose=args.verbose
|
||||||
|
)
|
||||||
|
|
||||||
|
results = tool.run()
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
output = json.dumps(results, indent=2)
|
||||||
|
if args.output:
|
||||||
|
with open(args.output, 'w') as f:
|
||||||
|
f.write(output)
|
||||||
|
print(f"Results written to {args.output}")
|
||||||
|
else:
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
114
.claude/skills/senior-frontend/scripts/component_generator.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Component Generator
|
||||||
|
Automated tool for senior frontend tasks
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
class ComponentGenerator:
|
||||||
|
"""Main class for component generator functionality"""
|
||||||
|
|
||||||
|
def __init__(self, target_path: str, verbose: bool = False):
|
||||||
|
self.target_path = Path(target_path)
|
||||||
|
self.verbose = verbose
|
||||||
|
self.results = {}
|
||||||
|
|
||||||
|
def run(self) -> Dict:
|
||||||
|
"""Execute the main functionality"""
|
||||||
|
print(f"🚀 Running {self.__class__.__name__}...")
|
||||||
|
print(f"📁 Target: {self.target_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.validate_target()
|
||||||
|
self.analyze()
|
||||||
|
self.generate_report()
|
||||||
|
|
||||||
|
print("✅ Completed successfully!")
|
||||||
|
return self.results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def validate_target(self):
|
||||||
|
"""Validate the target path exists and is accessible"""
|
||||||
|
if not self.target_path.exists():
|
||||||
|
raise ValueError(f"Target path does not exist: {self.target_path}")
|
||||||
|
|
||||||
|
if self.verbose:
|
||||||
|
print(f"✓ Target validated: {self.target_path}")
|
||||||
|
|
||||||
|
def analyze(self):
|
||||||
|
"""Perform the main analysis or operation"""
|
||||||
|
if self.verbose:
|
||||||
|
print("📊 Analyzing...")
|
||||||
|
|
||||||
|
# Main logic here
|
||||||
|
self.results['status'] = 'success'
|
||||||
|
self.results['target'] = str(self.target_path)
|
||||||
|
self.results['findings'] = []
|
||||||
|
|
||||||
|
# Add analysis results
|
||||||
|
if self.verbose:
|
||||||
|
print(f"✓ Analysis complete: {len(self.results.get('findings', []))} findings")
|
||||||
|
|
||||||
|
def generate_report(self):
|
||||||
|
"""Generate and display the report"""
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("REPORT")
|
||||||
|
print("="*50)
|
||||||
|
print(f"Target: {self.results.get('target')}")
|
||||||
|
print(f"Status: {self.results.get('status')}")
|
||||||
|
print(f"Findings: {len(self.results.get('findings', []))}")
|
||||||
|
print("="*50 + "\n")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Component Generator"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'target',
|
||||||
|
help='Target path to analyze or process'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--verbose', '-v',
|
||||||
|
action='store_true',
|
||||||
|
help='Enable verbose output'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--json',
|
||||||
|
action='store_true',
|
||||||
|
help='Output results as JSON'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--output', '-o',
|
||||||
|
help='Output file path'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
tool = ComponentGenerator(
|
||||||
|
args.target,
|
||||||
|
verbose=args.verbose
|
||||||
|
)
|
||||||
|
|
||||||
|
results = tool.run()
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
output = json.dumps(results, indent=2)
|
||||||
|
if args.output:
|
||||||
|
with open(args.output, 'w') as f:
|
||||||
|
f.write(output)
|
||||||
|
print(f"Results written to {args.output}")
|
||||||
|
else:
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
114
.claude/skills/senior-frontend/scripts/frontend_scaffolder.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Frontend Scaffolder
|
||||||
|
Automated tool for senior frontend tasks
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
class FrontendScaffolder:
|
||||||
|
"""Main class for frontend scaffolder functionality"""
|
||||||
|
|
||||||
|
def __init__(self, target_path: str, verbose: bool = False):
|
||||||
|
self.target_path = Path(target_path)
|
||||||
|
self.verbose = verbose
|
||||||
|
self.results = {}
|
||||||
|
|
||||||
|
def run(self) -> Dict:
|
||||||
|
"""Execute the main functionality"""
|
||||||
|
print(f"🚀 Running {self.__class__.__name__}...")
|
||||||
|
print(f"📁 Target: {self.target_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.validate_target()
|
||||||
|
self.analyze()
|
||||||
|
self.generate_report()
|
||||||
|
|
||||||
|
print("✅ Completed successfully!")
|
||||||
|
return self.results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def validate_target(self):
|
||||||
|
"""Validate the target path exists and is accessible"""
|
||||||
|
if not self.target_path.exists():
|
||||||
|
raise ValueError(f"Target path does not exist: {self.target_path}")
|
||||||
|
|
||||||
|
if self.verbose:
|
||||||
|
print(f"✓ Target validated: {self.target_path}")
|
||||||
|
|
||||||
|
def analyze(self):
|
||||||
|
"""Perform the main analysis or operation"""
|
||||||
|
if self.verbose:
|
||||||
|
print("📊 Analyzing...")
|
||||||
|
|
||||||
|
# Main logic here
|
||||||
|
self.results['status'] = 'success'
|
||||||
|
self.results['target'] = str(self.target_path)
|
||||||
|
self.results['findings'] = []
|
||||||
|
|
||||||
|
# Add analysis results
|
||||||
|
if self.verbose:
|
||||||
|
print(f"✓ Analysis complete: {len(self.results.get('findings', []))} findings")
|
||||||
|
|
||||||
|
def generate_report(self):
|
||||||
|
"""Generate and display the report"""
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("REPORT")
|
||||||
|
print("="*50)
|
||||||
|
print(f"Target: {self.results.get('target')}")
|
||||||
|
print(f"Status: {self.results.get('status')}")
|
||||||
|
print(f"Findings: {len(self.results.get('findings', []))}")
|
||||||
|
print("="*50 + "\n")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Frontend Scaffolder"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'target',
|
||||||
|
help='Target path to analyze or process'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--verbose', '-v',
|
||||||
|
action='store_true',
|
||||||
|
help='Enable verbose output'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--json',
|
||||||
|
action='store_true',
|
||||||
|
help='Output results as JSON'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--output', '-o',
|
||||||
|
help='Output file path'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
tool = FrontendScaffolder(
|
||||||
|
args.target,
|
||||||
|
verbose=args.verbose
|
||||||
|
)
|
||||||
|
|
||||||
|
results = tool.run()
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
output = json.dumps(results, indent=2)
|
||||||
|
if args.output:
|
||||||
|
with open(args.output, 'w') as f:
|
||||||
|
f.write(output)
|
||||||
|
print(f"Results written to {args.output}")
|
||||||
|
else:
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
209
.claude/skills/senior-qa/SKILL.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
---
|
||||||
|
name: senior-qa
|
||||||
|
description: Comprehensive QA and testing skill for quality assurance, test automation, and testing strategies for ReactJS, NextJS, NodeJS applications. Includes test suite generation, coverage analysis, E2E testing setup, and quality metrics. Use when designing test strategies, writing test cases, implementing test automation, performing manual testing, or analyzing test coverage.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Senior Qa
|
||||||
|
|
||||||
|
Complete toolkit for senior qa with modern tools and best practices.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Main Capabilities
|
||||||
|
|
||||||
|
This skill provides three core capabilities through automated scripts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Script 1: Test Suite Generator
|
||||||
|
python scripts/test_suite_generator.py [options]
|
||||||
|
|
||||||
|
# Script 2: Coverage Analyzer
|
||||||
|
python scripts/coverage_analyzer.py [options]
|
||||||
|
|
||||||
|
# Script 3: E2E Test Scaffolder
|
||||||
|
python scripts/e2e_test_scaffolder.py [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Capabilities
|
||||||
|
|
||||||
|
### 1. Test Suite Generator
|
||||||
|
|
||||||
|
Automated tool for test suite generator tasks.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Automated scaffolding
|
||||||
|
- Best practices built-in
|
||||||
|
- Configurable templates
|
||||||
|
- Quality checks
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
python scripts/test_suite_generator.py <project-path> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Coverage Analyzer
|
||||||
|
|
||||||
|
Comprehensive analysis and optimization tool.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Deep analysis
|
||||||
|
- Performance metrics
|
||||||
|
- Recommendations
|
||||||
|
- Automated fixes
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
python scripts/coverage_analyzer.py <target-path> [--verbose]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. E2E Test Scaffolder
|
||||||
|
|
||||||
|
Advanced tooling for specialized tasks.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Expert-level automation
|
||||||
|
- Custom configurations
|
||||||
|
- Integration ready
|
||||||
|
- Production-grade output
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
python scripts/e2e_test_scaffolder.py [arguments] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference Documentation
|
||||||
|
|
||||||
|
### Testing Strategies
|
||||||
|
|
||||||
|
Comprehensive guide available in `references/testing_strategies.md`:
|
||||||
|
|
||||||
|
- Detailed patterns and practices
|
||||||
|
- Code examples
|
||||||
|
- Best practices
|
||||||
|
- Anti-patterns to avoid
|
||||||
|
- Real-world scenarios
|
||||||
|
|
||||||
|
### Test Automation Patterns
|
||||||
|
|
||||||
|
Complete workflow documentation in `references/test_automation_patterns.md`:
|
||||||
|
|
||||||
|
- Step-by-step processes
|
||||||
|
- Optimization strategies
|
||||||
|
- Tool integrations
|
||||||
|
- Performance tuning
|
||||||
|
- Troubleshooting guide
|
||||||
|
|
||||||
|
### Qa Best Practices
|
||||||
|
|
||||||
|
Technical reference guide in `references/qa_best_practices.md`:
|
||||||
|
|
||||||
|
- Technology stack details
|
||||||
|
- Configuration examples
|
||||||
|
- Integration patterns
|
||||||
|
- Security considerations
|
||||||
|
- Scalability guidelines
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
**Languages:** TypeScript, JavaScript, Python, Go, Swift, Kotlin
|
||||||
|
**Frontend:** React, Next.js, React Native, Flutter
|
||||||
|
**Backend:** Node.js, Express, GraphQL, REST APIs
|
||||||
|
**Database:** PostgreSQL, Prisma, NeonDB, Supabase
|
||||||
|
**DevOps:** Docker, Kubernetes, Terraform, GitHub Actions, CircleCI
|
||||||
|
**Cloud:** AWS, GCP, Azure
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### 1. Setup and Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
# or
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Configure environment
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run Quality Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use the analyzer script
|
||||||
|
python scripts/coverage_analyzer.py .
|
||||||
|
|
||||||
|
# Review recommendations
|
||||||
|
# Apply fixes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Implement Best Practices
|
||||||
|
|
||||||
|
Follow the patterns and practices documented in:
|
||||||
|
- `references/testing_strategies.md`
|
||||||
|
- `references/test_automation_patterns.md`
|
||||||
|
- `references/qa_best_practices.md`
|
||||||
|
|
||||||
|
## Best Practices Summary
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- Follow established patterns
|
||||||
|
- Write comprehensive tests
|
||||||
|
- Document decisions
|
||||||
|
- Review regularly
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Measure before optimizing
|
||||||
|
- Use appropriate caching
|
||||||
|
- Optimize critical paths
|
||||||
|
- Monitor in production
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Validate all inputs
|
||||||
|
- Use parameterized queries
|
||||||
|
- Implement proper authentication
|
||||||
|
- Keep dependencies updated
|
||||||
|
|
||||||
|
### Maintainability
|
||||||
|
- Write clear code
|
||||||
|
- Use consistent naming
|
||||||
|
- Add helpful comments
|
||||||
|
- Keep it simple
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
npm run dev
|
||||||
|
npm run build
|
||||||
|
npm run test
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# Analysis
|
||||||
|
python scripts/coverage_analyzer.py .
|
||||||
|
python scripts/e2e_test_scaffolder.py --analyze
|
||||||
|
|
||||||
|
# Deployment
|
||||||
|
docker build -t app:latest .
|
||||||
|
docker-compose up -d
|
||||||
|
kubectl apply -f k8s/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
Check the comprehensive troubleshooting section in `references/qa_best_practices.md`.
|
||||||
|
|
||||||
|
### Getting Help
|
||||||
|
|
||||||
|
- Review reference documentation
|
||||||
|
- Check script output messages
|
||||||
|
- Consult tech stack documentation
|
||||||
|
- Review error logs
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- Pattern Reference: `references/testing_strategies.md`
|
||||||
|
- Workflow Guide: `references/test_automation_patterns.md`
|
||||||
|
- Technical Guide: `references/qa_best_practices.md`
|
||||||
|
- Tool Scripts: `scripts/` directory
|
||||||
103
.claude/skills/senior-qa/references/qa_best_practices.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Qa Best Practices
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This reference guide provides comprehensive information for senior qa.
|
||||||
|
|
||||||
|
## Patterns and Practices
|
||||||
|
|
||||||
|
### Pattern 1: Best Practice Implementation
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Detailed explanation of the pattern.
|
||||||
|
|
||||||
|
**When to Use:**
|
||||||
|
- Scenario 1
|
||||||
|
- Scenario 2
|
||||||
|
- Scenario 3
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```typescript
|
||||||
|
// Example code implementation
|
||||||
|
export class Example {
|
||||||
|
// Implementation details
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Benefit 1
|
||||||
|
- Benefit 2
|
||||||
|
- Benefit 3
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Consider 1
|
||||||
|
- Consider 2
|
||||||
|
- Consider 3
|
||||||
|
|
||||||
|
### Pattern 2: Advanced Technique
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Another important pattern for senior qa.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```typescript
|
||||||
|
// Advanced example
|
||||||
|
async function advancedExample() {
|
||||||
|
// Code here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
- Clear structure
|
||||||
|
- Logical separation
|
||||||
|
- Consistent naming
|
||||||
|
- Proper documentation
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
- Optimization strategies
|
||||||
|
- Bottleneck identification
|
||||||
|
- Monitoring approaches
|
||||||
|
- Scaling techniques
|
||||||
|
|
||||||
|
### Security Best Practices
|
||||||
|
- Input validation
|
||||||
|
- Authentication
|
||||||
|
- Authorization
|
||||||
|
- Data protection
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Pattern A
|
||||||
|
Implementation details and examples.
|
||||||
|
|
||||||
|
### Pattern B
|
||||||
|
Implementation details and examples.
|
||||||
|
|
||||||
|
### Pattern C
|
||||||
|
Implementation details and examples.
|
||||||
|
|
||||||
|
## Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
### Anti-Pattern 1
|
||||||
|
What not to do and why.
|
||||||
|
|
||||||
|
### Anti-Pattern 2
|
||||||
|
What not to do and why.
|
||||||
|
|
||||||
|
## Tools and Resources
|
||||||
|
|
||||||
|
### Recommended Tools
|
||||||
|
- Tool 1: Purpose
|
||||||
|
- Tool 2: Purpose
|
||||||
|
- Tool 3: Purpose
|
||||||
|
|
||||||
|
### Further Reading
|
||||||
|
- Resource 1
|
||||||
|
- Resource 2
|
||||||
|
- Resource 3
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Key takeaways for using this reference guide effectively.
|
||||||
103
.claude/skills/senior-qa/references/test_automation_patterns.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Test Automation Patterns
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This reference guide provides comprehensive information for senior qa.
|
||||||
|
|
||||||
|
## Patterns and Practices
|
||||||
|
|
||||||
|
### Pattern 1: Best Practice Implementation
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Detailed explanation of the pattern.
|
||||||
|
|
||||||
|
**When to Use:**
|
||||||
|
- Scenario 1
|
||||||
|
- Scenario 2
|
||||||
|
- Scenario 3
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```typescript
|
||||||
|
// Example code implementation
|
||||||
|
export class Example {
|
||||||
|
// Implementation details
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Benefit 1
|
||||||
|
- Benefit 2
|
||||||
|
- Benefit 3
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Consider 1
|
||||||
|
- Consider 2
|
||||||
|
- Consider 3
|
||||||
|
|
||||||
|
### Pattern 2: Advanced Technique
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Another important pattern for senior qa.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```typescript
|
||||||
|
// Advanced example
|
||||||
|
async function advancedExample() {
|
||||||
|
// Code here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
- Clear structure
|
||||||
|
- Logical separation
|
||||||
|
- Consistent naming
|
||||||
|
- Proper documentation
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
- Optimization strategies
|
||||||
|
- Bottleneck identification
|
||||||
|
- Monitoring approaches
|
||||||
|
- Scaling techniques
|
||||||
|
|
||||||
|
### Security Best Practices
|
||||||
|
- Input validation
|
||||||
|
- Authentication
|
||||||
|
- Authorization
|
||||||
|
- Data protection
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Pattern A
|
||||||
|
Implementation details and examples.
|
||||||
|
|
||||||
|
### Pattern B
|
||||||
|
Implementation details and examples.
|
||||||
|
|
||||||
|
### Pattern C
|
||||||
|
Implementation details and examples.
|
||||||
|
|
||||||
|
## Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
### Anti-Pattern 1
|
||||||
|
What not to do and why.
|
||||||
|
|
||||||
|
### Anti-Pattern 2
|
||||||
|
What not to do and why.
|
||||||
|
|
||||||
|
## Tools and Resources
|
||||||
|
|
||||||
|
### Recommended Tools
|
||||||
|
- Tool 1: Purpose
|
||||||
|
- Tool 2: Purpose
|
||||||
|
- Tool 3: Purpose
|
||||||
|
|
||||||
|
### Further Reading
|
||||||
|
- Resource 1
|
||||||
|
- Resource 2
|
||||||
|
- Resource 3
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Key takeaways for using this reference guide effectively.
|
||||||
103
.claude/skills/senior-qa/references/testing_strategies.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Testing Strategies
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This reference guide provides comprehensive information for senior qa.
|
||||||
|
|
||||||
|
## Patterns and Practices
|
||||||
|
|
||||||
|
### Pattern 1: Best Practice Implementation
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Detailed explanation of the pattern.
|
||||||
|
|
||||||
|
**When to Use:**
|
||||||
|
- Scenario 1
|
||||||
|
- Scenario 2
|
||||||
|
- Scenario 3
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```typescript
|
||||||
|
// Example code implementation
|
||||||
|
export class Example {
|
||||||
|
// Implementation details
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Benefit 1
|
||||||
|
- Benefit 2
|
||||||
|
- Benefit 3
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Consider 1
|
||||||
|
- Consider 2
|
||||||
|
- Consider 3
|
||||||
|
|
||||||
|
### Pattern 2: Advanced Technique
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Another important pattern for senior qa.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```typescript
|
||||||
|
// Advanced example
|
||||||
|
async function advancedExample() {
|
||||||
|
// Code here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
- Clear structure
|
||||||
|
- Logical separation
|
||||||
|
- Consistent naming
|
||||||
|
- Proper documentation
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
- Optimization strategies
|
||||||
|
- Bottleneck identification
|
||||||
|
- Monitoring approaches
|
||||||
|
- Scaling techniques
|
||||||
|
|
||||||
|
### Security Best Practices
|
||||||
|
- Input validation
|
||||||
|
- Authentication
|
||||||
|
- Authorization
|
||||||
|
- Data protection
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Pattern A
|
||||||
|
Implementation details and examples.
|
||||||
|
|
||||||
|
### Pattern B
|
||||||
|
Implementation details and examples.
|
||||||
|
|
||||||
|
### Pattern C
|
||||||
|
Implementation details and examples.
|
||||||
|
|
||||||
|
## Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
### Anti-Pattern 1
|
||||||
|
What not to do and why.
|
||||||
|
|
||||||
|
### Anti-Pattern 2
|
||||||
|
What not to do and why.
|
||||||
|
|
||||||
|
## Tools and Resources
|
||||||
|
|
||||||
|
### Recommended Tools
|
||||||
|
- Tool 1: Purpose
|
||||||
|
- Tool 2: Purpose
|
||||||
|
- Tool 3: Purpose
|
||||||
|
|
||||||
|
### Further Reading
|
||||||
|
- Resource 1
|
||||||
|
- Resource 2
|
||||||
|
- Resource 3
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Key takeaways for using this reference guide effectively.
|
||||||
114
.claude/skills/senior-qa/scripts/coverage_analyzer.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Coverage Analyzer
|
||||||
|
Automated tool for senior qa tasks
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
class CoverageAnalyzer:
|
||||||
|
"""Main class for coverage analyzer functionality"""
|
||||||
|
|
||||||
|
def __init__(self, target_path: str, verbose: bool = False):
|
||||||
|
self.target_path = Path(target_path)
|
||||||
|
self.verbose = verbose
|
||||||
|
self.results = {}
|
||||||
|
|
||||||
|
def run(self) -> Dict:
|
||||||
|
"""Execute the main functionality"""
|
||||||
|
print(f"🚀 Running {self.__class__.__name__}...")
|
||||||
|
print(f"📁 Target: {self.target_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.validate_target()
|
||||||
|
self.analyze()
|
||||||
|
self.generate_report()
|
||||||
|
|
||||||
|
print("✅ Completed successfully!")
|
||||||
|
return self.results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def validate_target(self):
|
||||||
|
"""Validate the target path exists and is accessible"""
|
||||||
|
if not self.target_path.exists():
|
||||||
|
raise ValueError(f"Target path does not exist: {self.target_path}")
|
||||||
|
|
||||||
|
if self.verbose:
|
||||||
|
print(f"✓ Target validated: {self.target_path}")
|
||||||
|
|
||||||
|
def analyze(self):
|
||||||
|
"""Perform the main analysis or operation"""
|
||||||
|
if self.verbose:
|
||||||
|
print("📊 Analyzing...")
|
||||||
|
|
||||||
|
# Main logic here
|
||||||
|
self.results['status'] = 'success'
|
||||||
|
self.results['target'] = str(self.target_path)
|
||||||
|
self.results['findings'] = []
|
||||||
|
|
||||||
|
# Add analysis results
|
||||||
|
if self.verbose:
|
||||||
|
print(f"✓ Analysis complete: {len(self.results.get('findings', []))} findings")
|
||||||
|
|
||||||
|
def generate_report(self):
|
||||||
|
"""Generate and display the report"""
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("REPORT")
|
||||||
|
print("="*50)
|
||||||
|
print(f"Target: {self.results.get('target')}")
|
||||||
|
print(f"Status: {self.results.get('status')}")
|
||||||
|
print(f"Findings: {len(self.results.get('findings', []))}")
|
||||||
|
print("="*50 + "\n")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Coverage Analyzer"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'target',
|
||||||
|
help='Target path to analyze or process'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--verbose', '-v',
|
||||||
|
action='store_true',
|
||||||
|
help='Enable verbose output'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--json',
|
||||||
|
action='store_true',
|
||||||
|
help='Output results as JSON'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--output', '-o',
|
||||||
|
help='Output file path'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
tool = CoverageAnalyzer(
|
||||||
|
args.target,
|
||||||
|
verbose=args.verbose
|
||||||
|
)
|
||||||
|
|
||||||
|
results = tool.run()
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
output = json.dumps(results, indent=2)
|
||||||
|
if args.output:
|
||||||
|
with open(args.output, 'w') as f:
|
||||||
|
f.write(output)
|
||||||
|
print(f"Results written to {args.output}")
|
||||||
|
else:
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
114
.claude/skills/senior-qa/scripts/e2e_test_scaffolder.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
E2E Test Scaffolder
|
||||||
|
Automated tool for senior qa tasks
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
class E2ETestScaffolder:
|
||||||
|
"""Main class for e2e test scaffolder functionality"""
|
||||||
|
|
||||||
|
def __init__(self, target_path: str, verbose: bool = False):
|
||||||
|
self.target_path = Path(target_path)
|
||||||
|
self.verbose = verbose
|
||||||
|
self.results = {}
|
||||||
|
|
||||||
|
def run(self) -> Dict:
|
||||||
|
"""Execute the main functionality"""
|
||||||
|
print(f"🚀 Running {self.__class__.__name__}...")
|
||||||
|
print(f"📁 Target: {self.target_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.validate_target()
|
||||||
|
self.analyze()
|
||||||
|
self.generate_report()
|
||||||
|
|
||||||
|
print("✅ Completed successfully!")
|
||||||
|
return self.results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def validate_target(self):
|
||||||
|
"""Validate the target path exists and is accessible"""
|
||||||
|
if not self.target_path.exists():
|
||||||
|
raise ValueError(f"Target path does not exist: {self.target_path}")
|
||||||
|
|
||||||
|
if self.verbose:
|
||||||
|
print(f"✓ Target validated: {self.target_path}")
|
||||||
|
|
||||||
|
def analyze(self):
|
||||||
|
"""Perform the main analysis or operation"""
|
||||||
|
if self.verbose:
|
||||||
|
print("📊 Analyzing...")
|
||||||
|
|
||||||
|
# Main logic here
|
||||||
|
self.results['status'] = 'success'
|
||||||
|
self.results['target'] = str(self.target_path)
|
||||||
|
self.results['findings'] = []
|
||||||
|
|
||||||
|
# Add analysis results
|
||||||
|
if self.verbose:
|
||||||
|
print(f"✓ Analysis complete: {len(self.results.get('findings', []))} findings")
|
||||||
|
|
||||||
|
def generate_report(self):
|
||||||
|
"""Generate and display the report"""
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("REPORT")
|
||||||
|
print("="*50)
|
||||||
|
print(f"Target: {self.results.get('target')}")
|
||||||
|
print(f"Status: {self.results.get('status')}")
|
||||||
|
print(f"Findings: {len(self.results.get('findings', []))}")
|
||||||
|
print("="*50 + "\n")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="E2E Test Scaffolder"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'target',
|
||||||
|
help='Target path to analyze or process'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--verbose', '-v',
|
||||||
|
action='store_true',
|
||||||
|
help='Enable verbose output'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--json',
|
||||||
|
action='store_true',
|
||||||
|
help='Output results as JSON'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--output', '-o',
|
||||||
|
help='Output file path'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
tool = E2ETestScaffolder(
|
||||||
|
args.target,
|
||||||
|
verbose=args.verbose
|
||||||
|
)
|
||||||
|
|
||||||
|
results = tool.run()
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
output = json.dumps(results, indent=2)
|
||||||
|
if args.output:
|
||||||
|
with open(args.output, 'w') as f:
|
||||||
|
f.write(output)
|
||||||
|
print(f"Results written to {args.output}")
|
||||||
|
else:
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
114
.claude/skills/senior-qa/scripts/test_suite_generator.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test Suite Generator
|
||||||
|
Automated tool for senior qa tasks
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
class TestSuiteGenerator:
|
||||||
|
"""Main class for test suite generator functionality"""
|
||||||
|
|
||||||
|
def __init__(self, target_path: str, verbose: bool = False):
|
||||||
|
self.target_path = Path(target_path)
|
||||||
|
self.verbose = verbose
|
||||||
|
self.results = {}
|
||||||
|
|
||||||
|
def run(self) -> Dict:
|
||||||
|
"""Execute the main functionality"""
|
||||||
|
print(f"🚀 Running {self.__class__.__name__}...")
|
||||||
|
print(f"📁 Target: {self.target_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.validate_target()
|
||||||
|
self.analyze()
|
||||||
|
self.generate_report()
|
||||||
|
|
||||||
|
print("✅ Completed successfully!")
|
||||||
|
return self.results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def validate_target(self):
|
||||||
|
"""Validate the target path exists and is accessible"""
|
||||||
|
if not self.target_path.exists():
|
||||||
|
raise ValueError(f"Target path does not exist: {self.target_path}")
|
||||||
|
|
||||||
|
if self.verbose:
|
||||||
|
print(f"✓ Target validated: {self.target_path}")
|
||||||
|
|
||||||
|
def analyze(self):
|
||||||
|
"""Perform the main analysis or operation"""
|
||||||
|
if self.verbose:
|
||||||
|
print("📊 Analyzing...")
|
||||||
|
|
||||||
|
# Main logic here
|
||||||
|
self.results['status'] = 'success'
|
||||||
|
self.results['target'] = str(self.target_path)
|
||||||
|
self.results['findings'] = []
|
||||||
|
|
||||||
|
# Add analysis results
|
||||||
|
if self.verbose:
|
||||||
|
print(f"✓ Analysis complete: {len(self.results.get('findings', []))} findings")
|
||||||
|
|
||||||
|
def generate_report(self):
|
||||||
|
"""Generate and display the report"""
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("REPORT")
|
||||||
|
print("="*50)
|
||||||
|
print(f"Target: {self.results.get('target')}")
|
||||||
|
print(f"Status: {self.results.get('status')}")
|
||||||
|
print(f"Findings: {len(self.results.get('findings', []))}")
|
||||||
|
print("="*50 + "\n")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Test Suite Generator"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'target',
|
||||||
|
help='Target path to analyze or process'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--verbose', '-v',
|
||||||
|
action='store_true',
|
||||||
|
help='Enable verbose output'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--json',
|
||||||
|
action='store_true',
|
||||||
|
help='Output results as JSON'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--output', '-o',
|
||||||
|
help='Output file path'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
tool = TestSuiteGenerator(
|
||||||
|
args.target,
|
||||||
|
verbose=args.verbose
|
||||||
|
)
|
||||||
|
|
||||||
|
results = tool.run()
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
output = json.dumps(results, indent=2)
|
||||||
|
if args.output:
|
||||||
|
with open(args.output, 'w') as f:
|
||||||
|
f.write(output)
|
||||||
|
print(f"Results written to {args.output}")
|
||||||
|
else:
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# NextAuth Configuration
|
||||||
|
# Generate a secret with: openssl rand -base64 32
|
||||||
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
NEXTAUTH_SECRET=your-secret-key-here
|
||||||
|
|
||||||
|
# Backend API URL
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
||||||
|
|
||||||
|
# Auth Mode: true = login required, false = public access with optional login
|
||||||
|
NEXT_PUBLIC_AUTH_REQUIRED=false
|
||||||
|
|
||||||
|
NEXT_PUBLIC_GOOGLE_API_KEY='api-key'
|
||||||
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto
|
||||||
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
.next
|
||||||
|
|
||||||
|
certificates
|
||||||
|
|
||||||
|
.env.local
|
||||||
12
.mcp.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"DeepGraph Next.js MCP": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"mcp-code-graph@latest",
|
||||||
|
"vercel/next.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
341
README.md
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
# 🚀 Enterprise Next.js Boilerplate (Antigravity Edition)
|
||||||
|
|
||||||
|
[](https://nextjs.org/)
|
||||||
|
[](https://react.dev/)
|
||||||
|
[](https://chakra-ui.com/)
|
||||||
|
[](https://www.typescriptlang.org/)
|
||||||
|
[](https://next-intl-docs.vercel.app/)
|
||||||
|
|
||||||
|
> **FOR AI AGENTS & DEVELOPERS:** This documentation is structured to provide deep context, architectural decisions, and operational details to ensure seamless handover to any AI coding assistant (like Antigravity) or human developer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 Project Context & Architecture (Read Me First)
|
||||||
|
|
||||||
|
This is an **opinionated, production-ready** frontend boilerplate built with Next.js 16 App Router. It is designed to work seamlessly with the companion **typescript-boilerplate-be** NestJS backend.
|
||||||
|
|
||||||
|
### 🏗️ Core Philosophy
|
||||||
|
|
||||||
|
- **Type Safety First:** Strict TypeScript configuration. DTOs and typed API responses connect frontend to backend.
|
||||||
|
- **App Router Native:** Built entirely on Next.js App Router with Server Components and React Server Actions support.
|
||||||
|
- **i18n Native:** Localization is baked into routing, API calls, and UI components via `next-intl`.
|
||||||
|
- **Theme-First Design:** Chakra UI v3 with custom theme system for consistent, accessible UI.
|
||||||
|
- **Auth by Default:** NextAuth.js integration with JWT token management and automatic session handling.
|
||||||
|
|
||||||
|
### 📐 Architectural Decision Records (ADR)
|
||||||
|
|
||||||
|
_To understand WHY things are the way they are:_
|
||||||
|
|
||||||
|
1. **Locale-Based Routing:**
|
||||||
|
- **Mechanism:** All routes are prefixed with locale (e.g., `/tr/dashboard`, `/en/login`).
|
||||||
|
- **Implementation:** `next-intl` plugin with `[locale]` dynamic segment in `app/` directory.
|
||||||
|
- **Config:** `localePrefix: 'always'` ensures consistent URL structure.
|
||||||
|
|
||||||
|
2. **API Client with Auto-Auth & Locale:**
|
||||||
|
- **Location:** `src/lib/api/create-api-client.ts`
|
||||||
|
- **Feature:** Automatically injects JWT `Authorization` header from NextAuth session.
|
||||||
|
- **Feature:** Automatically sets `Accept-Language` header based on current locale (cookie or URL path).
|
||||||
|
- **Auto-Logout:** 401 responses trigger automatic signOut and redirect.
|
||||||
|
|
||||||
|
3. **Backend Proxy (CORS Solution):**
|
||||||
|
- **Problem:** Local development CORS issues when frontend (port 3001) calls backend (port 3000).
|
||||||
|
- **Solution:** Next.js `rewrites` in `next.config.ts` proxies `/api/backend/*` → `http://localhost:3000/api/*`.
|
||||||
|
- **Usage:** API clients use `/api/backend/...` paths in development.
|
||||||
|
|
||||||
|
4. **Provider Composition:**
|
||||||
|
- **Location:** `src/components/ui/provider.tsx`
|
||||||
|
- **Stack:** `SessionProvider` (NextAuth) → `ChakraProvider` (UI) → `ColorModeProvider` (Dark/Light) → `Toaster` (Notifications)
|
||||||
|
- **Why:** Single import in layout provides all necessary contexts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start for AI & Humans
|
||||||
|
|
||||||
|
### 1. Prerequisites
|
||||||
|
|
||||||
|
- **Node.js:** v20.19+ (LTS)
|
||||||
|
- **Package Manager:** `npm` (Lockfile: `package-lock.json`)
|
||||||
|
- **Backend:** Companion NestJS backend running on port 3000 (see `typescript-boilerplate-be`)
|
||||||
|
|
||||||
|
### 2. Environment Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
# Edit .env.local with your configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required Environment Variables:**
|
||||||
|
|
||||||
|
| Variable | Description | Example |
|
||||||
|
| --------------------------- | --------------------------------------------------------------- | --------------------------- |
|
||||||
|
| `NEXTAUTH_URL` | NextAuth callback URL | `http://localhost:3001` |
|
||||||
|
| `NEXTAUTH_SECRET` | Authentication secret (generate with `openssl rand -base64 32`) | `abc123...` |
|
||||||
|
| `NEXT_PUBLIC_API_URL` | Backend API base URL | `http://localhost:3001/api` |
|
||||||
|
| `NEXT_PUBLIC_AUTH_REQUIRED` | Require login for all pages | `true` or `false` |
|
||||||
|
|
||||||
|
### 3. Installation & Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
# Development Mode (HTTPS enabled, port 3001)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Production Build
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Dev server runs on port 3001 with experimental HTTPS for secure cookie handling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌍 Internationalization (i18n) Guide
|
||||||
|
|
||||||
|
Deep integration with `next-intl` provides locale-aware routing and translations.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- **Supported Locales:** `['en', 'tr']`
|
||||||
|
- **Default Locale:** `tr`
|
||||||
|
- **Locale Detection:** Cookie (`NEXT_LOCALE`) → URL path → Default
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
messages/
|
||||||
|
├── en.json # English translations
|
||||||
|
└── tr.json # Turkish translations
|
||||||
|
|
||||||
|
src/i18n/
|
||||||
|
├── routing.ts # Locale routing configuration
|
||||||
|
├── navigation.ts # Typed navigation helpers (Link, useRouter)
|
||||||
|
└── request.ts # Server-side i18n setup
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage in Components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Client Component
|
||||||
|
"use client";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export function MyComponent() {
|
||||||
|
const t = useTranslations("common");
|
||||||
|
return <h1>{t("welcome")}</h1>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation with Locale
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
|
|
||||||
|
<Link href="/dashboard">Dashboard</Link>; // Auto-prefixes with current locale
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a New Locale
|
||||||
|
|
||||||
|
1. Add locale code to `src/i18n/routing.ts`:
|
||||||
|
```ts
|
||||||
|
export const locales = ["en", "tr", "de"]; // Add 'de'
|
||||||
|
```
|
||||||
|
2. Create `messages/de.json` with translations.
|
||||||
|
3. Update `getLocale()` in `create-api-client.ts` if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Authentication System
|
||||||
|
|
||||||
|
Built on NextAuth.js with JWT strategy for seamless backend integration.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Login:** User submits credentials → `/api/auth/[...nextauth]` → Backend validates → JWT returned.
|
||||||
|
2. **Session:** JWT stored in encrypted cookie, accessible via `useSession()` hook.
|
||||||
|
3. **API Calls:** `createApiClient()` automatically adds `Authorization: Bearer {token}` header.
|
||||||
|
4. **Auto-Refresh:** Token refreshed before expiry (managed by NextAuth).
|
||||||
|
|
||||||
|
### Protected Routes
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Use middleware.ts or check session in layout
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
|
||||||
|
export default async function ProtectedLayout({ children }) {
|
||||||
|
const session = await getServerSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth Mode Toggle
|
||||||
|
|
||||||
|
Set `NEXT_PUBLIC_AUTH_REQUIRED=true` for mandatory authentication across all pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 UI System (Chakra UI v3)
|
||||||
|
|
||||||
|
### Theme Configuration
|
||||||
|
|
||||||
|
- **Location:** `src/theme/theme.ts`
|
||||||
|
- **Font:** Bricolage Grotesque (Google Fonts, variable font)
|
||||||
|
- **Color Mode:** Light/Dark with system preference detection
|
||||||
|
|
||||||
|
### Component Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
src/components/
|
||||||
|
├── ui/ # Chakra UI wrapper components
|
||||||
|
│ ├── provider.tsx # Root provider (Session + Chakra + Theme)
|
||||||
|
│ ├── color-mode.tsx # Dark/Light mode toggle
|
||||||
|
│ ├── feedback/ # Toaster, alerts, etc.
|
||||||
|
│ └── ...
|
||||||
|
├── layout/ # Page layout components (Header, Sidebar, Footer)
|
||||||
|
├── auth/ # Authentication forms and guards
|
||||||
|
└── site/ # Site-specific feature components
|
||||||
|
```
|
||||||
|
|
||||||
|
### Toast Notifications
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { toaster } from "@/components/ui/feedback/toaster";
|
||||||
|
|
||||||
|
// Success toast
|
||||||
|
toaster.success({
|
||||||
|
title: "Saved!",
|
||||||
|
description: "Changes saved successfully.",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error toast
|
||||||
|
toaster.error({ title: "Error", description: "Something went wrong." });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📡 API Integration
|
||||||
|
|
||||||
|
### Creating API Clients
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/lib/api/auth/login/queries.ts
|
||||||
|
import { createApiClient } from "../create-api-client";
|
||||||
|
|
||||||
|
const api = createApiClient("/api/backend/auth");
|
||||||
|
|
||||||
|
export const loginUser = async (data: LoginDto) => {
|
||||||
|
const response = await api.post("/login", data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### React Query Integration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// Query hook
|
||||||
|
export const useUsers = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["users"],
|
||||||
|
queryFn: () => apiClient.get("/users"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutation hook
|
||||||
|
export const useCreateUser = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: (data: CreateUserDto) => apiClient.post("/users", data),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 System Map (Directory Structure)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Next.js App Router
|
||||||
|
│ ├── [locale]/ # Locale-prefixed routes
|
||||||
|
│ │ ├── (auth)/ # Auth pages (login, register)
|
||||||
|
│ │ ├── (site)/ # Main site pages
|
||||||
|
│ │ ├── (error)/ # Error pages
|
||||||
|
│ │ ├── layout.tsx # Root layout with providers
|
||||||
|
│ │ └── page.tsx # Home page
|
||||||
|
│ └── api/ # Next.js API routes
|
||||||
|
│ └── auth/ # NextAuth endpoints
|
||||||
|
├── components/ # React components
|
||||||
|
│ ├── ui/ # Chakra UI wrappers & base components
|
||||||
|
│ ├── layout/ # Layout components (Header, Sidebar)
|
||||||
|
│ ├── auth/ # Auth-related components
|
||||||
|
│ └── site/ # Feature-specific components
|
||||||
|
├── config/ # App configuration
|
||||||
|
├── hooks/ # Custom React hooks
|
||||||
|
├── i18n/ # Internationalization setup
|
||||||
|
├── lib/ # Utilities & services
|
||||||
|
│ ├── api/ # API clients (organized by module)
|
||||||
|
│ │ ├── auth/ # Auth API (login, register)
|
||||||
|
│ │ ├── admin/ # Admin API (roles, users)
|
||||||
|
│ │ └── create-api-client.ts # Axios factory with interceptors
|
||||||
|
│ ├── services/ # Business logic services
|
||||||
|
│ └── utils/ # Helper functions
|
||||||
|
├── theme/ # Chakra UI theme configuration
|
||||||
|
├── types/ # TypeScript type definitions
|
||||||
|
└── proxy.ts # API proxy utilities
|
||||||
|
|
||||||
|
messages/ # Translation JSON files
|
||||||
|
public/ # Static assets
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing & Development
|
||||||
|
|
||||||
|
### Available Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Start dev server (HTTPS, port 3001)
|
||||||
|
npm run build # Production build
|
||||||
|
npm run start # Start production server
|
||||||
|
npm run lint # Run ESLint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Tools
|
||||||
|
|
||||||
|
- **React Compiler:** Enabled for automatic optimization.
|
||||||
|
- **Top Loader:** Visual loading indicator for route transitions.
|
||||||
|
- **ESLint + Prettier:** Consistent code formatting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Troubleshooting (Known Issues)
|
||||||
|
|
||||||
|
**1. HTTPS Certificate Warnings in Development**
|
||||||
|
|
||||||
|
- **Context:** Dev server uses `--experimental-https` for secure cookies.
|
||||||
|
- **Fix:** Accept the self-signed certificate warning in your browser.
|
||||||
|
|
||||||
|
**2. Backend Connection Refused**
|
||||||
|
|
||||||
|
- **Fix:** Ensure NestJS backend is running on port 3000.
|
||||||
|
- **Check:** Run `curl http://localhost:3000/api/health` to verify.
|
||||||
|
|
||||||
|
**3. Session Not Persisting**
|
||||||
|
|
||||||
|
- **Fix:** Ensure `NEXTAUTH_SECRET` is set and consistent between restarts.
|
||||||
|
- **Fix:** Check that cookies are not blocked by browser settings.
|
||||||
|
|
||||||
|
**4. Locale Not Detected**
|
||||||
|
|
||||||
|
- **Context:** Default falls back to `tr` if cookie/URL detection fails.
|
||||||
|
- **Fix:** Clear `NEXT_LOCALE` cookie and refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Related Projects
|
||||||
|
|
||||||
|
- **Backend:** [typescript-boilerplate-be](../typescript-boilerplate-be) - NestJS backend with Prisma, JWT auth, and i18n.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📃 License
|
||||||
|
|
||||||
|
This project is proprietary and confidential.
|
||||||
25
eslint.config.mjs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { FlatCompat } from '@eslint/eslintrc';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
...compat.extends('next/core-web-vitals', 'next/typescript', 'prettier'),
|
||||||
|
{
|
||||||
|
ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-empty-object-type': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
121
messages/en.json
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
{
|
||||||
|
"home": "Home",
|
||||||
|
"about": "About",
|
||||||
|
"solutions": "Solutions",
|
||||||
|
"skriptai": "SkriptAI",
|
||||||
|
"projectDashboard": {
|
||||||
|
"deepResearch": "Deep Research",
|
||||||
|
"researchDescription": "AI-powered deep dive into your topic using real-time web sources.",
|
||||||
|
"additionalQuery": "Additional context or focus area...",
|
||||||
|
"startResearch": "Start Research",
|
||||||
|
"sources": "Sources",
|
||||||
|
"selected": "Selected",
|
||||||
|
"noSources": "No sources found yet. Start research to find material.",
|
||||||
|
"new": "NEW",
|
||||||
|
"delete": "Delete",
|
||||||
|
"continueToBrief": "Continue to Brief",
|
||||||
|
"continueToCharacters": "Continue to Characters",
|
||||||
|
"continueToScript": "Continue to Script",
|
||||||
|
"projectNotFound": "Project not found",
|
||||||
|
"backToDashboard": "Back to Dashboard",
|
||||||
|
"back": "Back",
|
||||||
|
"export": "Export JSON",
|
||||||
|
"generateScript": "Generate Script",
|
||||||
|
"research": "Research",
|
||||||
|
"brief": "Brief",
|
||||||
|
"characters": "Characters",
|
||||||
|
"script": "Script",
|
||||||
|
"analysis": "Analysis",
|
||||||
|
"characterProfiles": "Character Profiles",
|
||||||
|
"addCharacter": "Add Character",
|
||||||
|
"autoGenerate": "Auto Generate",
|
||||||
|
"characterDescription": "Define or generate characters for your script.",
|
||||||
|
"newCharacter": "New Character",
|
||||||
|
"characterName": "Full Name",
|
||||||
|
"role": "Role",
|
||||||
|
"values": "Values",
|
||||||
|
"valuesPlaceholder": "e.g. Honest, Loyal, Brave",
|
||||||
|
"traits": "Traits",
|
||||||
|
"traitsPlaceholder": "e.g. Short temper, Intelligent",
|
||||||
|
"mannerisms": "Mannerisms",
|
||||||
|
"mannerismsPlaceholder": "e.g. Always taps foot, Stutters",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"noCharacters": "No characters found yet. Create manually or auto-generate.",
|
||||||
|
"loglineHighConcept": "Logline & High Concept",
|
||||||
|
"generate": "Generate",
|
||||||
|
"noLogline": "No logline generated yet.",
|
||||||
|
"creativeBrief": "Creative Brief",
|
||||||
|
"generateQuestions": "Generate Questions",
|
||||||
|
"clickToAnswer": "Click to add an answer...",
|
||||||
|
"noBriefItems": "No brief questions generated yet.",
|
||||||
|
"visualAssets": "Storyboard / Visual Assets",
|
||||||
|
"generateAssets": "Generate Assets",
|
||||||
|
"noVisualAssets": "No visual assets generated yet. Click generate to create storyboard images.",
|
||||||
|
"contentAnalysis": "Content Analysis",
|
||||||
|
"analysisDescription": "AI-powered analysis of your script for engagement, viral potential, and commercial viability.",
|
||||||
|
"neuroAnalysis": "Neuro Marketing",
|
||||||
|
"youtubeAudit": "YouTube Audit",
|
||||||
|
"commercialBrief": "Commercial Brief",
|
||||||
|
"generateScriptFirst": "Please generate a script first to enable analysis tools.",
|
||||||
|
"neuroAnalysisResults": "Neuro Analysis Results",
|
||||||
|
"engagement": "Engagement",
|
||||||
|
"dopamine": "Dopamine",
|
||||||
|
"clarity": "Clarity",
|
||||||
|
"persuasionMetrics": "Persuasion Metrics",
|
||||||
|
"suggestions": "Suggestions",
|
||||||
|
"youtubeAuditResults": "YouTube Audit Results",
|
||||||
|
"hookScore": "Hook Score",
|
||||||
|
"pacing": "Pacing",
|
||||||
|
"viralPotential": "Viral Potential",
|
||||||
|
"titleSuggestions": "Title Suggestions",
|
||||||
|
"thumbnailIdeas": "Thumbnail Ideas",
|
||||||
|
"commercialBriefResults": "Commercial Brief Results",
|
||||||
|
"viability": "Viability",
|
||||||
|
"potentialSponsors": "Potential Sponsors",
|
||||||
|
"imageGenerating": "Generating image...",
|
||||||
|
"generateImage": "Generate Visual",
|
||||||
|
"generateVideo": "Generate Video",
|
||||||
|
"videoGenerationComingSoon": "Video Generation",
|
||||||
|
"narrator": "Narrator",
|
||||||
|
"scriptEditor": "Script Editor",
|
||||||
|
"regenerateScript": "Regenerate Script",
|
||||||
|
"generateScript": "Generate Script"
|
||||||
|
},
|
||||||
|
"intelligent-transportation-systems": "Intelligent Transportation Systems",
|
||||||
|
"artificial-intelligence": "Artificial Intelligence",
|
||||||
|
"error": {
|
||||||
|
"not-found": "Oops! The page you’re looking for doesn’t exist.",
|
||||||
|
"404": "404",
|
||||||
|
"back-to-home": "Go back home"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"home": "Home",
|
||||||
|
"skriptai": "SkriptAI",
|
||||||
|
"about": "About"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"select-language": "Select Language",
|
||||||
|
"open-menu": "Open Menu"
|
||||||
|
},
|
||||||
|
"email": "E-Mail",
|
||||||
|
"password": "Password",
|
||||||
|
"auth": {
|
||||||
|
"remember-me": "Remember Me",
|
||||||
|
"dont-have-account": "Don't have an account?",
|
||||||
|
"sign-out": "Sign Out",
|
||||||
|
"sign-up": "Sign Up",
|
||||||
|
"sign-in": "Sign In",
|
||||||
|
"welcome-back": "Welcome Back",
|
||||||
|
"subtitle": "Enter your email and password to sign in",
|
||||||
|
"already-have-an-account": "Already have an account?",
|
||||||
|
"create-an-account-now": "Create an account now"
|
||||||
|
},
|
||||||
|
"all-right-reserved": "All rights reserved.",
|
||||||
|
"privacy-policy": "Privacy Policy",
|
||||||
|
"terms-of-service": "Terms of Service",
|
||||||
|
"name": "Name",
|
||||||
|
"low": "Low",
|
||||||
|
"medium": "Medium",
|
||||||
|
"high": "High"
|
||||||
|
}
|
||||||
101
messages/en/skriptai.json
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "SkriptAI - AI Video Script Generator",
|
||||||
|
"pageDescription": "Create professional video scripts with AI assistance",
|
||||||
|
"dashboard": "SkriptAI Dashboard",
|
||||||
|
"dashboardSubtitle": "AI-powered video script generation",
|
||||||
|
"newProject": "New Project",
|
||||||
|
"createProject": "Create New Project",
|
||||||
|
"searchPlaceholder": "Search projects...",
|
||||||
|
"noProjects": "No projects yet",
|
||||||
|
"noSearchResults": "No projects match your search",
|
||||||
|
"createFirstProject": "Create your first project",
|
||||||
|
"projectDetail": "Project Detail",
|
||||||
|
"projectNotFound": "Project not found",
|
||||||
|
"backToDashboard": "Back to Dashboard",
|
||||||
|
"confirmDelete": "Are you sure you want to delete this project?",
|
||||||
|
"loadError": "Failed to load projects",
|
||||||
|
"topic": "Topic",
|
||||||
|
"topicPlaceholder": "Enter your video topic...",
|
||||||
|
"contentType": "Content Format",
|
||||||
|
"targetAudience": "Target Audience",
|
||||||
|
"speechStyle": "Speech Style",
|
||||||
|
"targetDuration": "Target Duration",
|
||||||
|
"language": "Language",
|
||||||
|
"notes": "Additional Notes",
|
||||||
|
"notesPlaceholder": "Any specific requirements or context...",
|
||||||
|
"selectMultiple": "Select one or more options",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"create": "Create Project",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"edit": "Edit",
|
||||||
|
"export": "Export JSON",
|
||||||
|
"back": "Back",
|
||||||
|
"segments": "segments",
|
||||||
|
"sources": "sources",
|
||||||
|
"words": "words",
|
||||||
|
"updated": "Updated",
|
||||||
|
"selected": "selected",
|
||||||
|
"new": "New",
|
||||||
|
"research": "Research",
|
||||||
|
"brief": "Brief",
|
||||||
|
"characters": "Characters",
|
||||||
|
"script": "Script",
|
||||||
|
"analysis": "Analysis",
|
||||||
|
"deepResearch": "Deep Research",
|
||||||
|
"researchDescription": "AI will search for relevant sources about your topic",
|
||||||
|
"additionalQuery": "Additional search query (optional)",
|
||||||
|
"startResearch": "Start Research",
|
||||||
|
"noSources": "No research sources yet",
|
||||||
|
"loglineHighConcept": "Logline & High Concept",
|
||||||
|
"generate": "Generate",
|
||||||
|
"noLogline": "No logline generated yet",
|
||||||
|
"creativeBrief": "Creative Brief",
|
||||||
|
"generateQuestions": "Generate Questions",
|
||||||
|
"noBriefItems": "No brief questions yet",
|
||||||
|
"clickToAnswer": "Click to add your answer...",
|
||||||
|
"characterProfiles": "Character Profiles",
|
||||||
|
"addCharacter": "Add Character",
|
||||||
|
"autoGenerate": "Auto Generate",
|
||||||
|
"characterDescription": "Define your characters using the Triunity model: Values, Traits, Mannerisms",
|
||||||
|
"newCharacter": "New Character",
|
||||||
|
"name": "Name",
|
||||||
|
"role": "Role",
|
||||||
|
"values": "Values",
|
||||||
|
"valuesPlaceholder": "Core beliefs and motivations...",
|
||||||
|
"traits": "Traits",
|
||||||
|
"traitsPlaceholder": "Personality characteristics...",
|
||||||
|
"mannerisms": "Mannerisms",
|
||||||
|
"mannerismsPlaceholder": "Speech patterns, gestures...",
|
||||||
|
"characterName": "Character name",
|
||||||
|
"noCharacters": "No characters defined yet",
|
||||||
|
"scriptEditor": "Script Editor",
|
||||||
|
"generateScript": "Generate Script",
|
||||||
|
"regenerateScript": "Regenerate Script",
|
||||||
|
"noSegments": "No script segments yet",
|
||||||
|
"addSourcesFirst": "Add research sources and generate your script",
|
||||||
|
"visual": "Visual",
|
||||||
|
"narrator": "Narrator",
|
||||||
|
"contentAnalysis": "Content Analysis",
|
||||||
|
"analysisDescription": "Analyze your script for engagement, virality, and commercial potential",
|
||||||
|
"neuroAnalysis": "Neuro Marketing",
|
||||||
|
"youtubeAudit": "YouTube Audit",
|
||||||
|
"commercialBrief": "Commercial Brief",
|
||||||
|
"generateScriptFirst": "Generate a script first to run analysis",
|
||||||
|
"neuroAnalysisResults": "Neuro Marketing Analysis",
|
||||||
|
"engagement": "Engagement",
|
||||||
|
"dopamine": "Dopamine",
|
||||||
|
"clarity": "Clarity",
|
||||||
|
"persuasionMetrics": "Persuasion Metrics",
|
||||||
|
"suggestions": "Improvement Suggestions",
|
||||||
|
"youtubeAuditResults": "YouTube Algorithm Audit",
|
||||||
|
"hookScore": "Hook Score",
|
||||||
|
"pacing": "Pacing",
|
||||||
|
"viralPotential": "Viral Potential",
|
||||||
|
"titleSuggestions": "Title Suggestions",
|
||||||
|
"thumbnailIdeas": "Thumbnail Ideas",
|
||||||
|
"commercialBriefResults": "Commercial Brief",
|
||||||
|
"viability": "Viability",
|
||||||
|
"potentialSponsors": "Potential Sponsors"
|
||||||
|
}
|
||||||
121
messages/tr.json
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
{
|
||||||
|
"home": "Anasayfa",
|
||||||
|
"about": "Hakkımızda",
|
||||||
|
"solutions": "Çözümler",
|
||||||
|
"skriptai": "SkriptAI",
|
||||||
|
"projectDashboard": {
|
||||||
|
"deepResearch": "Derin Araştırma",
|
||||||
|
"researchDescription": "Gerçek zamanlı web kaynaklarını kullanarak konunuz hakkında AI destekli derinlemesine inceleme.",
|
||||||
|
"additionalQuery": "Ek bağlam veya odak alanı...",
|
||||||
|
"startResearch": "Araştırmayı Başlat",
|
||||||
|
"sources": "Kaynaklar",
|
||||||
|
"selected": "Seçildi",
|
||||||
|
"noSources": "Henüz kaynak bulunamadı. Malzeme bulmak için araştırmayı başlatın.",
|
||||||
|
"new": "YENİ",
|
||||||
|
"delete": "Sil",
|
||||||
|
"continueToBrief": "Brief'e Devam Et",
|
||||||
|
"continueToCharacters": "Karakterlere Devam Et",
|
||||||
|
"continueToScript": "Senaryoya Devam Et",
|
||||||
|
"projectNotFound": "Proje bulunamadı",
|
||||||
|
"backToDashboard": "Panele Dön",
|
||||||
|
"back": "Geri",
|
||||||
|
"export": "JSON İndir",
|
||||||
|
"generateScript": "Senaryo Oluştur",
|
||||||
|
"research": "Araştırma",
|
||||||
|
"brief": "Brief",
|
||||||
|
"characters": "Karakterler",
|
||||||
|
"script": "Senaryo",
|
||||||
|
"analysis": "Analiz",
|
||||||
|
"characterProfiles": "Karakter Profilleri",
|
||||||
|
"addCharacter": "Karakter Ekle",
|
||||||
|
"autoGenerate": "Otomatik Oluştur",
|
||||||
|
"characterDescription": "Senaryonuz için karakterleri tanımlayın veya oluşturun.",
|
||||||
|
"newCharacter": "Yeni Karakter",
|
||||||
|
"characterName": "Ad Soyad",
|
||||||
|
"role": "Rol",
|
||||||
|
"values": "Değerler",
|
||||||
|
"valuesPlaceholder": "örn. Dürüst, Sadık, Cesur",
|
||||||
|
"traits": "Özellikler",
|
||||||
|
"traitsPlaceholder": "örn. Sinirli, Zeki",
|
||||||
|
"mannerisms": "Alışkanlıklar",
|
||||||
|
"mannerismsPlaceholder": "örn. Sürekli ayağını vurur, Kekeler",
|
||||||
|
"cancel": "İptal",
|
||||||
|
"save": "Kaydet",
|
||||||
|
"noCharacters": "Henüz karakter bulunamadı. Manuel oluşturun veya otomatik üretin.",
|
||||||
|
"loglineHighConcept": "Logline & Yüksek Konsept",
|
||||||
|
"generate": "Oluştur",
|
||||||
|
"noLogline": "Henüz logline oluşturulmadı.",
|
||||||
|
"creativeBrief": "Yaratıcı Özet",
|
||||||
|
"generateQuestions": "Soru Oluştur",
|
||||||
|
"clickToAnswer": "Cevap eklemek için tıklayın...",
|
||||||
|
"noBriefItems": "Henüz özet sorusu oluşturulmadı.",
|
||||||
|
"visualAssets": "Storyboard / Görsel Varlıklar",
|
||||||
|
"generateAssets": "Görsel Oluştur",
|
||||||
|
"noVisualAssets": "Henüz görsel oluşturulmadı. Storyboard görselleri için butona tıklayın.",
|
||||||
|
"contentAnalysis": "İçerik Analizi",
|
||||||
|
"analysisDescription": "Senaryonuzun etkileşim, viral potansiyel ve ticari uygunluk için AI destekli analizi.",
|
||||||
|
"neuroAnalysis": "Nöro Pazarlama",
|
||||||
|
"youtubeAudit": "YouTube Denetimi",
|
||||||
|
"commercialBrief": "Ticari Özet",
|
||||||
|
"generateScriptFirst": "Analiz araçlarını kullanmak için lütfen önce bir senaryo oluşturun.",
|
||||||
|
"neuroAnalysisResults": "Nöro Analiz Sonuçları",
|
||||||
|
"engagement": "Etkileşim",
|
||||||
|
"dopamine": "Dopamin",
|
||||||
|
"clarity": "Netlik",
|
||||||
|
"persuasionMetrics": "İkna Metrikleri",
|
||||||
|
"suggestions": "Öneriler",
|
||||||
|
"youtubeAuditResults": "YouTube Denetim Sonuçları",
|
||||||
|
"hookScore": "Kanca Puanı",
|
||||||
|
"pacing": "Tempo",
|
||||||
|
"viralPotential": "Viral Potansiyel",
|
||||||
|
"titleSuggestions": "Başlık Önerileri",
|
||||||
|
"thumbnailIdeas": "Küçük Resim Fikirleri",
|
||||||
|
"commercialBriefResults": "Ticari Özet Sonuçları",
|
||||||
|
"viability": "Uygunluk",
|
||||||
|
"potentialSponsors": "Potansiyel Sponsorlar",
|
||||||
|
"imageGenerating": "Görsel oluşturuluyor...",
|
||||||
|
"generateImage": "Görsel Oluştur",
|
||||||
|
"generateVideo": "Video Oluştur",
|
||||||
|
"videoGenerationComingSoon": "Video Oluşturma",
|
||||||
|
"narrator": "Anlatıcı",
|
||||||
|
"scriptEditor": "Senaryo Editörü",
|
||||||
|
"regenerateScript": "Senaryoyu Yeniden Oluştur",
|
||||||
|
"generateScript": "Senaryo Oluştur"
|
||||||
|
},
|
||||||
|
"intelligent-transportation-systems": "Akıllı Ulaşım Sistemleri",
|
||||||
|
"artificial-intelligence": "Yapay Zeka",
|
||||||
|
"error": {
|
||||||
|
"not-found": "Üzgünüz, aradığınız sayfa bulunamadı.",
|
||||||
|
"404": "404",
|
||||||
|
"back-to-home": "Ana sayfaya dön"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"home": "Ana Sayfa",
|
||||||
|
"skriptai": "SkriptAI",
|
||||||
|
"about": "Hakkında"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"select-language": "Dil Seçin",
|
||||||
|
"open-menu": "Menüyü Aç"
|
||||||
|
},
|
||||||
|
"email": "E-Posta",
|
||||||
|
"password": "Şifre",
|
||||||
|
"auth": {
|
||||||
|
"remember-me": "Beni Hatırla",
|
||||||
|
"dont-have-account": "Hesabınız yok mu?",
|
||||||
|
"sign-out": "Çıkış Yap",
|
||||||
|
"sign-up": "Kaydol",
|
||||||
|
"sign-in": "Giriş Yap",
|
||||||
|
"welcome-back": "Hoş Geldiniz",
|
||||||
|
"subtitle": "Giriş yapmak için e-postanızı ve şifrenizi giriniz",
|
||||||
|
"already-have-an-account": "Zaten bir hesabınız var mı?",
|
||||||
|
"create-an-account-now": "Hemen bir hesap oluşturun"
|
||||||
|
},
|
||||||
|
"all-right-reserved": "Tüm hakları saklıdır.",
|
||||||
|
"privacy-policy": "Gizlilik Politikası",
|
||||||
|
"terms-of-service": "Hizmet Şartları",
|
||||||
|
"name": "İsim",
|
||||||
|
"low": "Düşük",
|
||||||
|
"medium": "Orta",
|
||||||
|
"high": "Yüksek"
|
||||||
|
}
|
||||||
101
messages/tr/skriptai.json
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "SkriptAI - AI Video Script Oluşturucu",
|
||||||
|
"pageDescription": "AI desteğiyle profesyonel video scriptleri oluşturun",
|
||||||
|
"dashboard": "SkriptAI Paneli",
|
||||||
|
"dashboardSubtitle": "AI destekli video script oluşturma",
|
||||||
|
"newProject": "Yeni Proje",
|
||||||
|
"createProject": "Yeni Proje Oluştur",
|
||||||
|
"searchPlaceholder": "Projelerde ara...",
|
||||||
|
"noProjects": "Henüz proje yok",
|
||||||
|
"noSearchResults": "Aramanızla eşleşen proje bulunamadı",
|
||||||
|
"createFirstProject": "İlk projenizi oluşturun",
|
||||||
|
"projectDetail": "Proje Detayı",
|
||||||
|
"projectNotFound": "Proje bulunamadı",
|
||||||
|
"backToDashboard": "Panele Dön",
|
||||||
|
"confirmDelete": "Bu projeyi silmek istediğinizden emin misiniz?",
|
||||||
|
"loadError": "Projeler yüklenemedi",
|
||||||
|
"topic": "Konu",
|
||||||
|
"topicPlaceholder": "Video konunuzu girin...",
|
||||||
|
"contentType": "İçerik Formatı",
|
||||||
|
"targetAudience": "Hedef Kitle",
|
||||||
|
"speechStyle": "Konuşma Tarzı",
|
||||||
|
"targetDuration": "Hedef Süre",
|
||||||
|
"language": "Dil",
|
||||||
|
"notes": "Ek Notlar",
|
||||||
|
"notesPlaceholder": "Özel gereksinimler veya bağlam...",
|
||||||
|
"selectMultiple": "Bir veya daha fazla seçenek seçin",
|
||||||
|
"cancel": "İptal",
|
||||||
|
"create": "Proje Oluştur",
|
||||||
|
"save": "Kaydet",
|
||||||
|
"delete": "Sil",
|
||||||
|
"duplicate": "Kopyala",
|
||||||
|
"edit": "Düzenle",
|
||||||
|
"export": "JSON Dışa Aktar",
|
||||||
|
"back": "Geri",
|
||||||
|
"segments": "segment",
|
||||||
|
"sources": "kaynak",
|
||||||
|
"words": "kelime",
|
||||||
|
"updated": "Güncellendi",
|
||||||
|
"selected": "seçili",
|
||||||
|
"new": "Yeni",
|
||||||
|
"research": "Araştırma",
|
||||||
|
"brief": "Brief",
|
||||||
|
"characters": "Karakterler",
|
||||||
|
"script": "Script",
|
||||||
|
"analysis": "Analiz",
|
||||||
|
"deepResearch": "Derin Araştırma",
|
||||||
|
"researchDescription": "AI konunuz hakkında ilgili kaynakları arayacak",
|
||||||
|
"additionalQuery": "Ek arama sorgusu (isteğe bağlı)",
|
||||||
|
"startResearch": "Araştırmayı Başlat",
|
||||||
|
"noSources": "Henüz araştırma kaynağı yok",
|
||||||
|
"loglineHighConcept": "Logline & High Concept",
|
||||||
|
"generate": "Oluştur",
|
||||||
|
"noLogline": "Henüz logline oluşturulmadı",
|
||||||
|
"creativeBrief": "Yaratıcı Brief",
|
||||||
|
"generateQuestions": "Soru Oluştur",
|
||||||
|
"noBriefItems": "Henüz brief sorusu yok",
|
||||||
|
"clickToAnswer": "Cevabınızı eklemek için tıklayın...",
|
||||||
|
"characterProfiles": "Karakter Profilleri",
|
||||||
|
"addCharacter": "Karakter Ekle",
|
||||||
|
"autoGenerate": "Otomatik Oluştur",
|
||||||
|
"characterDescription": "Karakterlerinizi Triunity modeli ile tanımlayın: Değerler, Özellikler, Tavırlar",
|
||||||
|
"newCharacter": "Yeni Karakter",
|
||||||
|
"name": "İsim",
|
||||||
|
"role": "Rol",
|
||||||
|
"values": "Değerler",
|
||||||
|
"valuesPlaceholder": "Temel inançlar ve motivasyonlar...",
|
||||||
|
"traits": "Özellikler",
|
||||||
|
"traitsPlaceholder": "Kişilik özellikleri...",
|
||||||
|
"mannerisms": "Tavırlar",
|
||||||
|
"mannerismsPlaceholder": "Konuşma kalıpları, jestler...",
|
||||||
|
"characterName": "Karakter adı",
|
||||||
|
"noCharacters": "Henüz karakter tanımlanmadı",
|
||||||
|
"scriptEditor": "Script Editörü",
|
||||||
|
"generateScript": "Script Oluştur",
|
||||||
|
"regenerateScript": "Scripti Yeniden Oluştur",
|
||||||
|
"noSegments": "Henüz script segmenti yok",
|
||||||
|
"addSourcesFirst": "Araştırma kaynakları ekleyin ve scriptinizi oluşturun",
|
||||||
|
"visual": "Görsel",
|
||||||
|
"narrator": "Anlatıcı",
|
||||||
|
"contentAnalysis": "İçerik Analizi",
|
||||||
|
"analysisDescription": "Scriptinizi etkileşim, virallik ve ticari potansiyel açısından analiz edin",
|
||||||
|
"neuroAnalysis": "Nöro Pazarlama",
|
||||||
|
"youtubeAudit": "YouTube Denetimi",
|
||||||
|
"commercialBrief": "Ticari Brief",
|
||||||
|
"generateScriptFirst": "Analiz çalıştırmak için önce bir script oluşturun",
|
||||||
|
"neuroAnalysisResults": "Nöro Pazarlama Analizi",
|
||||||
|
"engagement": "Etkileşim",
|
||||||
|
"dopamine": "Dopamin",
|
||||||
|
"clarity": "Netlik",
|
||||||
|
"persuasionMetrics": "İkna Metrikleri",
|
||||||
|
"suggestions": "İyileştirme Önerileri",
|
||||||
|
"youtubeAuditResults": "YouTube Algoritma Denetimi",
|
||||||
|
"hookScore": "Hook Skoru",
|
||||||
|
"pacing": "Tempo",
|
||||||
|
"viralPotential": "Viral Potansiyel",
|
||||||
|
"titleSuggestions": "Başlık Önerileri",
|
||||||
|
"thumbnailIdeas": "Küçük Resim Fikirleri",
|
||||||
|
"commercialBriefResults": "Ticari Brief",
|
||||||
|
"viability": "Uygulanabilirlik",
|
||||||
|
"potentialSponsors": "Potansiyel Sponsorlar"
|
||||||
|
}
|
||||||
42
middleware.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { withAuth } from "next-auth/middleware";
|
||||||
|
import createMiddleware from "next-intl/middleware";
|
||||||
|
import { routing } from "./src/i18n/routing";
|
||||||
|
|
||||||
|
const intlMiddleware = createMiddleware(routing);
|
||||||
|
|
||||||
|
const publicPages = ["/signin", "/signup"];
|
||||||
|
|
||||||
|
const authMiddleware = withAuth(
|
||||||
|
// Note: It is important to return the intlMiddleware to handle locale rewrites/redirects
|
||||||
|
(req) => intlMiddleware(req),
|
||||||
|
{
|
||||||
|
callbacks: {
|
||||||
|
authorized: ({ token }) => token != null,
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: "/signin",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function middleware(req: any) {
|
||||||
|
const publicPathnameRegex = RegExp(
|
||||||
|
`^(/(${routing.locales.join("|")}))?(${publicPages
|
||||||
|
.flatMap((p) => (p === "/" ? ["", "/"] : p))
|
||||||
|
.join("|")})/?$`,
|
||||||
|
"i"
|
||||||
|
);
|
||||||
|
const isPublicPage = publicPathnameRegex.test(req.nextUrl.pathname);
|
||||||
|
|
||||||
|
if (isPublicPage) {
|
||||||
|
return intlMiddleware(req);
|
||||||
|
} else {
|
||||||
|
return (authMiddleware as any)(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Skip all paths that should not be internationalized.
|
||||||
|
// This skips the folders "api", "_next" and all files with an extension (e.g. favicon.ico)
|
||||||
|
matcher: ["/((?!api|_next|.*\\..*).*)"],
|
||||||
|
};
|
||||||
6
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
20
next.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
experimental: {
|
||||||
|
optimizePackageImports: ["@chakra-ui/react"],
|
||||||
|
},
|
||||||
|
reactCompiler: true,
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/backend/:path*",
|
||||||
|
destination: "http://localhost:3000/api/:path*",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
export default withNextIntl(nextConfig);
|
||||||
10277
package-lock.json
generated
Normal file
45
package.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "SkriptAI-fe",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --webpack --experimental-https -p 3001",
|
||||||
|
"build": "next build --webpack",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@chakra-ui/react": "^3.28.0",
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@google/genai": "^1.35.0",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@tanstack/react-query": "^5.90.16",
|
||||||
|
"@tanstack/react-query-devtools": "^5.91.2",
|
||||||
|
"axios": "^1.13.1",
|
||||||
|
"i18next": "^25.6.0",
|
||||||
|
"next": "16.0.0",
|
||||||
|
"next-auth": "^4.24.13",
|
||||||
|
"next-intl": "^4.4.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"nextjs-toploader": "^3.9.17",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0",
|
||||||
|
"react-hook-form": "^7.65.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
|
"yup": "^1.7.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@chakra-ui/cli": "^3.27.1",
|
||||||
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "19.2.2",
|
||||||
|
"@types/react-dom": "19.2.2",
|
||||||
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"eslint": "^9.37.0",
|
||||||
|
"eslint-config-next": "16.0.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"description": "Generated by Frontend CLI"
|
||||||
|
}
|
||||||
110
prompt.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# 🤖 AI Assistant Context - Next.js Frontend
|
||||||
|
|
||||||
|
> Bu dosya, AI asistanların (Claude, GPT, Gemini vb.) projeyi hızlıca anlaması için hazırlanmış bir referans dökümanıdır.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Projeyi Anlamak İçin Önce Oku
|
||||||
|
|
||||||
|
1. **README.md** dosyasını oku - Projenin mimarisi, teknoloji stack'i ve kurulum adımlarını içerir.
|
||||||
|
|
||||||
|
```
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Referans Klasörü
|
||||||
|
|
||||||
|
`.claude/` klasörü best practice'ler, agent tanımları ve yardımcı scriptler içerir. Görev türüne göre ilgili referansları kullan:
|
||||||
|
|
||||||
|
### Skills (Beceri Setleri)
|
||||||
|
|
||||||
|
| Beceri | Konum | Ne Zaman Kullan |
|
||||||
|
| ------------------- | --------------------------------- | ------------------------------------------ |
|
||||||
|
| **Senior QA** | `.claude/skills/senior-qa/` | Test yazarken, coverage analizi yaparken |
|
||||||
|
| **Senior Frontend** | `.claude/skills/senior-frontend/` | Component geliştirirken, UI best practices |
|
||||||
|
| **Frontend Design** | `.claude/skills/frontend-design/` | Tasarım kararları alırken |
|
||||||
|
|
||||||
|
### Agents (Roller)
|
||||||
|
|
||||||
|
| Agent | Konum | Açıklama |
|
||||||
|
| ------------------------------- | ---------------------------------------------- | ------------------------- |
|
||||||
|
| **Frontend Developer** | `.claude/agents/frontend-developer.md` | Genel frontend geliştirme |
|
||||||
|
| **Next.js Architecture Expert** | `.claude/agents/nextjs-architecture-expert.md` | Mimari kararlar |
|
||||||
|
|
||||||
|
### Commands (Komutlar)
|
||||||
|
|
||||||
|
| Komut | Konum | Açıklama |
|
||||||
|
| ----------------------- | ------------------------------------------------ | ------------------------ |
|
||||||
|
| **Component Generator** | `.claude/commands/nextjs-component-generator.md` | Yeni component oluşturma |
|
||||||
|
| **API Tester** | `.claude/commands/nextjs-api-tester.md` | API endpoint test etme |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Teknoloji Stack'i (Özet)
|
||||||
|
|
||||||
|
- **Framework:** Next.js 16 (App Router)
|
||||||
|
- **UI Library:** Chakra UI v3
|
||||||
|
- **State Management:** React Query (TanStack)
|
||||||
|
- **Auth:** NextAuth.js
|
||||||
|
- **i18n:** next-intl
|
||||||
|
- **Language:** TypeScript (Strict Mode)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Proje Yapısı Özeti
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/[locale]/ # Locale-based routing
|
||||||
|
├── components/ # UI components
|
||||||
|
├── lib/api/ # API clients
|
||||||
|
├── i18n/ # Internationalization
|
||||||
|
└── theme/ # Chakra UI theme
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Görev Bazlı Referans Kullanımı
|
||||||
|
|
||||||
|
**Test yazarken:**
|
||||||
|
|
||||||
|
```
|
||||||
|
.claude/skills/senior-qa/references/testing_strategies.md
|
||||||
|
.claude/skills/senior-qa/references/test_automation_patterns.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**Component geliştirirken:**
|
||||||
|
|
||||||
|
```
|
||||||
|
.claude/skills/senior-frontend/SKILL.md
|
||||||
|
.claude/skills/frontend-design/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mimari kararlar alırken:**
|
||||||
|
|
||||||
|
```
|
||||||
|
.claude/agents/nextjs-architecture-expert.md
|
||||||
|
README.md (ADR bölümü)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Örnek Prompt'lar
|
||||||
|
|
||||||
|
### Yeni Component Oluşturma
|
||||||
|
|
||||||
|
> "`.claude/skills/senior-frontend/` referanslarını kullanarak, reusable bir `DataTable` component'i oluştur."
|
||||||
|
|
||||||
|
### Test Yazma
|
||||||
|
|
||||||
|
> "`.claude/skills/senior-qa/references/testing_strategies.md` pattern'lerini kullanarak `LoginForm` için unit test yaz."
|
||||||
|
|
||||||
|
### Code Review
|
||||||
|
|
||||||
|
> "`.claude/skills/senior-frontend/` best practice'lerine göre `src/components/auth/` klasörünü review et."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Backend Projesi:** `../typescript-boilerplate-be/prompt.md`
|
||||||
BIN
public/.DS_Store
vendored
Normal file
BIN
public/assets/.DS_Store
vendored
Normal file
BIN
public/assets/img/sign-in-image.png
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
public/assets/img/sign-up-image.png
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
public/favicon/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/favicon/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
public/favicon/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 558 B |
BIN
public/favicon/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
19
public/favicon/site.webmanifest
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"short_name": "",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/favicon/android-chrome-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/favicon/android-chrome-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
BIN
src.tar.gz
Normal file
BIN
src/.DS_Store
vendored
Normal file
BIN
src/app/.DS_Store
vendored
Normal file
15
src/app/[locale]/(auth)/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Footer from '@/components/layout/footer/footer';
|
||||||
|
import { Box, Flex } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Flex minH='100vh' direction='column'>
|
||||||
|
<Box as='main'>{children}</Box>
|
||||||
|
<Footer />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthLayout;
|
||||||
231
src/app/[locale]/(auth)/signin/page.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
Input,
|
||||||
|
Link as ChakraLink,
|
||||||
|
Text,
|
||||||
|
ClientOnly,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { Button } from "@/components/ui/buttons/button";
|
||||||
|
import { Switch } from "@/components/ui/forms/switch";
|
||||||
|
import { Field } from "@/components/ui/forms/field";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import signInImage from "/public/assets/img/sign-in-image.png";
|
||||||
|
import { InputGroup } from "@/components/ui/forms/input-group";
|
||||||
|
import { BiLock } from "react-icons/bi";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
import * as yup from "yup";
|
||||||
|
import { Link, useRouter } from "@/i18n/navigation";
|
||||||
|
import { MdMail } from "react-icons/md";
|
||||||
|
import { PasswordInput } from "@/components/ui/forms/password-input";
|
||||||
|
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { toaster } from "@/components/ui/feedback/toaster";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const schema = yup.object({
|
||||||
|
email: yup.string().email().required(),
|
||||||
|
password: yup.string().required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SignInForm = yup.InferType<typeof schema>;
|
||||||
|
|
||||||
|
const defaultValues = {
|
||||||
|
email: "test@test.com.tr",
|
||||||
|
password: "test1234",
|
||||||
|
};
|
||||||
|
|
||||||
|
function SignInPage() {
|
||||||
|
const t = useTranslations();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<SignInForm>({
|
||||||
|
resolver: yupResolver(schema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (formData: SignInForm) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await signIn("credentials", {
|
||||||
|
redirect: false,
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res?.error) {
|
||||||
|
throw new Error(res.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace("/home");
|
||||||
|
} catch (error) {
|
||||||
|
toaster.error({
|
||||||
|
title: (error as Error).message || "Giriş yaparken hata oluştu!",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box position="relative">
|
||||||
|
<Flex
|
||||||
|
h={{ sm: "initial", md: "75vh", lg: "85vh" }}
|
||||||
|
w="100%"
|
||||||
|
maxW="1044px"
|
||||||
|
mx="auto"
|
||||||
|
justifyContent="space-between"
|
||||||
|
mb="30px"
|
||||||
|
pt={{ sm: "100px", md: "0px" }}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="start"
|
||||||
|
style={{ userSelect: "none" }}
|
||||||
|
w={{ base: "100%", md: "50%", lg: "42%" }}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
w="100%"
|
||||||
|
background="transparent"
|
||||||
|
p="10"
|
||||||
|
mt={{ md: "150px", lg: "80px" }}
|
||||||
|
>
|
||||||
|
<Heading
|
||||||
|
color={{ base: "primary.400", _dark: "primary.200" }}
|
||||||
|
fontSize="32px"
|
||||||
|
mb="10px"
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{t("auth.welcome-back")}
|
||||||
|
</Heading>
|
||||||
|
<Text
|
||||||
|
mb="36px"
|
||||||
|
ms="4px"
|
||||||
|
color={{ base: "gray.400", _dark: "white" }}
|
||||||
|
fontWeight="bold"
|
||||||
|
fontSize="14px"
|
||||||
|
>
|
||||||
|
{t("auth.subtitle")}
|
||||||
|
</Text>
|
||||||
|
<Field
|
||||||
|
mb="24px"
|
||||||
|
label={t("email")}
|
||||||
|
errorText={errors.email?.message}
|
||||||
|
invalid={!!errors.email}
|
||||||
|
>
|
||||||
|
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
|
||||||
|
<Input
|
||||||
|
borderRadius="15px"
|
||||||
|
fontSize="sm"
|
||||||
|
type="text"
|
||||||
|
placeholder={t("email")}
|
||||||
|
size="lg"
|
||||||
|
{...register("email")}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
mb="24px"
|
||||||
|
label={t("password")}
|
||||||
|
errorText={errors.password?.message}
|
||||||
|
invalid={!!errors.password}
|
||||||
|
>
|
||||||
|
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
|
||||||
|
<PasswordInput
|
||||||
|
borderRadius="15px"
|
||||||
|
fontSize="sm"
|
||||||
|
placeholder={t("password")}
|
||||||
|
size="lg"
|
||||||
|
{...register("password")}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
<Field mb="24px">
|
||||||
|
<Switch colorPalette="teal" label={t("auth.remember-me")}>
|
||||||
|
{t("auth.remember-me")}
|
||||||
|
</Switch>
|
||||||
|
</Field>
|
||||||
|
<Field mb="24px">
|
||||||
|
<ClientOnly fallback={<Skeleton height="45px" width="100%" />}>
|
||||||
|
<Button
|
||||||
|
loading={loading}
|
||||||
|
type="submit"
|
||||||
|
bg="primary.400"
|
||||||
|
w="100%"
|
||||||
|
h="45px"
|
||||||
|
color="white"
|
||||||
|
_hover={{
|
||||||
|
bg: "primary.500",
|
||||||
|
}}
|
||||||
|
_active={{
|
||||||
|
bg: "primary.400",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("auth.sign-in")}
|
||||||
|
</Button>
|
||||||
|
</ClientOnly>
|
||||||
|
</Field>
|
||||||
|
<Flex
|
||||||
|
flexDirection="column"
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
maxW="100%"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
color={{ base: "gray.400", _dark: "white" }}
|
||||||
|
fontWeight="medium"
|
||||||
|
>
|
||||||
|
{t("auth.dont-have-account")}
|
||||||
|
<ChakraLink
|
||||||
|
as={Link}
|
||||||
|
href="/signup"
|
||||||
|
color={{ base: "primary.400", _dark: "primary.200" }}
|
||||||
|
ms="5px"
|
||||||
|
fontWeight="bold"
|
||||||
|
focusRing="none"
|
||||||
|
>
|
||||||
|
{t("auth.sign-up")}
|
||||||
|
</ChakraLink>
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
<Box
|
||||||
|
display={{ base: "none", md: "block" }}
|
||||||
|
overflowX="hidden"
|
||||||
|
h="100%"
|
||||||
|
w="40vw"
|
||||||
|
position="absolute"
|
||||||
|
right="0px"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
bgImage={`url(${signInImage.src})`}
|
||||||
|
w="100%"
|
||||||
|
h="100%"
|
||||||
|
bgSize="cover"
|
||||||
|
bgPos="50%"
|
||||||
|
position="absolute"
|
||||||
|
borderBottomLeftRadius="20px"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SignInPage;
|
||||||
199
src/app/[locale]/(auth)/signup/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Box, Flex, Input, Link as ChakraLink, Text, ClientOnly } from '@chakra-ui/react';
|
||||||
|
import signUpImage from '/public/assets/img/sign-up-image.png';
|
||||||
|
import { Button } from '@/components/ui/buttons/button';
|
||||||
|
import { Field } from '@/components/ui/forms/field';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import { InputGroup } from '@/components/ui/forms/input-group';
|
||||||
|
import { BiLock, BiUser } from 'react-icons/bi';
|
||||||
|
import { Link } from '@/i18n/navigation';
|
||||||
|
import { MdMail } from 'react-icons/md';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { PasswordInput } from '@/components/ui/forms/password-input';
|
||||||
|
import { Skeleton } from '@/components/ui/feedback/skeleton';
|
||||||
|
import { useRegister } from '@/lib/api/example/auth/use-hooks';
|
||||||
|
import { toaster } from '@/components/ui/feedback/toaster';
|
||||||
|
|
||||||
|
const schema = yup.object({
|
||||||
|
name: yup.string().required(),
|
||||||
|
email: yup.string().email().required(),
|
||||||
|
password: yup.string().min(8, 'Password must be at least 8 characters').required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SignUpForm = yup.InferType<typeof schema>;
|
||||||
|
|
||||||
|
function SignUpPage() {
|
||||||
|
const t = useTranslations();
|
||||||
|
const router = useRouter();
|
||||||
|
const register = useRegister();
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
register: formRegister,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<SignUpForm>({ resolver: yupResolver(schema), mode: 'onChange' });
|
||||||
|
|
||||||
|
const onSubmit = async (formData: SignUpForm) => {
|
||||||
|
try {
|
||||||
|
// Split name into first and last name
|
||||||
|
const names = formData.name.trim().split(' ');
|
||||||
|
const firstName = names[0];
|
||||||
|
const lastName = names.length > 1 ? names.slice(1).join(' ') : '';
|
||||||
|
|
||||||
|
await register.mutateAsync({
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
});
|
||||||
|
|
||||||
|
toaster.create({
|
||||||
|
title: t('auth.account-created'),
|
||||||
|
description: t('auth.please-sign-in'),
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
|
||||||
|
router.replace('/signin');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Registration error:', error);
|
||||||
|
toaster.create({
|
||||||
|
title: t('common.error'),
|
||||||
|
description: error.message || t('auth.registration-failed'),
|
||||||
|
type: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box
|
||||||
|
position='absolute'
|
||||||
|
minH={{ base: '70vh', md: '50vh' }}
|
||||||
|
w={{ md: 'calc(100vw - 50px)' }}
|
||||||
|
borderRadius={{ md: '15px' }}
|
||||||
|
left='0'
|
||||||
|
right='0'
|
||||||
|
bgRepeat='no-repeat'
|
||||||
|
overflow='hidden'
|
||||||
|
zIndex='-1'
|
||||||
|
top='0'
|
||||||
|
bgImage={`url(${signUpImage.src})`}
|
||||||
|
bgSize='cover'
|
||||||
|
mx={{ md: 'auto' }}
|
||||||
|
mt={{ md: '14px' }}
|
||||||
|
/>
|
||||||
|
<Flex w='full' h='full' direction='column' alignItems='center' justifyContent='center'>
|
||||||
|
<Text
|
||||||
|
fontSize={{ base: '2xl', md: '3xl', lg: '4xl' }}
|
||||||
|
color='white'
|
||||||
|
fontWeight='bold'
|
||||||
|
mt={{ base: '2rem', md: '4.5rem', '2xl': '6.5rem' }}
|
||||||
|
mb={{ base: '2rem', md: '3rem', '2xl': '4rem' }}
|
||||||
|
>
|
||||||
|
{t('auth.create-an-account-now')}
|
||||||
|
</Text>
|
||||||
|
<Flex
|
||||||
|
direction='column'
|
||||||
|
w={{ base: '100%', md: '445px' }}
|
||||||
|
background='transparent'
|
||||||
|
borderRadius='15px'
|
||||||
|
p='10'
|
||||||
|
mx={{ base: '100px' }}
|
||||||
|
bg='bg.panel'
|
||||||
|
boxShadow='0 20px 27px 0 rgb(0 0 0 / 5%)'
|
||||||
|
mb='8'
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
as='form'
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
flexDirection='column'
|
||||||
|
alignItems='center'
|
||||||
|
justifyContent='start'
|
||||||
|
w='100%'
|
||||||
|
>
|
||||||
|
<Field mb='24px' label={t('name')} errorText={errors.name?.message} invalid={!!errors.name}>
|
||||||
|
<InputGroup w='full' startElement={<BiUser size='1rem' />}>
|
||||||
|
<Input
|
||||||
|
borderRadius='15px'
|
||||||
|
fontSize='sm'
|
||||||
|
type='text'
|
||||||
|
placeholder={t('name')}
|
||||||
|
size='lg'
|
||||||
|
disabled={register.isPending}
|
||||||
|
{...formRegister('name')}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
<Field mb='24px' label={t('email')} errorText={errors.email?.message} invalid={!!errors.email}>
|
||||||
|
<InputGroup w='full' startElement={<MdMail size='1rem' />}>
|
||||||
|
<Input
|
||||||
|
borderRadius='15px'
|
||||||
|
fontSize='sm'
|
||||||
|
type='text'
|
||||||
|
placeholder={t('email')}
|
||||||
|
size='lg'
|
||||||
|
disabled={register.isPending}
|
||||||
|
{...formRegister('email')}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
<Field mb='24px' label={t('password')} errorText={errors.password?.message} invalid={!!errors.password}>
|
||||||
|
<InputGroup w='full' startElement={<BiLock size='1rem' />}>
|
||||||
|
<PasswordInput
|
||||||
|
borderRadius='15px'
|
||||||
|
fontSize='sm'
|
||||||
|
placeholder={t('password')}
|
||||||
|
size='lg'
|
||||||
|
disabled={register.isPending}
|
||||||
|
{...formRegister('password')}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
<Field mb='24px'>
|
||||||
|
<ClientOnly fallback={<Skeleton height='45px' width='100%' />}>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
bg='primary.400'
|
||||||
|
color='white'
|
||||||
|
fontWeight='bold'
|
||||||
|
w='100%'
|
||||||
|
h='45px'
|
||||||
|
loading={register.isPending}
|
||||||
|
_hover={{
|
||||||
|
bg: 'primary.500',
|
||||||
|
}}
|
||||||
|
_active={{
|
||||||
|
bg: 'primary.400',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('auth.sign-up')}
|
||||||
|
</Button>
|
||||||
|
</ClientOnly>
|
||||||
|
</Field>
|
||||||
|
</Flex>
|
||||||
|
<Flex flexDirection='column' justifyContent='center' alignItems='center' maxW='100%'>
|
||||||
|
<Text color={{ base: 'gray.400', _dark: 'white' }} fontWeight='medium'>
|
||||||
|
{t('auth.already-have-an-account')}
|
||||||
|
<ChakraLink
|
||||||
|
as={Link}
|
||||||
|
color={{ base: 'primary.400', _dark: 'primary.200' }}
|
||||||
|
ml='2'
|
||||||
|
href='/signin'
|
||||||
|
fontWeight='bold'
|
||||||
|
focusRing='none'
|
||||||
|
>
|
||||||
|
{t('auth.sign-in')}
|
||||||
|
</ChakraLink>
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SignUpPage;
|
||||||
5
src/app/[locale]/(error)/[...slug]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function CatchAllPage() {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
7
src/app/[locale]/(site)/about/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
function AboutPage() {
|
||||||
|
return <div>AboutPage</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AboutPage;
|
||||||
14
src/app/[locale]/(site)/home/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import HomeCard from "@/components/site/home/home-card";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${t("home")} | FCS`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <HomeCard />;
|
||||||
|
}
|
||||||
21
src/app/[locale]/(site)/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Container, Flex } from '@chakra-ui/react';
|
||||||
|
import Header from '@/components/layout/header/header';
|
||||||
|
import Footer from '@/components/layout/footer/footer';
|
||||||
|
import BackToTop from '@/components/ui/back-to-top';
|
||||||
|
|
||||||
|
function MainLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Flex minH='100vh' direction='column'>
|
||||||
|
<Header />
|
||||||
|
<Container as='main' maxW='8xl' flex='1' py={4}>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
<BackToTop />
|
||||||
|
<Footer />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MainLayout;
|
||||||
15
src/app/[locale]/(site)/skriptai/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Metadata } from 'next';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
import SkriptAIDashboard from '@/components/skriptai/SkriptAIDashboard';
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations('skriptai');
|
||||||
|
return {
|
||||||
|
title: t('pageTitle'),
|
||||||
|
description: t('pageDescription'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SkriptAIPage() {
|
||||||
|
return <SkriptAIDashboard />;
|
||||||
|
}
|
||||||
19
src/app/[locale]/(site)/skriptai/projects/[id]/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import ProjectDetail from '@/components/skriptai/ProjectDetail';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string; locale: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const t = await getTranslations('skriptai');
|
||||||
|
return {
|
||||||
|
title: t('projectDetail'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProjectPage({ params }: Props) {
|
||||||
|
const { id } = await params;
|
||||||
|
return <ProjectDetail projectId={id} />;
|
||||||
|
}
|
||||||
BIN
src/app/[locale]/.DS_Store
vendored
Normal file
7
src/app/[locale]/global.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
46
src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Provider } from '@/components/ui/provider';
|
||||||
|
import { Bricolage_Grotesque } from 'next/font/google';
|
||||||
|
import { hasLocale, NextIntlClientProvider } from 'next-intl';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { routing } from '@/i18n/routing';
|
||||||
|
import { dir } from 'i18next';
|
||||||
|
import './global.css';
|
||||||
|
|
||||||
|
const bricolage = Bricolage_Grotesque({
|
||||||
|
variable: '--font-bricolage',
|
||||||
|
subsets: ['latin'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
console.log('DEBUG: RootLayout locale:', locale);
|
||||||
|
console.log('DEBUG: routing.locales:', routing.locales);
|
||||||
|
// Temporary bypass for debugging
|
||||||
|
// if (!hasLocale(routing.locales, locale)) {
|
||||||
|
if (!routing.locales.includes(locale as any)) {
|
||||||
|
console.log('DEBUG: Invalid locale, calling notFound()');
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={locale} dir={dir(locale)} suppressHydrationWarning data-scroll-behavior='smooth'>
|
||||||
|
<head>
|
||||||
|
<link rel='apple-touch-icon' sizes='180x180' href='/favicon/apple-touch-icon.png' />
|
||||||
|
<link rel='icon' type='image/png' sizes='32x32' href='/favicon/favicon-32x32.png' />
|
||||||
|
<link rel='icon' type='image/png' sizes='16x16' href='/favicon/favicon-16x16.png' />
|
||||||
|
<link rel='manifest' href='/favicon/site.webmanifest' />
|
||||||
|
</head>
|
||||||
|
<body className={bricolage.variable}>
|
||||||
|
<NextIntlClientProvider>
|
||||||
|
<Provider>{children}</Provider>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/app/[locale]/not-found.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Link } from '@/i18n/navigation';
|
||||||
|
import { Flex, Text, Button, VStack, Heading } from '@chakra-ui/react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
export default async function NotFoundPage() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex h='100vh' alignItems='center' justifyContent='center' textAlign='center' px={6}>
|
||||||
|
<VStack spaceY={6}>
|
||||||
|
<Heading
|
||||||
|
as='h1'
|
||||||
|
fontSize={{ base: '5xl', md: '6xl' }}
|
||||||
|
fontWeight='bold'
|
||||||
|
color={{ base: 'primary.600', _dark: 'primary.400' }}
|
||||||
|
>
|
||||||
|
{t('error.404')}
|
||||||
|
</Heading>
|
||||||
|
<Text fontSize={{ base: 'md', md: 'lg' }} color={{ base: 'fg.muted', _dark: 'white' }}>
|
||||||
|
{t('error.not-found')}
|
||||||
|
</Text>
|
||||||
|
<Link href='/home' passHref>
|
||||||
|
<Button size={{ base: 'md', md: 'lg' }} rounded='md'>
|
||||||
|
{t('error.back-to-home')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</VStack>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/[locale]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
redirect('/home');
|
||||||
|
}
|
||||||
BIN
src/app/api/.DS_Store
vendored
Normal file
94
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import baseUrl from "@/config/base-url";
|
||||||
|
import { authService } from "@/lib/api/example/auth/service";
|
||||||
|
import NextAuth from "next-auth";
|
||||||
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
|
||||||
|
function randomToken() {
|
||||||
|
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMockMode = process.env.NEXT_PUBLIC_ENABLE_MOCK_MODE === "true";
|
||||||
|
|
||||||
|
const handler = NextAuth({
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
name: "Credentials",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "text" },
|
||||||
|
password: { label: "Password", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
console.log("credentials", credentials);
|
||||||
|
if (!credentials?.email || !credentials?.password) {
|
||||||
|
throw new Error("Email ve şifre gereklidir.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eğer mock mod aktifse backend'e gitme
|
||||||
|
if (isMockMode) {
|
||||||
|
return {
|
||||||
|
id: credentials.email,
|
||||||
|
name: credentials.email.split("@")[0],
|
||||||
|
email: credentials.email,
|
||||||
|
accessToken: randomToken(),
|
||||||
|
refreshToken: randomToken(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal mod: backend'e istek at
|
||||||
|
const res = await authService.login({
|
||||||
|
email: credentials.email,
|
||||||
|
password: credentials.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("res", res);
|
||||||
|
|
||||||
|
const response = res;
|
||||||
|
|
||||||
|
// Backend returns ApiResponse<TokenResponseDto>
|
||||||
|
// Structure: { data: { accessToken, refreshToken, expiresIn, user }, message, statusCode }
|
||||||
|
if (!res.success || !response?.data?.accessToken) {
|
||||||
|
throw new Error(response?.message || "Giriş başarısız");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { accessToken, refreshToken, user } = response.data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.firstName
|
||||||
|
? `${user.firstName} ${user.lastName || ""}`.trim()
|
||||||
|
: user.email.split("@")[0],
|
||||||
|
email: user.email,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
roles: user.roles || [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }: any) {
|
||||||
|
if (user) {
|
||||||
|
token.accessToken = user.accessToken;
|
||||||
|
token.refreshToken = user.refreshToken;
|
||||||
|
token.id = user.id;
|
||||||
|
token.roles = user.roles;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }: any) {
|
||||||
|
session.user.id = token.id;
|
||||||
|
session.user.roles = token.roles;
|
||||||
|
session.accessToken = token.accessToken;
|
||||||
|
session.refreshToken = token.refreshToken;
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: "/signin",
|
||||||
|
error: "/signin",
|
||||||
|
},
|
||||||
|
session: { strategy: "jwt" },
|
||||||
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
|
});
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
93
src/app/api/backend/skriptai/[...path]/route.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Proxy for SkriptAI Backend
|
||||||
|
*
|
||||||
|
* This catch-all route forwards requests to the backend API.
|
||||||
|
* Authentication is handled by the client-side API client which
|
||||||
|
* adds the Bearer token to requests.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> },
|
||||||
|
) {
|
||||||
|
return handleRequest(request, params, 'GET');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> },
|
||||||
|
) {
|
||||||
|
return handleRequest(request, params, 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> },
|
||||||
|
) {
|
||||||
|
return handleRequest(request, params, 'PUT');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> },
|
||||||
|
) {
|
||||||
|
return handleRequest(request, params, 'DELETE');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRequest(
|
||||||
|
request: NextRequest,
|
||||||
|
params: Promise<{ path: string[] }>,
|
||||||
|
method: string,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { path } = await params;
|
||||||
|
const pathString = path?.join('/') || '';
|
||||||
|
|
||||||
|
// Build backend URL
|
||||||
|
const backendUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
const url = `${backendUrl}/skriptai/${pathString}`;
|
||||||
|
|
||||||
|
// Forward authorization header if present
|
||||||
|
const authHeader = request.headers.get('authorization');
|
||||||
|
|
||||||
|
// Build headers
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept-Language': request.headers.get('accept-language') || 'tr',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authHeader) {
|
||||||
|
headers['Authorization'] = authHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build fetch options
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add body for POST/PUT
|
||||||
|
if (method === 'POST' || method === 'PUT') {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
fetchOptions.body = JSON.stringify(body);
|
||||||
|
} catch {
|
||||||
|
// No body or invalid JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward request to backend
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
|
||||||
|
// Return response
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Proxy Error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/components/.DS_Store
vendored
Normal file
154
src/components/auth/login-modal.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Box, Heading, Input, Text, VStack } from "@chakra-ui/react";
|
||||||
|
import { Button } from "@/components/ui/buttons/button";
|
||||||
|
import { Field } from "@/components/ui/forms/field";
|
||||||
|
import { InputGroup } from "@/components/ui/forms/input-group";
|
||||||
|
import { PasswordInput } from "@/components/ui/forms/password-input";
|
||||||
|
import {
|
||||||
|
DialogBody,
|
||||||
|
DialogCloseTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogRoot,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/overlays/dialog";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
import * as yup from "yup";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { toaster } from "@/components/ui/feedback/toaster";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { MdMail } from "react-icons/md";
|
||||||
|
import { BiLock } from "react-icons/bi";
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
|
|
||||||
|
const schema = yup.object({
|
||||||
|
email: yup.string().email().required(),
|
||||||
|
password: yup.string().min(6).required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LoginForm = yup.InferType<typeof schema>;
|
||||||
|
|
||||||
|
interface LoginModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoginForm>({
|
||||||
|
resolver: yupResolver(schema),
|
||||||
|
mode: "onChange",
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (formData: LoginForm) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await signIn("credentials", {
|
||||||
|
redirect: false,
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res?.error) {
|
||||||
|
throw new Error(res.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
toaster.success({
|
||||||
|
title: t("auth.login-success") || "Login successful!",
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toaster.error({
|
||||||
|
title: (error as Error).message || "Login failed!",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogRoot open={open} onOpenChange={(e) => onOpenChange(e.open)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Heading size="lg" color="primary.500">
|
||||||
|
{t("auth.sign-in")}
|
||||||
|
</Heading>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogCloseTrigger />
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody>
|
||||||
|
<Box as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Field
|
||||||
|
label={t("email")}
|
||||||
|
errorText={errors.email?.message}
|
||||||
|
invalid={!!errors.email}
|
||||||
|
>
|
||||||
|
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
|
||||||
|
<Input
|
||||||
|
borderRadius="md"
|
||||||
|
fontSize="sm"
|
||||||
|
type="text"
|
||||||
|
placeholder={t("email")}
|
||||||
|
{...register("email")}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("password")}
|
||||||
|
errorText={errors.password?.message}
|
||||||
|
invalid={!!errors.password}
|
||||||
|
>
|
||||||
|
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
|
||||||
|
<PasswordInput
|
||||||
|
borderRadius="md"
|
||||||
|
fontSize="sm"
|
||||||
|
placeholder={t("password")}
|
||||||
|
{...register("password")}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
loading={loading}
|
||||||
|
type="submit"
|
||||||
|
bg="primary.400"
|
||||||
|
w="100%"
|
||||||
|
color="white"
|
||||||
|
_hover={{ bg: "primary.500" }}
|
||||||
|
>
|
||||||
|
{t("auth.sign-in")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Text fontSize="sm" color="fg.muted">
|
||||||
|
{t("auth.dont-have-account")}{" "}
|
||||||
|
<Link
|
||||||
|
href="/signup"
|
||||||
|
style={{
|
||||||
|
color: "var(--chakra-colors-primary-500)",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("auth.sign-up")}
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/components/layout/footer/footer.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Box, Text, HStack, Link as ChakraLink } from "@chakra-ui/react";
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box as="footer" bg="bg.muted" mt="auto">
|
||||||
|
<HStack
|
||||||
|
display="flex"
|
||||||
|
justify={{ base: "center", md: "space-between" }}
|
||||||
|
alignContent="center"
|
||||||
|
maxW="8xl"
|
||||||
|
mx="auto"
|
||||||
|
wrap="wrap"
|
||||||
|
px={{ base: 4, md: 8 }}
|
||||||
|
position="relative"
|
||||||
|
minH="16"
|
||||||
|
>
|
||||||
|
<Text fontSize="sm" color="fg.muted">
|
||||||
|
© {new Date().getFullYear()}
|
||||||
|
<ChakraLink
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
href="https://www.fcs.com.tr"
|
||||||
|
color={{ base: "primary.500", _dark: "primary.300" }}
|
||||||
|
focusRing="none"
|
||||||
|
ml="1"
|
||||||
|
>
|
||||||
|
{"FCS"}
|
||||||
|
</ChakraLink>
|
||||||
|
. {t("all-right-reserved")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<HStack spaceX={4}>
|
||||||
|
<ChakraLink
|
||||||
|
as={Link}
|
||||||
|
href="/privacy"
|
||||||
|
fontSize="sm"
|
||||||
|
color="fg.muted"
|
||||||
|
focusRing="none"
|
||||||
|
position="relative"
|
||||||
|
textDecor="none"
|
||||||
|
transition="color 0.3s ease-in-out"
|
||||||
|
_hover={{
|
||||||
|
color: { base: "primary.500", _dark: "primary.300" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("privacy-policy")}
|
||||||
|
</ChakraLink>
|
||||||
|
<ChakraLink
|
||||||
|
as={Link}
|
||||||
|
href="/terms"
|
||||||
|
fontSize="sm"
|
||||||
|
color="fg.muted"
|
||||||
|
focusRing="none"
|
||||||
|
position="relative"
|
||||||
|
textDecor="none"
|
||||||
|
transition="color 0.3s ease-in-out"
|
||||||
|
_hover={{
|
||||||
|
color: { base: "primary.500", _dark: "primary.300" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("terms-of-service")}
|
||||||
|
</ChakraLink>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
src/components/layout/header/header-link.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Box, Link as ChakraLink, Text } from '@chakra-ui/react';
|
||||||
|
import { NavItem } from '@/config/navigation';
|
||||||
|
import { MenuContent, MenuItem, MenuRoot, MenuTrigger } from '@/components/ui/overlays/menu';
|
||||||
|
import { RxChevronDown } from 'react-icons/rx';
|
||||||
|
import { useActiveNavItem } from '@/hooks/useActiveNavItem';
|
||||||
|
import { Link } from '@/i18n/navigation';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
function HeaderLink({ item }: { item: NavItem }) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { isActive, isChildActive } = useActiveNavItem(item);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const handleMouseOpen = () => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseClose = () => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = setTimeout(() => setOpen(false), 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={item.label}>
|
||||||
|
{item.children ? (
|
||||||
|
<Box onMouseEnter={handleMouseOpen} onMouseLeave={handleMouseClose}>
|
||||||
|
<MenuRoot open={open} onOpenChange={(e) => setOpen(e.open)}>
|
||||||
|
<MenuTrigger asChild>
|
||||||
|
<Text
|
||||||
|
display='inline-flex'
|
||||||
|
alignItems='center'
|
||||||
|
gap='1'
|
||||||
|
cursor='pointer'
|
||||||
|
color={isActive ? { base: 'primary.500', _dark: 'primary.300' } : 'fg.muted'}
|
||||||
|
position='relative'
|
||||||
|
fontWeight='semibold'
|
||||||
|
>
|
||||||
|
{t(item.label)}
|
||||||
|
<RxChevronDown
|
||||||
|
style={{ transform: open ? 'rotate(-180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
</MenuTrigger>
|
||||||
|
<MenuContent>
|
||||||
|
{item.children.map((child, index) => {
|
||||||
|
const isActiveChild = isChildActive(child.href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem key={index} value={child.href}>
|
||||||
|
<ChakraLink
|
||||||
|
key={index}
|
||||||
|
as={Link}
|
||||||
|
href={child.href}
|
||||||
|
focusRing='none'
|
||||||
|
w='full'
|
||||||
|
color={isActiveChild ? { base: 'primary.500', _dark: 'primary.300' } : 'fg.muted'}
|
||||||
|
position='relative'
|
||||||
|
textDecor='none'
|
||||||
|
fontWeight='semibold'
|
||||||
|
>
|
||||||
|
{t(child.label)}
|
||||||
|
</ChakraLink>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</MenuContent>
|
||||||
|
</MenuRoot>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<ChakraLink
|
||||||
|
as={Link}
|
||||||
|
href={item.href}
|
||||||
|
focusRing='none'
|
||||||
|
color={isActive ? { base: 'primary.500', _dark: 'primary.300' } : 'fg.muted'}
|
||||||
|
position='relative'
|
||||||
|
textDecor='none'
|
||||||
|
fontWeight='semibold'
|
||||||
|
_after={{
|
||||||
|
content: "''",
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
bottom: '-2px',
|
||||||
|
width: '0%',
|
||||||
|
height: '1.5px',
|
||||||
|
bg: { base: 'primary.500', _dark: 'primary.300' },
|
||||||
|
transition: 'width 0.3s ease-in-out',
|
||||||
|
}}
|
||||||
|
_hover={{
|
||||||
|
_after: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(item.label)}
|
||||||
|
</ChakraLink>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HeaderLink;
|
||||||
229
src/components/layout/header/header.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Flex,
|
||||||
|
HStack,
|
||||||
|
IconButton,
|
||||||
|
Link as ChakraLink,
|
||||||
|
Stack,
|
||||||
|
VStack,
|
||||||
|
Button,
|
||||||
|
MenuItem,
|
||||||
|
ClientOnly,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { Link, useRouter } from "@/i18n/navigation";
|
||||||
|
import { ColorModeButton } from "@/components/ui/color-mode";
|
||||||
|
import {
|
||||||
|
PopoverBody,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverRoot,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/overlays/popover";
|
||||||
|
import { RxHamburgerMenu } from "react-icons/rx";
|
||||||
|
import { NAV_ITEMS } from "@/config/navigation";
|
||||||
|
import HeaderLink from "./header-link";
|
||||||
|
import MobileHeaderLink from "./mobile-header-link";
|
||||||
|
import LocaleSwitcher from "@/components/ui/locale-switcher";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
MenuContent,
|
||||||
|
MenuRoot,
|
||||||
|
MenuTrigger,
|
||||||
|
} from "@/components/ui/overlays/menu";
|
||||||
|
import { Avatar } from "@/components/ui/data-display/avatar";
|
||||||
|
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
||||||
|
import { signOut, useSession } from "next-auth/react";
|
||||||
|
import { authConfig } from "@/config/auth";
|
||||||
|
import { LoginModal } from "@/components/auth/login-modal";
|
||||||
|
import { LuLogIn } from "react-icons/lu";
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [isSticky, setIsSticky] = useState(false);
|
||||||
|
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
|
||||||
|
const isAuthenticated = !!session;
|
||||||
|
const isLoading = status === "loading";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
setIsSticky(window.scrollY >= 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("scroll", handleScroll);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", handleScroll);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await signOut({ redirect: false });
|
||||||
|
if (authConfig.isAuthRequired) {
|
||||||
|
router.replace("/signin");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render user menu or login button based on auth state
|
||||||
|
const renderAuthSection = () => {
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton boxSize="10" rounded="full" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<MenuRoot positioning={{ placement: "bottom-start" }}>
|
||||||
|
<MenuTrigger rounded="full" focusRing="none">
|
||||||
|
<Avatar name={session?.user?.name || "User"} variant="solid" />
|
||||||
|
</MenuTrigger>
|
||||||
|
<MenuContent>
|
||||||
|
<MenuItem onClick={handleLogout} value="sign-out">
|
||||||
|
{t("auth.sign-out")}
|
||||||
|
</MenuItem>
|
||||||
|
</MenuContent>
|
||||||
|
</MenuRoot>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not authenticated - show login button
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
colorPalette="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLoginModalOpen(true)}
|
||||||
|
>
|
||||||
|
<LuLogIn />
|
||||||
|
{t("auth.sign-in")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render mobile auth section
|
||||||
|
const renderMobileAuthSection = () => {
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton height="10" width="full" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Avatar name={session?.user?.name || "User"} variant="solid" />
|
||||||
|
<Button
|
||||||
|
variant="surface"
|
||||||
|
size="sm"
|
||||||
|
width="full"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
{t("auth.sign-out")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
colorPalette="primary"
|
||||||
|
size="sm"
|
||||||
|
width="full"
|
||||||
|
onClick={() => setLoginModalOpen(true)}
|
||||||
|
>
|
||||||
|
<LuLogIn />
|
||||||
|
{t("auth.sign-in")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
as="nav"
|
||||||
|
bg={isSticky ? "rgba(255, 255, 255, 0.6)" : "white"}
|
||||||
|
_dark={{
|
||||||
|
bg: isSticky ? "rgba(1, 1, 1, 0.6)" : "black",
|
||||||
|
}}
|
||||||
|
shadow={isSticky ? "sm" : "none"}
|
||||||
|
backdropFilter="blur(12px) saturate(180%)"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={isSticky ? "whiteAlpha.300" : "transparent"}
|
||||||
|
borderBottomRadius={isSticky ? "xl" : "none"}
|
||||||
|
transition="all 0.4s ease-in-out"
|
||||||
|
px={{ base: 4, md: 8 }}
|
||||||
|
py="3"
|
||||||
|
position="sticky"
|
||||||
|
top={0}
|
||||||
|
zIndex={10}
|
||||||
|
w="full"
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="center" maxW="8xl" mx="auto">
|
||||||
|
{/* Logo */}
|
||||||
|
<HStack>
|
||||||
|
<ChakraLink
|
||||||
|
as={Link}
|
||||||
|
href="/home"
|
||||||
|
fontSize="lg"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={{ base: "primary.500", _dark: "primary.300" }}
|
||||||
|
focusRing="none"
|
||||||
|
textDecor="none"
|
||||||
|
transition="all 0.3s ease-in-out"
|
||||||
|
_hover={{
|
||||||
|
color: { base: "primary.900", _dark: "primary.50" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{"FCS "}
|
||||||
|
</ChakraLink>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* DESKTOP NAVIGATION */}
|
||||||
|
<HStack spaceX={4} display={{ base: "none", lg: "flex" }}>
|
||||||
|
{NAV_ITEMS.map((item, index) => (
|
||||||
|
<HeaderLink key={index} item={item} />
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack>
|
||||||
|
<ColorModeButton colorPalette="gray" />
|
||||||
|
<Box display={{ base: "none", lg: "inline-flex" }} gap={2}>
|
||||||
|
<LocaleSwitcher />
|
||||||
|
<ClientOnly fallback={<Skeleton boxSize="10" rounded="full" />}>
|
||||||
|
{renderAuthSection()}
|
||||||
|
</ClientOnly>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* MOBILE NAVIGATION */}
|
||||||
|
<Stack display={{ base: "inline-flex", lg: "none" }}>
|
||||||
|
<ClientOnly fallback={<Skeleton boxSize="9" />}>
|
||||||
|
<PopoverRoot>
|
||||||
|
<PopoverTrigger as="span">
|
||||||
|
<IconButton aria-label={t("common.open-menu")} variant="ghost">
|
||||||
|
<RxHamburgerMenu />
|
||||||
|
</IconButton>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent width={{ base: "xs", sm: "sm", md: "md" }}>
|
||||||
|
<PopoverBody>
|
||||||
|
<VStack mt="2" align="start" spaceY="2" w="full">
|
||||||
|
{NAV_ITEMS.map((item) => (
|
||||||
|
<MobileHeaderLink key={item.label} item={item} />
|
||||||
|
))}
|
||||||
|
<LocaleSwitcher />
|
||||||
|
{renderMobileAuthSection()}
|
||||||
|
</VStack>
|
||||||
|
</PopoverBody>
|
||||||
|
</PopoverContent>
|
||||||
|
</PopoverRoot>
|
||||||
|
</ClientOnly>
|
||||||
|
</Stack>
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Login Modal */}
|
||||||
|
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/components/layout/header/mobile-header-link.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Text, Box, Link as ChakraLink, useDisclosure, VStack } from '@chakra-ui/react';
|
||||||
|
import { RxChevronDown } from 'react-icons/rx';
|
||||||
|
import { NavItem } from '@/config/navigation';
|
||||||
|
import { useActiveNavItem } from '@/hooks/useActiveNavItem';
|
||||||
|
import { Link } from '@/i18n/navigation';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
function MobileHeaderLink({ item }: { item: NavItem }) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { isActive, isChildActive } = useActiveNavItem(item);
|
||||||
|
const { open, onToggle } = useDisclosure();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={item.label} w='full'>
|
||||||
|
{item.children ? (
|
||||||
|
<VStack align='start' w='full' spaceY={0}>
|
||||||
|
<Text
|
||||||
|
onClick={onToggle}
|
||||||
|
display='inline-flex'
|
||||||
|
alignItems='center'
|
||||||
|
gap='1'
|
||||||
|
cursor='pointer'
|
||||||
|
color={isActive ? { base: 'primary.500', _dark: 'primary.300' } : 'fg.muted'}
|
||||||
|
textUnderlineOffset='4px'
|
||||||
|
textUnderlinePosition='from-font'
|
||||||
|
textDecoration={isActive ? 'underline' : 'none'}
|
||||||
|
fontWeight='semibold'
|
||||||
|
fontSize={{ base: 'md', md: 'lg' }}
|
||||||
|
_hover={{
|
||||||
|
color: { base: 'primary.500', _dark: 'primary.300' },
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(item.label)}
|
||||||
|
<RxChevronDown
|
||||||
|
style={{ transform: open ? 'rotate(-180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
{open && item.children && (
|
||||||
|
<VStack align='start' pl='4' pt='1' pb='2' w='full' spaceY={1}>
|
||||||
|
{item.children.map((child, index) => {
|
||||||
|
const isActiveChild = isChildActive(child.href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChakraLink
|
||||||
|
key={index}
|
||||||
|
as={Link}
|
||||||
|
href={child.href}
|
||||||
|
focusRing='none'
|
||||||
|
color={isActiveChild ? { base: 'primary.500', _dark: 'primary.300' } : 'fg.muted'}
|
||||||
|
textUnderlineOffset='4px'
|
||||||
|
textUnderlinePosition='from-font'
|
||||||
|
textDecoration={isActiveChild ? 'underline' : 'none'}
|
||||||
|
fontWeight='semibold'
|
||||||
|
fontSize={{ base: 'md', md: 'lg' }}
|
||||||
|
>
|
||||||
|
{t(child.label)}
|
||||||
|
</ChakraLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<ChakraLink
|
||||||
|
as={Link}
|
||||||
|
href={item.href}
|
||||||
|
w='full'
|
||||||
|
focusRing='none'
|
||||||
|
color={isActive ? { base: 'primary.500', _dark: 'primary.300' } : 'fg.muted'}
|
||||||
|
textUnderlineOffset='4px'
|
||||||
|
textUnderlinePosition='from-font'
|
||||||
|
textDecoration={isActive ? 'underline' : 'none'}
|
||||||
|
fontWeight='semibold'
|
||||||
|
fontSize={{ base: 'md', md: 'lg' }}
|
||||||
|
>
|
||||||
|
{t(item.label)}
|
||||||
|
</ChakraLink>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileHeaderLink;
|
||||||
3303
src/components/site/home/home-card.tsx
Normal file
336
src/components/skriptai/CreateProjectModal.tsx
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Field,
|
||||||
|
Fieldset,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
Input,
|
||||||
|
Stack,
|
||||||
|
Textarea,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import type {
|
||||||
|
CreateProjectRequest,
|
||||||
|
ContentFormat,
|
||||||
|
TargetAudience,
|
||||||
|
SpeechStyle,
|
||||||
|
} from '@/types/skriptai';
|
||||||
|
|
||||||
|
// Options
|
||||||
|
const CONTENT_FORMATS: ContentFormat[] = [
|
||||||
|
'YouTube Documentary',
|
||||||
|
'YouTube Long Form (Edu/Video Essay)',
|
||||||
|
'YouTube Short / TikTok',
|
||||||
|
'Kids Cartoon (Script & Dialogue)',
|
||||||
|
'Preschool Learning (Slow Paced)',
|
||||||
|
'True Crime Story',
|
||||||
|
'Product Showcase / Ad',
|
||||||
|
'Corporate Presentation',
|
||||||
|
'Newsletter / Blog Post',
|
||||||
|
'News Bulletin / Journalism',
|
||||||
|
];
|
||||||
|
|
||||||
|
const TARGET_AUDIENCES: TargetAudience[] = [
|
||||||
|
'Preschool (0-5 Years)',
|
||||||
|
'Kids (6-12 Years)',
|
||||||
|
'Teenagers (13-17 Years)',
|
||||||
|
'Young Adults (18-24 Years)',
|
||||||
|
'Adults (25-45 Years)',
|
||||||
|
'Seniors (60+ Years)',
|
||||||
|
'Professionals / B2B',
|
||||||
|
'Mature (18+) / Uncensored',
|
||||||
|
'General Audience',
|
||||||
|
];
|
||||||
|
|
||||||
|
const SPEECH_STYLES: SpeechStyle[] = [
|
||||||
|
'Standard / Balanced',
|
||||||
|
'Casual / Conversational',
|
||||||
|
'Street / Slang (Argo)',
|
||||||
|
'Formal / Corporate',
|
||||||
|
'Poetic / Artistic',
|
||||||
|
'Humorous / Witty',
|
||||||
|
'Dramatic / Intense',
|
||||||
|
'Tech-Savvy / Jargon',
|
||||||
|
'Storyteller / Narrator',
|
||||||
|
'Fairy Tale / Masal',
|
||||||
|
'Didactic / Educational',
|
||||||
|
'Dark / Noir / Mystery',
|
||||||
|
'Satirical / Sarcastic',
|
||||||
|
'Motivational / High Energy',
|
||||||
|
];
|
||||||
|
|
||||||
|
const DURATIONS = [
|
||||||
|
{ label: 'Short (1-3 min)', value: 'Short (1-3 min)' },
|
||||||
|
{ label: 'Standard (5-7 min)', value: 'Standard (5-7 min)' },
|
||||||
|
{ label: 'Long (10-15 min)', value: 'Long (10-15 min)' },
|
||||||
|
{ label: 'Deep Dive (20+ min)', value: 'Deep Dive (20+ min)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CreateProjectModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: CreateProjectRequest) => Promise<void>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateProjectModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
isLoading,
|
||||||
|
}: CreateProjectModalProps) {
|
||||||
|
const t = useTranslations('skriptai');
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<CreateProjectRequest>({
|
||||||
|
topic: '',
|
||||||
|
contentType: 'YouTube Documentary',
|
||||||
|
targetAudience: ['Adults (25-45 Years)'],
|
||||||
|
speechStyle: ['Standard / Balanced'],
|
||||||
|
targetDuration: 'Standard (5-7 min)',
|
||||||
|
language: 'tr',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await onSubmit(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleArrayItem = <T extends string>(
|
||||||
|
arr: T[],
|
||||||
|
item: T,
|
||||||
|
setter: (val: T[]) => void,
|
||||||
|
) => {
|
||||||
|
if (arr.includes(item)) {
|
||||||
|
setter(arr.filter((i) => i !== item));
|
||||||
|
} else {
|
||||||
|
setter([...arr, item]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
position='fixed'
|
||||||
|
inset={0}
|
||||||
|
bg='blackAlpha.600'
|
||||||
|
zIndex={1000}
|
||||||
|
display='flex'
|
||||||
|
alignItems='center'
|
||||||
|
justifyContent='center'
|
||||||
|
p={4}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
bg='white'
|
||||||
|
_dark={{ bg: 'gray.800' }}
|
||||||
|
borderRadius='xl'
|
||||||
|
maxW='600px'
|
||||||
|
w='full'
|
||||||
|
maxH='90vh'
|
||||||
|
overflow='auto'
|
||||||
|
p={6}
|
||||||
|
>
|
||||||
|
<Heading size='lg' mb={6}>
|
||||||
|
{t('createProject')}
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<VStack gap={5} align='stretch'>
|
||||||
|
{/* Topic */}
|
||||||
|
<Field.Root required>
|
||||||
|
<Field.Label>{t('topic')}</Field.Label>
|
||||||
|
<Input
|
||||||
|
placeholder={t('topicPlaceholder')}
|
||||||
|
value={formData.topic}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, topic: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field.Root>
|
||||||
|
|
||||||
|
{/* Content Type */}
|
||||||
|
<Field.Root required>
|
||||||
|
<Field.Label>{t('contentType')}</Field.Label>
|
||||||
|
<Flex flexWrap='wrap' gap={2}>
|
||||||
|
{CONTENT_FORMATS.map((format) => (
|
||||||
|
<Button
|
||||||
|
key={format}
|
||||||
|
size='sm'
|
||||||
|
variant={
|
||||||
|
formData.contentType === format ? 'solid' : 'outline'
|
||||||
|
}
|
||||||
|
colorPalette={
|
||||||
|
formData.contentType === format ? 'blue' : 'gray'
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
setFormData({ ...formData, contentType: format })
|
||||||
|
}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
{format}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Field.Root>
|
||||||
|
|
||||||
|
{/* Target Audience */}
|
||||||
|
<Field.Root required>
|
||||||
|
<Field.Label>{t('targetAudience')}</Field.Label>
|
||||||
|
<Text fontSize='xs' color='gray.500' mb={2}>
|
||||||
|
{t('selectMultiple')}
|
||||||
|
</Text>
|
||||||
|
<Flex flexWrap='wrap' gap={2}>
|
||||||
|
{TARGET_AUDIENCES.map((audience) => (
|
||||||
|
<Button
|
||||||
|
key={audience}
|
||||||
|
size='sm'
|
||||||
|
variant={
|
||||||
|
formData.targetAudience.includes(audience)
|
||||||
|
? 'solid'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
colorPalette={
|
||||||
|
formData.targetAudience.includes(audience)
|
||||||
|
? 'green'
|
||||||
|
: 'gray'
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
toggleArrayItem(
|
||||||
|
formData.targetAudience,
|
||||||
|
audience,
|
||||||
|
(val) =>
|
||||||
|
setFormData({ ...formData, targetAudience: val }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
{audience}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Field.Root>
|
||||||
|
|
||||||
|
{/* Speech Style */}
|
||||||
|
<Field.Root required>
|
||||||
|
<Field.Label>{t('speechStyle')}</Field.Label>
|
||||||
|
<Text fontSize='xs' color='gray.500' mb={2}>
|
||||||
|
{t('selectMultiple')}
|
||||||
|
</Text>
|
||||||
|
<Flex flexWrap='wrap' gap={2}>
|
||||||
|
{SPEECH_STYLES.map((style) => (
|
||||||
|
<Button
|
||||||
|
key={style}
|
||||||
|
size='sm'
|
||||||
|
variant={
|
||||||
|
formData.speechStyle.includes(style) ? 'solid' : 'outline'
|
||||||
|
}
|
||||||
|
colorPalette={
|
||||||
|
formData.speechStyle.includes(style) ? 'purple' : 'gray'
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
toggleArrayItem(formData.speechStyle, style, (val) =>
|
||||||
|
setFormData({ ...formData, speechStyle: val }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
{style}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Field.Root>
|
||||||
|
|
||||||
|
{/* Duration */}
|
||||||
|
<Field.Root required>
|
||||||
|
<Field.Label>{t('targetDuration')}</Field.Label>
|
||||||
|
<Flex gap={2}>
|
||||||
|
{DURATIONS.map((dur) => (
|
||||||
|
<Button
|
||||||
|
key={dur.value}
|
||||||
|
size='sm'
|
||||||
|
variant={
|
||||||
|
formData.targetDuration === dur.value ? 'solid' : 'outline'
|
||||||
|
}
|
||||||
|
colorPalette={
|
||||||
|
formData.targetDuration === dur.value ? 'orange' : 'gray'
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
setFormData({ ...formData, targetDuration: dur.value })
|
||||||
|
}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
{dur.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Field.Root>
|
||||||
|
|
||||||
|
{/* Language */}
|
||||||
|
<Field.Root>
|
||||||
|
<Field.Label>{t('language')}</Field.Label>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant={formData.language === 'tr' ? 'solid' : 'outline'}
|
||||||
|
colorPalette={formData.language === 'tr' ? 'blue' : 'gray'}
|
||||||
|
onClick={() => setFormData({ ...formData, language: 'tr' })}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
🇹🇷 Türkçe
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant={formData.language === 'en' ? 'solid' : 'outline'}
|
||||||
|
colorPalette={formData.language === 'en' ? 'blue' : 'gray'}
|
||||||
|
onClick={() => setFormData({ ...formData, language: 'en' })}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
🇬🇧 English
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</Field.Root>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<Field.Root>
|
||||||
|
<Field.Label>{t('notes')}</Field.Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder={t('notesPlaceholder')}
|
||||||
|
value={formData.userNotes || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, userNotes: e.target.value })
|
||||||
|
}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</Field.Root>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<HStack justify='flex-end' gap={3} pt={4}>
|
||||||
|
<Button variant='ghost' onClick={onClose} type='button'>
|
||||||
|
{t('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
colorPalette='blue'
|
||||||
|
type='submit'
|
||||||
|
loading={isLoading}
|
||||||
|
disabled={
|
||||||
|
!formData.topic ||
|
||||||
|
formData.targetAudience.length === 0 ||
|
||||||
|
formData.speechStyle.length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('create')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</form>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
src/components/skriptai/ProjectDetail.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
HStack,
|
||||||
|
IconButton,
|
||||||
|
Spinner,
|
||||||
|
Tabs,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
Badge,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import {
|
||||||
|
LuArrowLeft,
|
||||||
|
LuPlay,
|
||||||
|
LuFileText,
|
||||||
|
LuSearch,
|
||||||
|
LuUsers,
|
||||||
|
LuBrain,
|
||||||
|
LuChartBar,
|
||||||
|
LuDownload,
|
||||||
|
} from 'react-icons/lu';
|
||||||
|
import {
|
||||||
|
useGetProject,
|
||||||
|
useGenerateScript,
|
||||||
|
useDeepResearch,
|
||||||
|
useNeuroAnalysis,
|
||||||
|
useYoutubeAudit,
|
||||||
|
projectsService,
|
||||||
|
} from '@/lib/api/skriptai';
|
||||||
|
|
||||||
|
// Tab Components
|
||||||
|
import ResearchTab from './tabs/ResearchTab';
|
||||||
|
import { BriefTab, CharactersTab, ScriptTab, AnalysisTab } from './tabs';
|
||||||
|
|
||||||
|
|
||||||
|
interface ProjectDetailProps {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project Detail Component
|
||||||
|
*
|
||||||
|
* Full project view with tabbed interface for research, brief, characters, script, and analysis.
|
||||||
|
*/
|
||||||
|
export default function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||||
|
const t = useTranslations('projectDashboard');
|
||||||
|
const router = useRouter();
|
||||||
|
const [activeTab, setActiveTab] = useState('research');
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const { data: project, isLoading, error } = useGetProject(projectId);
|
||||||
|
const generateScript = useGenerateScript(projectId);
|
||||||
|
const deepResearch = useDeepResearch(projectId);
|
||||||
|
const neuroAnalysis = useNeuroAnalysis(projectId);
|
||||||
|
const youtubeAudit = useYoutubeAudit(projectId);
|
||||||
|
|
||||||
|
// Export to JSON
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
const response = await projectsService.exportToJson(projectId);
|
||||||
|
const data = response.data;
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||||
|
type: 'application/json',
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${project?.topic || 'project'}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Export failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Flex justify='center' align='center' minH='400px'>
|
||||||
|
<Spinner size='xl' color='blue.500' />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !project) {
|
||||||
|
return (
|
||||||
|
<Container maxW='4xl' py={10}>
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Text color='red.500'>{t('projectNotFound')}</Text>
|
||||||
|
<Button onClick={() => router.push('/skriptai')}>
|
||||||
|
{t('backToDashboard')}
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxW='8xl' py={6}>
|
||||||
|
{/* Header */}
|
||||||
|
<Flex justify='space-between' align='start' mb={6} flexWrap='wrap' gap={4}>
|
||||||
|
<VStack align='start' gap={2}>
|
||||||
|
<HStack>
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('back')}
|
||||||
|
variant='ghost'
|
||||||
|
onClick={() => router.push('/skriptai')}
|
||||||
|
>
|
||||||
|
<LuArrowLeft />
|
||||||
|
</IconButton>
|
||||||
|
<Heading size='lg'>{project.topic}</Heading>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack gap={2} flexWrap='wrap'>
|
||||||
|
<Badge colorPalette='blue'>{project.contentType}</Badge>
|
||||||
|
<Badge colorPalette='green'>
|
||||||
|
{project.speechStyle?.join(', ')}
|
||||||
|
</Badge>
|
||||||
|
<Badge colorPalette='purple'>{project.targetDuration}</Badge>
|
||||||
|
<Badge colorPalette='gray'>
|
||||||
|
{project.language?.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{project.logline && (
|
||||||
|
<Text color='gray.500' fontStyle='italic' maxW='600px'>
|
||||||
|
{project.logline}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
onClick={handleExport}
|
||||||
|
>
|
||||||
|
<LuDownload />
|
||||||
|
{t('export')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
colorPalette='blue'
|
||||||
|
onClick={() => generateScript.mutate()}
|
||||||
|
loading={generateScript.isPending}
|
||||||
|
disabled={!project.sources || project.sources.length === 0}
|
||||||
|
>
|
||||||
|
<LuPlay />
|
||||||
|
{t('generateScript')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs.Root
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(e) => setActiveTab(e.value)}
|
||||||
|
>
|
||||||
|
<Tabs.List mb={6}>
|
||||||
|
<Tabs.Trigger value='research'>
|
||||||
|
<LuSearch />
|
||||||
|
{t('research')}
|
||||||
|
{project.sources && (
|
||||||
|
<Badge ml={2} size='sm'>
|
||||||
|
{project.sources.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value='brief'>
|
||||||
|
<LuFileText />
|
||||||
|
{t('brief')}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value='characters'>
|
||||||
|
<LuUsers />
|
||||||
|
{t('characters')}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value='script'>
|
||||||
|
<LuFileText />
|
||||||
|
{t('script')}
|
||||||
|
{project.segments && (
|
||||||
|
<Badge ml={2} size='sm'>
|
||||||
|
{project.segments.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value='analysis'>
|
||||||
|
<LuChartBar />
|
||||||
|
{t('analysis')}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Box minH='500px'>
|
||||||
|
<Tabs.Content value='research'>
|
||||||
|
<ResearchTab
|
||||||
|
project={project}
|
||||||
|
onResearch={() => deepResearch.mutate(undefined)}
|
||||||
|
isResearching={deepResearch.isPending}
|
||||||
|
onNext={() => setActiveTab('brief')}
|
||||||
|
/>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value='brief'>
|
||||||
|
<BriefTab
|
||||||
|
project={project}
|
||||||
|
onNext={() => setActiveTab('characters')}
|
||||||
|
/>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value='characters'>
|
||||||
|
<CharactersTab
|
||||||
|
project={project}
|
||||||
|
onNext={() => setActiveTab('script')}
|
||||||
|
/>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value='script'>
|
||||||
|
<ScriptTab
|
||||||
|
project={project}
|
||||||
|
onGenerate={() => generateScript.mutate(undefined)}
|
||||||
|
isGenerating={generateScript.isPending}
|
||||||
|
/>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value='analysis'>
|
||||||
|
<AnalysisTab
|
||||||
|
project={project}
|
||||||
|
onNeuroAnalysis={() => neuroAnalysis.mutate()}
|
||||||
|
onYoutubeAudit={() => youtubeAudit.mutate()}
|
||||||
|
isAnalyzing={neuroAnalysis.isPending || youtubeAudit.isPending}
|
||||||
|
/>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Box>
|
||||||
|
</Tabs.Root>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
256
src/components/skriptai/SkriptAIDashboard.tsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Container,
|
||||||
|
Flex,
|
||||||
|
Grid,
|
||||||
|
Heading,
|
||||||
|
HStack,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
Badge,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { LuPlus, LuSearch, LuTrash2, LuCopy, LuFileText } from 'react-icons/lu';
|
||||||
|
import {
|
||||||
|
useGetProjects,
|
||||||
|
useCreateProject,
|
||||||
|
useDeleteProject,
|
||||||
|
useDuplicateProject,
|
||||||
|
} from '@/lib/api/skriptai';
|
||||||
|
import type { ProjectListItemDto, CreateProjectDto } from '@/lib/api/skriptai';
|
||||||
|
import CreateProjectModal from './CreateProjectModal';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SkriptAI Dashboard Component
|
||||||
|
*
|
||||||
|
* Main dashboard showing all user projects with CRUD operations.
|
||||||
|
*/
|
||||||
|
export default function SkriptAIDashboard() {
|
||||||
|
const t = useTranslations('skriptai');
|
||||||
|
const router = useRouter();
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const { data: projects, isLoading, error } = useGetProjects();
|
||||||
|
const createProject = useCreateProject();
|
||||||
|
const deleteProject = useDeleteProject();
|
||||||
|
const duplicateProject = useDuplicateProject();
|
||||||
|
|
||||||
|
// Filter projects by search query
|
||||||
|
const filteredProjects = projects?.filter((p) =>
|
||||||
|
p.topic.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle project creation
|
||||||
|
const handleCreateProject = async (data: CreateProjectDto) => {
|
||||||
|
const response = await createProject.mutateAsync(data);
|
||||||
|
setIsCreateModalOpen(false);
|
||||||
|
router.push(`/skriptai/projects/${response.data.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle project deletion
|
||||||
|
const handleDeleteProject = async (id: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (window.confirm(t('confirmDelete'))) {
|
||||||
|
await deleteProject.mutateAsync(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle project duplication
|
||||||
|
const handleDuplicateProject = async (id: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await duplicateProject.mutateAsync(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigate to project detail
|
||||||
|
const openProject = (id: string) => {
|
||||||
|
router.push(`/skriptai/projects/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Flex justify='center' align='center' minH='400px'>
|
||||||
|
<Spinner size='xl' color='blue.500' />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box textAlign='center' py={10}>
|
||||||
|
<Text color='red.500'>{t('loadError')}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxW='7xl' py={8}>
|
||||||
|
{/* Header */}
|
||||||
|
<Flex justify='space-between' align='center' mb={8}>
|
||||||
|
<VStack align='start' gap={1}>
|
||||||
|
<Heading size='xl'>{t('dashboard')}</Heading>
|
||||||
|
<Text color='gray.500'>{t('dashboardSubtitle')}</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
colorScheme='blue'
|
||||||
|
size='lg'
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
>
|
||||||
|
<LuPlus />
|
||||||
|
{t('newProject')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<Box mb={6}>
|
||||||
|
<HStack>
|
||||||
|
<LuSearch />
|
||||||
|
<Input
|
||||||
|
placeholder={t('searchPlaceholder')}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
maxW='400px'
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Projects Grid */}
|
||||||
|
{filteredProjects && filteredProjects.length > 0 ? (
|
||||||
|
<Grid
|
||||||
|
templateColumns={{
|
||||||
|
base: '1fr',
|
||||||
|
md: 'repeat(2, 1fr)',
|
||||||
|
lg: 'repeat(3, 1fr)',
|
||||||
|
}}
|
||||||
|
gap={6}
|
||||||
|
>
|
||||||
|
{filteredProjects.map((project) => (
|
||||||
|
<ProjectCard
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
onOpen={() => openProject(project.id)}
|
||||||
|
onDelete={(e) => handleDeleteProject(project.id, e)}
|
||||||
|
onDuplicate={(e) => handleDuplicateProject(project.id, e)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Box textAlign='center' py={16}>
|
||||||
|
<LuFileText
|
||||||
|
size={48}
|
||||||
|
style={{ margin: '0 auto', opacity: 0.3, marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
<Text color='gray.500' mb={4}>
|
||||||
|
{searchQuery ? t('noSearchResults') : t('noProjects')}
|
||||||
|
</Text>
|
||||||
|
{!searchQuery && (
|
||||||
|
<Button
|
||||||
|
colorScheme='blue'
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
>
|
||||||
|
{t('createFirstProject')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Project Modal */}
|
||||||
|
<CreateProjectModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
onSubmit={handleCreateProject}
|
||||||
|
isLoading={createProject.isPending}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project Card Component
|
||||||
|
interface ProjectCardProps {
|
||||||
|
project: ProjectListItemDto;
|
||||||
|
onOpen: () => void;
|
||||||
|
onDelete: (e: React.MouseEvent) => void;
|
||||||
|
onDuplicate: (e: React.MouseEvent) => void;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectCard({
|
||||||
|
project,
|
||||||
|
onOpen,
|
||||||
|
onDelete,
|
||||||
|
onDuplicate,
|
||||||
|
t,
|
||||||
|
}: ProjectCardProps) {
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card.Root
|
||||||
|
cursor='pointer'
|
||||||
|
onClick={onOpen}
|
||||||
|
transition='all 0.2s'
|
||||||
|
_hover={{ transform: 'translateY(-2px)', shadow: 'lg' }}
|
||||||
|
>
|
||||||
|
<Card.Body>
|
||||||
|
<VStack align='stretch' gap={3}>
|
||||||
|
<Heading size='md' lineClamp={2}>
|
||||||
|
{project.topic}
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<HStack gap={2} flexWrap='wrap'>
|
||||||
|
<Badge colorPalette='blue'>{project.contentType}</Badge>
|
||||||
|
<Badge colorPalette='gray'>{project.language.toUpperCase()}</Badge>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack justify='space-between' color='gray.500' fontSize='sm'>
|
||||||
|
<Text>
|
||||||
|
{project._count.segments} {t('segments')}
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
{project._count.sources} {t('sources')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Text color='gray.400' fontSize='xs'>
|
||||||
|
{t('updated')}: {formatDate(project.updatedAt)}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
|
||||||
|
<Card.Footer>
|
||||||
|
<HStack justify='flex-end' gap={2}>
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('duplicate')}
|
||||||
|
size='sm'
|
||||||
|
variant='ghost'
|
||||||
|
onClick={onDuplicate}
|
||||||
|
>
|
||||||
|
<LuCopy />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('delete')}
|
||||||
|
size='sm'
|
||||||
|
variant='ghost'
|
||||||
|
colorPalette='red'
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<LuTrash2 />
|
||||||
|
</IconButton>
|
||||||
|
</HStack>
|
||||||
|
</Card.Footer>
|
||||||
|
</Card.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/components/skriptai/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as SkriptAIDashboard } from './SkriptAIDashboard';
|
||||||
|
export { default as CreateProjectModal } from './CreateProjectModal';
|
||||||
|
export { default as ProjectDetail } from './ProjectDetail';
|
||||||
363
src/components/skriptai/tabs/AnalysisTab.tsx
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
Badge,
|
||||||
|
Progress,
|
||||||
|
Grid,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { LuBrain, LuYoutube, LuDollarSign, LuSparkles } from 'react-icons/lu';
|
||||||
|
import { useCommercialBrief, useGenerateVisualAssets } from '@/lib/api/skriptai';
|
||||||
|
import type { ScriptProject } from '@/types/skriptai';
|
||||||
|
|
||||||
|
interface AnalysisTabProps {
|
||||||
|
project: ScriptProject;
|
||||||
|
onNeuroAnalysis: () => void;
|
||||||
|
onYoutubeAudit: () => void;
|
||||||
|
isAnalyzing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnalysisTab({
|
||||||
|
project,
|
||||||
|
onNeuroAnalysis,
|
||||||
|
onYoutubeAudit,
|
||||||
|
isAnalyzing,
|
||||||
|
}: AnalysisTabProps) {
|
||||||
|
const t = useTranslations('projectDashboard');
|
||||||
|
const commercialBrief = useCommercialBrief(project.id);
|
||||||
|
const generateVisualAssets = useGenerateVisualAssets(project.id);
|
||||||
|
|
||||||
|
const hasSegments = project.segments && project.segments.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack align='stretch' gap={6}>
|
||||||
|
{/* Analysis Actions */}
|
||||||
|
<Card.Root p={4}>
|
||||||
|
<VStack align='stretch' gap={4}>
|
||||||
|
<Heading size='md'>{t('contentAnalysis')}</Heading>
|
||||||
|
<Text color='gray.500'>{t('analysisDescription')}</Text>
|
||||||
|
|
||||||
|
<Grid templateColumns={{ base: '1fr', md: 'repeat(3, 1fr)' }} gap={4}>
|
||||||
|
<Button
|
||||||
|
onClick={onNeuroAnalysis}
|
||||||
|
loading={isAnalyzing}
|
||||||
|
disabled={!hasSegments}
|
||||||
|
size='lg'
|
||||||
|
variant='outline'
|
||||||
|
>
|
||||||
|
<LuBrain />
|
||||||
|
{t('neuroAnalysis')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onYoutubeAudit}
|
||||||
|
loading={isAnalyzing}
|
||||||
|
disabled={!hasSegments}
|
||||||
|
size='lg'
|
||||||
|
variant='outline'
|
||||||
|
>
|
||||||
|
<LuYoutube />
|
||||||
|
{t('youtubeAudit')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => commercialBrief.mutate()}
|
||||||
|
loading={commercialBrief.isPending}
|
||||||
|
disabled={!hasSegments}
|
||||||
|
size='lg'
|
||||||
|
variant='outline'
|
||||||
|
>
|
||||||
|
<LuDollarSign />
|
||||||
|
{t('commercialBrief')}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{!hasSegments && (
|
||||||
|
<Text color='orange.500' fontSize='sm'>
|
||||||
|
{t('generateScriptFirst')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
{/* Neuro Analysis Results */}
|
||||||
|
{project.neuroAnalysis && (
|
||||||
|
<Card.Root p={4}>
|
||||||
|
<VStack align='stretch' gap={4}>
|
||||||
|
<Flex justify='space-between' align='center'>
|
||||||
|
<Heading size='md'>
|
||||||
|
<LuBrain style={{ display: 'inline', marginRight: 8 }} />
|
||||||
|
{t('neuroAnalysisResults')}
|
||||||
|
</Heading>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* Scores */}
|
||||||
|
<Grid templateColumns={{ base: '1fr', md: 'repeat(3, 1fr)' }} gap={4}>
|
||||||
|
<ScoreCard
|
||||||
|
label={t('engagement')}
|
||||||
|
score={project.neuroAnalysis.engagementScore}
|
||||||
|
color='blue'
|
||||||
|
/>
|
||||||
|
<ScoreCard
|
||||||
|
label={t('dopamine')}
|
||||||
|
score={project.neuroAnalysis.dopamineScore}
|
||||||
|
color='purple'
|
||||||
|
/>
|
||||||
|
<ScoreCard
|
||||||
|
label={t('clarity')}
|
||||||
|
score={project.neuroAnalysis.clarityScore}
|
||||||
|
color='green'
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Persuasion Metrics */}
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight='semibold' mb={3}>
|
||||||
|
{t('persuasionMetrics')} (Cialdini)
|
||||||
|
</Text>
|
||||||
|
<Grid templateColumns='repeat(3, 1fr)' gap={3}>
|
||||||
|
{Object.entries(project.neuroAnalysis.persuasionMetrics).map(
|
||||||
|
([key, value]) => (
|
||||||
|
<Box key={key}>
|
||||||
|
<Flex justify='space-between' mb={1}>
|
||||||
|
<Text fontSize='sm' textTransform='capitalize'>
|
||||||
|
{key}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize='sm' fontWeight='bold'>
|
||||||
|
{value}%
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<Progress.Root value={value} size='sm'>
|
||||||
|
<Progress.Track>
|
||||||
|
<Progress.Range />
|
||||||
|
</Progress.Track>
|
||||||
|
</Progress.Root>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Suggestions */}
|
||||||
|
{project.neuroAnalysis.suggestions && (
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight='semibold' mb={2}>
|
||||||
|
{t('suggestions')}
|
||||||
|
</Text>
|
||||||
|
<VStack align='stretch' gap={2}>
|
||||||
|
{project.neuroAnalysis.suggestions.map((suggestion, i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
p={2}
|
||||||
|
bg='yellow.50'
|
||||||
|
_dark={{ bg: 'yellow.900' }}
|
||||||
|
borderRadius='md'
|
||||||
|
>
|
||||||
|
<Text fontSize='sm'>💡 {suggestion}</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Card.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* YouTube Audit Results */}
|
||||||
|
{project.youtubeAudit && (
|
||||||
|
<Card.Root p={4}>
|
||||||
|
<VStack align='stretch' gap={4}>
|
||||||
|
<Heading size='md'>
|
||||||
|
<LuYoutube
|
||||||
|
style={{ display: 'inline', marginRight: 8, color: 'red' }}
|
||||||
|
/>
|
||||||
|
{t('youtubeAuditResults')}
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
{/* Scores */}
|
||||||
|
<Grid templateColumns={{ base: '1fr', md: 'repeat(3, 1fr)' }} gap={4}>
|
||||||
|
<ScoreCard
|
||||||
|
label={t('hookScore')}
|
||||||
|
score={project.youtubeAudit.hookScore}
|
||||||
|
color='red'
|
||||||
|
/>
|
||||||
|
<ScoreCard
|
||||||
|
label={t('pacing')}
|
||||||
|
score={project.youtubeAudit.pacingScore}
|
||||||
|
color='orange'
|
||||||
|
/>
|
||||||
|
<ScoreCard
|
||||||
|
label={t('viralPotential')}
|
||||||
|
score={project.youtubeAudit.viralPotential}
|
||||||
|
color='pink'
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Title Suggestions */}
|
||||||
|
{project.youtubeAudit.titles && (
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight='semibold' mb={2}>
|
||||||
|
{t('titleSuggestions')}
|
||||||
|
</Text>
|
||||||
|
<VStack align='stretch' gap={1}>
|
||||||
|
{project.youtubeAudit.titles.map((title, i) => (
|
||||||
|
<Text key={i} fontSize='sm'>
|
||||||
|
{i + 1}. {title}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Thumbnails */}
|
||||||
|
{project.youtubeAudit.thumbnails && (
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight='semibold' mb={2}>
|
||||||
|
{t('thumbnailIdeas')}
|
||||||
|
</Text>
|
||||||
|
<Grid templateColumns={{ base: '1fr', md: 'repeat(3, 1fr)' }} gap={3}>
|
||||||
|
{project.youtubeAudit.thumbnails.map((thumb, i) => (
|
||||||
|
<Card.Root key={i} p={3} size='sm'>
|
||||||
|
<Text fontWeight='semibold' fontSize='sm'>
|
||||||
|
{thumb.conceptName}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize='xs' color='gray.500'>
|
||||||
|
{thumb.visualDescription}
|
||||||
|
</Text>
|
||||||
|
<Badge size='sm' mt={2}>
|
||||||
|
{thumb.emotionTarget}
|
||||||
|
</Badge>
|
||||||
|
</Card.Root>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Card.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Commercial Brief Results */}
|
||||||
|
{project.commercialBrief && (
|
||||||
|
<Card.Root p={4}>
|
||||||
|
<VStack align='stretch' gap={4}>
|
||||||
|
<Heading size='md'>
|
||||||
|
<LuDollarSign style={{ display: 'inline', marginRight: 8 }} />
|
||||||
|
{t('commercialBriefResults')}
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<HStack>
|
||||||
|
<Badge colorPalette='green' size='lg'>
|
||||||
|
{t('viability')}: {project.commercialBrief.viabilityScore}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Text color='gray.600'>{project.commercialBrief.viabilityReason}</Text>
|
||||||
|
|
||||||
|
{project.commercialBrief.sponsors && (
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight='semibold' mb={2}>
|
||||||
|
{t('potentialSponsors')}
|
||||||
|
</Text>
|
||||||
|
<VStack align='stretch' gap={3}>
|
||||||
|
{project.commercialBrief.sponsors.map((sponsor, i) => (
|
||||||
|
<Card.Root key={i} p={3} variant='outline'>
|
||||||
|
<HStack justify='space-between' mb={2}>
|
||||||
|
<Text fontWeight='semibold'>{sponsor.name}</Text>
|
||||||
|
<Badge>{sponsor.industry}</Badge>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize='sm' color='gray.500'>
|
||||||
|
{sponsor.matchReason}
|
||||||
|
</Text>
|
||||||
|
</Card.Root>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Card.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Visual Assets (Storyboard) */}
|
||||||
|
<Card.Root p={4}>
|
||||||
|
<VStack align='stretch' gap={4}>
|
||||||
|
<Flex justify='space-between' align='center'>
|
||||||
|
<Heading size='md'>
|
||||||
|
<LuSparkles style={{ display: 'inline', marginRight: 8, color: 'purple' }} />
|
||||||
|
{t('visualAssets')}
|
||||||
|
</Heading>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='outline'
|
||||||
|
onClick={() => generateVisualAssets.mutate(5)}
|
||||||
|
loading={generateVisualAssets.isPending}
|
||||||
|
>
|
||||||
|
<LuSparkles />
|
||||||
|
{t('generateAssets')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{project.visualAssets && project.visualAssets.length > 0 ? (
|
||||||
|
<Grid templateColumns={{ base: '1fr', md: 'repeat(3, 1fr)' }} gap={4}>
|
||||||
|
{project.visualAssets.map((asset) => (
|
||||||
|
<Card.Root key={asset.id} overflow='hidden'>
|
||||||
|
<img
|
||||||
|
src={asset.url}
|
||||||
|
alt={asset.desc}
|
||||||
|
style={{ width: '100%', height: '200px', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
<Box p={2}>
|
||||||
|
<Text fontSize='xs' color='gray.500' lineClamp={2}>
|
||||||
|
{asset.desc}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Card.Root>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Text color='gray.500' fontStyle='italic'>
|
||||||
|
{t('noVisualAssets')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Card.Root>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score Card Component
|
||||||
|
interface ScoreCardProps {
|
||||||
|
label: string;
|
||||||
|
score: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreCard({ label, score, color }: ScoreCardProps) {
|
||||||
|
const getScoreColor = (score: number) => {
|
||||||
|
if (score >= 80) return 'green';
|
||||||
|
if (score >= 60) return 'yellow';
|
||||||
|
if (score >= 40) return 'orange';
|
||||||
|
return 'red';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card.Root p={4} textAlign='center'>
|
||||||
|
<Text fontSize='sm' color='gray.500' mb={2}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize='3xl' fontWeight='bold' color={`${getScoreColor(score)}.500`}>
|
||||||
|
{score}
|
||||||
|
</Text>
|
||||||
|
<Progress.Root value={score} size='sm' mt={2} colorPalette={color}>
|
||||||
|
<Progress.Track>
|
||||||
|
<Progress.Range />
|
||||||
|
</Progress.Track>
|
||||||
|
</Progress.Root>
|
||||||
|
</Card.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
src/components/skriptai/tabs/BriefTab.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
HStack,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
VStack,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { LuPlus, LuTrash2, LuSparkles, LuSave, LuArrowLeft } from 'react-icons/lu';
|
||||||
|
import {
|
||||||
|
useGenerateDiscoveryQuestions,
|
||||||
|
useGenerateLogline,
|
||||||
|
researchService,
|
||||||
|
ProjectsQueryKeys,
|
||||||
|
} from '@/lib/api/skriptai';
|
||||||
|
import type { ScriptProject, BriefItem } from '@/types/skriptai';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
interface BriefTabProps {
|
||||||
|
project: ScriptProject;
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BriefTab({ project, onNext }: BriefTabProps) {
|
||||||
|
const t = useTranslations('projectDashboard');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const generateQuestions = useGenerateDiscoveryQuestions();
|
||||||
|
const generateLogline = useGenerateLogline(project.id);
|
||||||
|
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editValue, setEditValue] = useState('');
|
||||||
|
|
||||||
|
// Add new question
|
||||||
|
const handleAddQuestion = async (question: string) => {
|
||||||
|
await researchService.addBriefItem({
|
||||||
|
projectId: project.id,
|
||||||
|
question,
|
||||||
|
answer: '',
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ProjectsQueryKeys.detail(project.id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate AI questions
|
||||||
|
const handleGenerateQuestions = async () => {
|
||||||
|
const existingQuestions = project.briefItems?.map((b) => b.question) || [];
|
||||||
|
const response = await generateQuestions.mutateAsync({
|
||||||
|
topic: project.topic,
|
||||||
|
language: project.language,
|
||||||
|
existingQuestions,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add generated questions
|
||||||
|
const questions = response?.data || [];
|
||||||
|
for (const question of questions) {
|
||||||
|
await researchService.addBriefItem({
|
||||||
|
projectId: project.id,
|
||||||
|
question,
|
||||||
|
answer: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ProjectsQueryKeys.detail(project.id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update answer
|
||||||
|
const handleSaveAnswer = async (id: string) => {
|
||||||
|
await researchService.updateBriefItem(id, editValue);
|
||||||
|
setEditingId(null);
|
||||||
|
setEditValue('');
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ProjectsQueryKeys.detail(project.id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete brief item
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
await researchService.deleteBriefItem(id);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ProjectsQueryKeys.detail(project.id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start editing
|
||||||
|
const startEdit = (item: BriefItem) => {
|
||||||
|
setEditingId(item.id);
|
||||||
|
setEditValue(item.answer);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack align='stretch' gap={6}>
|
||||||
|
{/* Logline Section */}
|
||||||
|
<Card.Root p={4}>
|
||||||
|
<VStack align='stretch' gap={4}>
|
||||||
|
<Flex justify='space-between' align='center'>
|
||||||
|
<Heading size='md'>{t('loglineHighConcept')}</Heading>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
onClick={() => generateLogline.mutate()}
|
||||||
|
loading={generateLogline.isPending}
|
||||||
|
>
|
||||||
|
<LuSparkles />
|
||||||
|
{t('generate')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{project.logline ? (
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight='semibold' mb={1}>
|
||||||
|
Logline:
|
||||||
|
</Text>
|
||||||
|
<Text fontStyle='italic' color='gray.600'>
|
||||||
|
"{project.logline}"
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Text color='gray.500'>{t('noLogline')}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.highConcept && (
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight='semibold' mb={1}>
|
||||||
|
High Concept:
|
||||||
|
</Text>
|
||||||
|
<Text color='gray.600'>{project.highConcept}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
{/* Creative Brief Questions */}
|
||||||
|
<Box>
|
||||||
|
<Flex justify='space-between' align='center' mb={4}>
|
||||||
|
<Heading size='md'>{t('creativeBrief')}</Heading>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
onClick={handleGenerateQuestions}
|
||||||
|
loading={generateQuestions.isPending}
|
||||||
|
>
|
||||||
|
<LuSparkles />
|
||||||
|
{t('generateQuestions')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<VStack align='stretch' gap={4}>
|
||||||
|
{project.briefItems && project.briefItems.length > 0 ? (
|
||||||
|
project.briefItems.map((item) => (
|
||||||
|
<Card.Root key={item.id} p={4}>
|
||||||
|
<VStack align='stretch' gap={3}>
|
||||||
|
<Flex justify='space-between' align='start'>
|
||||||
|
<Text fontWeight='semibold'>{item.question}</Text>
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('delete')}
|
||||||
|
size='xs'
|
||||||
|
variant='ghost'
|
||||||
|
colorPalette='red'
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
<LuTrash2 />
|
||||||
|
</IconButton>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<HStack>
|
||||||
|
<Textarea
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
colorPalette='green'
|
||||||
|
onClick={() => handleSaveAnswer(item.id)}
|
||||||
|
>
|
||||||
|
<LuSave />
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
p={3}
|
||||||
|
bg='gray.50'
|
||||||
|
_dark={{ bg: 'gray.700' }}
|
||||||
|
borderRadius='md'
|
||||||
|
cursor='pointer'
|
||||||
|
onClick={() => startEdit(item)}
|
||||||
|
minH='60px'
|
||||||
|
>
|
||||||
|
{item.answer ? (
|
||||||
|
<Text>{item.answer}</Text>
|
||||||
|
) : (
|
||||||
|
<Text color='gray.400' fontStyle='italic'>
|
||||||
|
{t('clickToAnswer')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Card.Root>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Card.Root p={8}>
|
||||||
|
<VStack>
|
||||||
|
<Text color='gray.500'>{t('noBriefItems')}</Text>
|
||||||
|
<Button
|
||||||
|
onClick={handleGenerateQuestions}
|
||||||
|
loading={generateQuestions.isPending}
|
||||||
|
>
|
||||||
|
{t('generateQuestions')}
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</Card.Root>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
<Flex justify='flex-end' mt={4}>
|
||||||
|
<Button
|
||||||
|
size='lg'
|
||||||
|
colorPalette='blue'
|
||||||
|
onClick={onNext}
|
||||||
|
>
|
||||||
|
{t('continueToCharacters')}
|
||||||
|
<LuArrowLeft style={{ transform: 'rotate(180deg)' }} />
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
316
src/components/skriptai/tabs/CharactersTab.tsx
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
HStack,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
Badge,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { LuPlus, LuTrash2, LuSparkles, LuSave, LuArrowLeft } from 'react-icons/lu';
|
||||||
|
import { useGenerateCharacters, researchService, ProjectsQueryKeys } from '@/lib/api/skriptai';
|
||||||
|
import type { ScriptProject, CharacterProfile, CharacterRole } from '@/types/skriptai';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const ROLES: CharacterRole[] = [
|
||||||
|
'Protagonist',
|
||||||
|
'Antagonist',
|
||||||
|
'Guide/Mentor',
|
||||||
|
'Sidekick',
|
||||||
|
'Narrator',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CharactersTabProps {
|
||||||
|
project: ScriptProject;
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CharactersTab({ project, onNext }: CharactersTabProps) {
|
||||||
|
const t = useTranslations('projectDashboard');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const generateCharacters = useGenerateCharacters(project.id);
|
||||||
|
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [newCharacter, setNewCharacter] = useState({
|
||||||
|
name: '',
|
||||||
|
role: 'Protagonist' as CharacterRole,
|
||||||
|
values: '',
|
||||||
|
traits: '',
|
||||||
|
mannerisms: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add new character
|
||||||
|
const handleAddCharacter = async () => {
|
||||||
|
await researchService.addCharacter({
|
||||||
|
projectId: project.id,
|
||||||
|
...newCharacter,
|
||||||
|
});
|
||||||
|
setIsAdding(false);
|
||||||
|
setNewCharacter({
|
||||||
|
name: '',
|
||||||
|
role: 'Protagonist',
|
||||||
|
values: '',
|
||||||
|
traits: '',
|
||||||
|
mannerisms: '',
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ProjectsQueryKeys.detail(project.id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete character
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
await researchService.deleteCharacter(id);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ProjectsQueryKeys.detail(project.id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate AI characters
|
||||||
|
const handleGenerateCharacters = async () => {
|
||||||
|
await generateCharacters.mutateAsync();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack align='stretch' gap={6}>
|
||||||
|
{/* Header */}
|
||||||
|
<Flex justify='space-between' align='center'>
|
||||||
|
<Heading size='md'>{t('characterProfiles')}</Heading>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='outline'
|
||||||
|
onClick={() => setIsAdding(true)}
|
||||||
|
>
|
||||||
|
<LuPlus />
|
||||||
|
{t('addCharacter')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
onClick={handleGenerateCharacters}
|
||||||
|
loading={generateCharacters.isPending}
|
||||||
|
>
|
||||||
|
<LuSparkles />
|
||||||
|
{t('autoGenerate')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Text color='gray.500' fontSize='sm'>
|
||||||
|
{t('characterDescription')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Add Character Form */}
|
||||||
|
{isAdding && (
|
||||||
|
<Card.Root p={4}>
|
||||||
|
<VStack align='stretch' gap={4}>
|
||||||
|
<Heading size='sm'>{t('newCharacter')}</Heading>
|
||||||
|
|
||||||
|
<HStack gap={4}>
|
||||||
|
<Box flex={1}>
|
||||||
|
<Text fontSize='sm' mb={1}>{t('name')}</Text>
|
||||||
|
<Input
|
||||||
|
value={newCharacter.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewCharacter({ ...newCharacter, name: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={t('characterName')}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize='sm' mb={1}>{t('role')}</Text>
|
||||||
|
<Flex gap={1}>
|
||||||
|
{ROLES.map((role) => (
|
||||||
|
<Button
|
||||||
|
key={role}
|
||||||
|
size='xs'
|
||||||
|
variant={newCharacter.role === role ? 'solid' : 'outline'}
|
||||||
|
colorPalette={newCharacter.role === role ? 'blue' : 'gray'}
|
||||||
|
onClick={() =>
|
||||||
|
setNewCharacter({ ...newCharacter, role })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text fontSize='sm' mb={1}>{t('values')}</Text>
|
||||||
|
<Input
|
||||||
|
value={newCharacter.values}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewCharacter({ ...newCharacter, values: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={t('valuesPlaceholder')}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text fontSize='sm' mb={1}>{t('traits')}</Text>
|
||||||
|
<Input
|
||||||
|
value={newCharacter.traits}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewCharacter({ ...newCharacter, traits: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={t('traitsPlaceholder')}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text fontSize='sm' mb={1}>{t('mannerisms')}</Text>
|
||||||
|
<Input
|
||||||
|
value={newCharacter.mannerisms}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewCharacter({ ...newCharacter, mannerisms: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={t('mannerismsPlaceholder')}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<HStack justify='flex-end'>
|
||||||
|
<Button variant='ghost' onClick={() => setIsAdding(false)}>
|
||||||
|
{t('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
colorPalette='blue'
|
||||||
|
onClick={handleAddCharacter}
|
||||||
|
disabled={!newCharacter.name}
|
||||||
|
>
|
||||||
|
{t('save')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Card.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Characters List */}
|
||||||
|
{project.characters && project.characters.length > 0 ? (
|
||||||
|
<VStack align='stretch' gap={4}>
|
||||||
|
{project.characters.map((character) => (
|
||||||
|
<CharacterCard
|
||||||
|
key={character.id}
|
||||||
|
character={character}
|
||||||
|
onDelete={() => handleDelete(character.id)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
!isAdding && (
|
||||||
|
<Card.Root p={8}>
|
||||||
|
<VStack>
|
||||||
|
<Text color='gray.500'>{t('noCharacters')}</Text>
|
||||||
|
<Button
|
||||||
|
onClick={handleGenerateCharacters}
|
||||||
|
loading={generateCharacters.isPending}
|
||||||
|
>
|
||||||
|
{t('autoGenerate')}
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</Card.Root>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
<Flex justify='flex-end' mt={4}>
|
||||||
|
<Button
|
||||||
|
size='lg'
|
||||||
|
colorPalette='blue'
|
||||||
|
onClick={onNext}
|
||||||
|
>
|
||||||
|
{t('continueToScript')}
|
||||||
|
<LuArrowLeft style={{ transform: 'rotate(180deg)' }} />
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Character Card Component
|
||||||
|
interface CharacterCardProps {
|
||||||
|
character: CharacterProfile;
|
||||||
|
onDelete: () => void;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CharacterCard({ character, onDelete, t }: CharacterCardProps) {
|
||||||
|
const getRoleColor = (role: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'Protagonist':
|
||||||
|
return 'blue';
|
||||||
|
case 'Antagonist':
|
||||||
|
return 'red';
|
||||||
|
case 'Guide/Mentor':
|
||||||
|
return 'green';
|
||||||
|
case 'Sidekick':
|
||||||
|
return 'orange';
|
||||||
|
case 'Narrator':
|
||||||
|
return 'purple';
|
||||||
|
default:
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card.Root p={4}>
|
||||||
|
<Flex justify='space-between' align='start'>
|
||||||
|
<VStack align='start' gap={3} flex={1}>
|
||||||
|
<HStack>
|
||||||
|
<Heading size='sm'>{character.name}</Heading>
|
||||||
|
<Badge colorPalette={getRoleColor(character.role)}>
|
||||||
|
{character.role}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{character.values && (
|
||||||
|
<Box>
|
||||||
|
<Text fontSize='xs' fontWeight='semibold' color='gray.500'>
|
||||||
|
{t('values')}:
|
||||||
|
</Text>
|
||||||
|
<Text fontSize='sm'>{character.values}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{character.traits && (
|
||||||
|
<Box>
|
||||||
|
<Text fontSize='xs' fontWeight='semibold' color='gray.500'>
|
||||||
|
{t('traits')}:
|
||||||
|
</Text>
|
||||||
|
<Text fontSize='sm'>{character.traits}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{character.mannerisms && (
|
||||||
|
<Box>
|
||||||
|
<Text fontSize='xs' fontWeight='semibold' color='gray.500'>
|
||||||
|
{t('mannerisms')}:
|
||||||
|
</Text>
|
||||||
|
<Text fontSize='sm'>{character.mannerisms}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('delete')}
|
||||||
|
size='sm'
|
||||||
|
variant='ghost'
|
||||||
|
colorPalette='red'
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<LuTrash2 />
|
||||||
|
</IconButton>
|
||||||
|
</Flex>
|
||||||
|
</Card.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
src/components/skriptai/tabs/ResearchTab.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
HStack,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
Badge,
|
||||||
|
Checkbox,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { LuSearch, LuPlus, LuTrash2, LuExternalLink, LuArrowLeft } from 'react-icons/lu';
|
||||||
|
import { researchService, ProjectsQueryKeys } from '@/lib/api/skriptai';
|
||||||
|
import type { ScriptProject, ResearchSource } from '@/types/skriptai';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
interface ResearchTabProps {
|
||||||
|
project: ScriptProject;
|
||||||
|
onResearch: () => void;
|
||||||
|
isResearching: boolean;
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResearchTab({
|
||||||
|
project,
|
||||||
|
onResearch,
|
||||||
|
isResearching,
|
||||||
|
onNext,
|
||||||
|
}: ResearchTabProps) {
|
||||||
|
const t = useTranslations('projectDashboard');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [additionalQuery, setAdditionalQuery] = useState('');
|
||||||
|
|
||||||
|
const handleToggleSource = async (id: string) => {
|
||||||
|
await researchService.toggleSourceSelection(id);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ProjectsQueryKeys.detail(project.id) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSource = async (id: string) => {
|
||||||
|
await researchService.deleteSource(id);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ProjectsQueryKeys.detail(project.id) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedCount = project.sources?.filter((s) => s.selected).length || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack align='stretch' gap={6}>
|
||||||
|
{/* Research Controls */}
|
||||||
|
<Card.Root p={4}>
|
||||||
|
<VStack align='stretch' gap={4}>
|
||||||
|
<Heading size='md'>{t('deepResearch')}</Heading>
|
||||||
|
<Text color='gray.500'>{t('researchDescription')}</Text>
|
||||||
|
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Input
|
||||||
|
placeholder={t('additionalQuery')}
|
||||||
|
value={additionalQuery}
|
||||||
|
onChange={(e) => setAdditionalQuery(e.target.value)}
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
colorPalette='blue'
|
||||||
|
onClick={onResearch}
|
||||||
|
loading={isResearching}
|
||||||
|
>
|
||||||
|
<LuSearch />
|
||||||
|
{t('startResearch')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
{/* Sources List */}
|
||||||
|
<Box>
|
||||||
|
<Flex justify='space-between' align='center' mb={4}>
|
||||||
|
<Heading size='md'>
|
||||||
|
{t('sources')} ({project.sources?.length || 0})
|
||||||
|
</Heading>
|
||||||
|
<Badge colorPalette='green'>
|
||||||
|
{selectedCount} {t('selected')}
|
||||||
|
</Badge>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{project.sources && project.sources.length > 0 ? (
|
||||||
|
<VStack align='stretch' gap={3}>
|
||||||
|
{project.sources.map((source) => (
|
||||||
|
<SourceCard
|
||||||
|
key={source.id}
|
||||||
|
source={source}
|
||||||
|
onToggle={() => handleToggleSource(source.id)}
|
||||||
|
onDelete={() => handleDeleteSource(source.id)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Flex justify='flex-end' mt={4}>
|
||||||
|
<Button
|
||||||
|
size='lg'
|
||||||
|
colorPalette='blue'
|
||||||
|
onClick={onNext}
|
||||||
|
>
|
||||||
|
{t('continueToBrief')}
|
||||||
|
<LuArrowLeft style={{ transform: 'rotate(180deg)' }} />
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<Card.Root p={8}>
|
||||||
|
<VStack>
|
||||||
|
<LuSearch size={32} style={{ opacity: 0.3 }} />
|
||||||
|
<Text color='gray.500'>{t('noSources')}</Text>
|
||||||
|
<Button onClick={onResearch} loading={isResearching}>
|
||||||
|
{t('startResearch')}
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</Card.Root>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source Card Component
|
||||||
|
interface SourceCardProps {
|
||||||
|
source: ResearchSource;
|
||||||
|
onToggle: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceCard({ source, onToggle, onDelete, t }: SourceCardProps) {
|
||||||
|
return (
|
||||||
|
<Card.Root
|
||||||
|
p={4}
|
||||||
|
borderWidth={source.selected ? 2 : 1}
|
||||||
|
borderColor={source.selected ? 'blue.500' : 'gray.200'}
|
||||||
|
>
|
||||||
|
<Flex gap={4}>
|
||||||
|
<Checkbox.Root
|
||||||
|
checked={source.selected}
|
||||||
|
onCheckedChange={onToggle}
|
||||||
|
mt={1}
|
||||||
|
>
|
||||||
|
<Checkbox.HiddenInput />
|
||||||
|
<Checkbox.Control />
|
||||||
|
</Checkbox.Root>
|
||||||
|
|
||||||
|
<Box flex={1}>
|
||||||
|
<HStack justify='space-between' mb={2}>
|
||||||
|
<Text fontWeight='semibold' lineClamp={1}>
|
||||||
|
{source.title}
|
||||||
|
</Text>
|
||||||
|
<HStack gap={1}>
|
||||||
|
<Badge colorPalette='gray' size='sm'>
|
||||||
|
{source.type}
|
||||||
|
</Badge>
|
||||||
|
{source.isNew && (
|
||||||
|
<Badge colorPalette='green' size='sm'>
|
||||||
|
{t('new')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{source.snippet && (
|
||||||
|
<Text color='gray.500' fontSize='sm' lineClamp={2} mb={2}>
|
||||||
|
{source.snippet}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<HStack justify='space-between'>
|
||||||
|
<a
|
||||||
|
href={source.url}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
style={{ color: 'inherit', fontSize: '12px' }}
|
||||||
|
>
|
||||||
|
<HStack gap={1}>
|
||||||
|
<LuExternalLink size={12} />
|
||||||
|
<Text fontSize='xs' color='blue.500' lineClamp={1}>
|
||||||
|
{source.url}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('delete')}
|
||||||
|
size='xs'
|
||||||
|
variant='ghost'
|
||||||
|
colorPalette='red'
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<LuTrash2 />
|
||||||
|
</IconButton>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
</Card.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
533
src/components/skriptai/tabs/ScriptTab.tsx
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
HStack,
|
||||||
|
IconButton,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
VStack,
|
||||||
|
Badge,
|
||||||
|
Menu,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import {
|
||||||
|
LuPlay,
|
||||||
|
LuTrash2,
|
||||||
|
LuPencil,
|
||||||
|
LuSave,
|
||||||
|
LuRefreshCw,
|
||||||
|
LuGripVertical,
|
||||||
|
LuChevronDown, // Added missing import
|
||||||
|
LuWand,
|
||||||
|
LuImage,
|
||||||
|
LuVideo,
|
||||||
|
LuCopy,
|
||||||
|
LuX,
|
||||||
|
} from 'react-icons/lu';
|
||||||
|
import {
|
||||||
|
useRewriteSegment,
|
||||||
|
useUpdateSegment,
|
||||||
|
scriptsService,
|
||||||
|
ProjectsQueryKeys,
|
||||||
|
useGenerateSegmentImage
|
||||||
|
} from '@/lib/api/skriptai';
|
||||||
|
import type { ScriptProject, ScriptSegment } from '@/types/skriptai';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toaster } from '@/components/ui/feedback/toaster';
|
||||||
|
|
||||||
|
const REWRITE_STYLES = [
|
||||||
|
'Casual / Conversational',
|
||||||
|
'Formal / Corporate',
|
||||||
|
'Humorous / Witty',
|
||||||
|
'Dramatic / Intense',
|
||||||
|
'Storyteller / Narrator',
|
||||||
|
'Make it Longer',
|
||||||
|
'Make it Shorter',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ScriptTabProps {
|
||||||
|
project: ScriptProject;
|
||||||
|
onGenerate: () => void;
|
||||||
|
isGenerating: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScriptTab({
|
||||||
|
project,
|
||||||
|
onGenerate,
|
||||||
|
isGenerating,
|
||||||
|
}: ScriptTabProps) {
|
||||||
|
const t = useTranslations('skriptai');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const rewriteSegment = useRewriteSegment(project.id);
|
||||||
|
const generateSegmentImage = useGenerateSegmentImage(project.id);
|
||||||
|
|
||||||
|
const totalDuration = project.segments?.reduce((acc, seg) => {
|
||||||
|
const dur = parseInt(seg.duration) || 0;
|
||||||
|
return acc + dur;
|
||||||
|
}, 0) || 0;
|
||||||
|
|
||||||
|
const totalWords = project.segments?.reduce((acc, seg) => {
|
||||||
|
return acc + (seg.narratorScript?.split(' ').length || 0);
|
||||||
|
}, 0) || 0;
|
||||||
|
|
||||||
|
const handleDeleteSegment = async (id: string) => {
|
||||||
|
await scriptsService.deleteSegment(id);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ProjectsQueryKeys.detail(project.id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRewrite = async (segmentId: string, style: string) => {
|
||||||
|
await rewriteSegment.mutateAsync({ segmentId, newStyle: style });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateImage = async (segmentId: string) => {
|
||||||
|
await generateSegmentImage.mutateAsync(segmentId);
|
||||||
|
toaster.create({
|
||||||
|
title: t('imageGenerating'),
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateVideo = async (segmentId: string) => {
|
||||||
|
// Placeholder for future video generation implementation
|
||||||
|
toaster.create({
|
||||||
|
title: t('videoGenerationComingSoon'),
|
||||||
|
description: "Google Video integration is coming soon.",
|
||||||
|
type: 'info',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<VStack align='stretch' gap={6}>
|
||||||
|
{/* Header Stats */}
|
||||||
|
<Flex justify='space-between' align='center' flexWrap='wrap' gap={4}>
|
||||||
|
<HStack gap={4}>
|
||||||
|
<Heading size='md'>{t('scriptEditor')}</Heading>
|
||||||
|
<Badge colorPalette='blue'>
|
||||||
|
{project.segments?.length || 0} {t('segments')}
|
||||||
|
</Badge>
|
||||||
|
<Badge colorPalette='green'>
|
||||||
|
~{Math.floor(totalDuration / 60)}:{(totalDuration % 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')}
|
||||||
|
</Badge>
|
||||||
|
<Badge colorPalette='purple'>~{totalWords} {t('words')}</Badge>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
colorPalette='blue'
|
||||||
|
onClick={onGenerate}
|
||||||
|
loading={isGenerating}
|
||||||
|
disabled={!project.sources || project.sources.length === 0}
|
||||||
|
>
|
||||||
|
<LuPlay />
|
||||||
|
{project.segments && project.segments.length > 0
|
||||||
|
? t('regenerateScript')
|
||||||
|
: t('generateScript')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* SEO Info */}
|
||||||
|
{project.seoTitle && (
|
||||||
|
<Card.Root p={4} bg='blue.50' _dark={{ bg: 'blue.900' }}>
|
||||||
|
<VStack align='start' gap={2}>
|
||||||
|
<Text fontWeight='bold'>{project.seoTitle}</Text>
|
||||||
|
{project.seoDescription && (
|
||||||
|
<Text fontSize='sm' color='gray.600'>
|
||||||
|
{project.seoDescription}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{project.seoTags && project.seoTags.length > 0 && (
|
||||||
|
<HStack gap={1} flexWrap='wrap'>
|
||||||
|
{project.seoTags.map((tag, i) => (
|
||||||
|
<Badge key={i} size='sm'>
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Card.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Segments List */}
|
||||||
|
{project.segments && project.segments.length > 0 ? (
|
||||||
|
<VStack align='stretch' gap={4}>
|
||||||
|
{project.segments.map((segment, index) => (
|
||||||
|
<SegmentCard
|
||||||
|
key={segment.id}
|
||||||
|
segment={segment}
|
||||||
|
index={index}
|
||||||
|
projectId={project.id}
|
||||||
|
onDelete={() => handleDeleteSegment(segment.id)}
|
||||||
|
onRewrite={(style) => handleRewrite(segment.id, style)}
|
||||||
|
onGenerateImage={() => handleGenerateImage(segment.id)}
|
||||||
|
onGenerateVideo={() => handleGenerateVideo(segment.id)}
|
||||||
|
isRewriting={rewriteSegment.isPending}
|
||||||
|
isGeneratingImage={generateSegmentImage.isPending && generateSegmentImage.variables === segment.id}
|
||||||
|
t={t}
|
||||||
|
onImageClick={(url) => setSelectedImage(url)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<Card.Root p={8}>
|
||||||
|
<VStack>
|
||||||
|
<Text color='gray.500'>{t('noSegments')}</Text>
|
||||||
|
<Text color='gray.400' fontSize='sm'>
|
||||||
|
{t('addSourcesFirst')}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
onClick={onGenerate}
|
||||||
|
loading={isGenerating}
|
||||||
|
disabled={!project.sources || project.sources.length === 0}
|
||||||
|
>
|
||||||
|
{t('generateScript')}
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</Card.Root>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* Image Lightbox Dialog */}
|
||||||
|
{selectedImage && (
|
||||||
|
<Box
|
||||||
|
position="fixed"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
bg="blackAlpha.800"
|
||||||
|
zIndex={9999}
|
||||||
|
onClick={() => setSelectedImage(null)}
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
p={4}
|
||||||
|
cursor="zoom-out"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
maxW="90vw"
|
||||||
|
maxH="90vh"
|
||||||
|
position="relative"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={selectedImage}
|
||||||
|
alt="Full size view"
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Close"
|
||||||
|
position="absolute"
|
||||||
|
top={-4}
|
||||||
|
right={-4}
|
||||||
|
size="sm"
|
||||||
|
rounded="full"
|
||||||
|
onClick={() => setSelectedImage(null)}
|
||||||
|
bg="white"
|
||||||
|
color="black"
|
||||||
|
_hover={{ bg: "gray.100" }}
|
||||||
|
>
|
||||||
|
<LuX />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment Card Component
|
||||||
|
interface SegmentCardProps {
|
||||||
|
segment: ScriptSegment;
|
||||||
|
index: number;
|
||||||
|
projectId: string;
|
||||||
|
onDelete: () => void;
|
||||||
|
onRewrite: (style: string) => void;
|
||||||
|
onGenerateImage: () => void;
|
||||||
|
onGenerateVideo: () => void;
|
||||||
|
isRewriting: boolean;
|
||||||
|
isGeneratingImage: boolean;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
onImageClick: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SegmentCard({
|
||||||
|
segment,
|
||||||
|
index,
|
||||||
|
projectId,
|
||||||
|
onDelete,
|
||||||
|
onRewrite,
|
||||||
|
onGenerateImage,
|
||||||
|
onGenerateVideo,
|
||||||
|
isRewriting,
|
||||||
|
isGeneratingImage,
|
||||||
|
t,
|
||||||
|
onImageClick,
|
||||||
|
}: SegmentCardProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const updateSegment = useUpdateSegment(projectId);
|
||||||
|
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editedScript, setEditedScript] = useState(segment.narratorScript || '');
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
await updateSegment.mutateAsync({
|
||||||
|
id: segment.id,
|
||||||
|
data: { narratorScript: editedScript },
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeColor = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'Hook':
|
||||||
|
return 'red';
|
||||||
|
case 'Intro':
|
||||||
|
return 'blue';
|
||||||
|
case 'Outro':
|
||||||
|
return 'purple';
|
||||||
|
case 'CTA':
|
||||||
|
return 'green';
|
||||||
|
case 'Ad/Sponsor':
|
||||||
|
return 'orange';
|
||||||
|
default:
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Flex justify='space-between' align='center'>
|
||||||
|
<HStack gap={3}>
|
||||||
|
<Box color='gray.400' cursor='grab'>
|
||||||
|
<LuGripVertical />
|
||||||
|
</Box>
|
||||||
|
<Badge colorPalette={getTypeColor(segment.segmentType)}>
|
||||||
|
{segment.segmentType}
|
||||||
|
</Badge>
|
||||||
|
<Text fontSize='sm' color='gray.500'>
|
||||||
|
{segment.timeStart} ({segment.duration})
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack gap={1}>
|
||||||
|
{/* Generate Image Button */}
|
||||||
|
<Button
|
||||||
|
size='xs'
|
||||||
|
variant='ghost'
|
||||||
|
title={t('generateImage')}
|
||||||
|
onClick={onGenerateImage}
|
||||||
|
loading={isGeneratingImage}
|
||||||
|
disabled={isGeneratingImage}
|
||||||
|
>
|
||||||
|
<LuImage />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Generate Video Button */}
|
||||||
|
<Button
|
||||||
|
size='xs'
|
||||||
|
variant='ghost'
|
||||||
|
title={t('generateVideo')}
|
||||||
|
onClick={onGenerateVideo}
|
||||||
|
>
|
||||||
|
<LuVideo />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box width="1px" height="16px" bg="gray.200" mx={1} />
|
||||||
|
|
||||||
|
{/* Rewrite Menu */}
|
||||||
|
<Menu.Root>
|
||||||
|
<Menu.Trigger asChild>
|
||||||
|
<Button size='xs' variant='ghost' disabled={isRewriting}>
|
||||||
|
<LuRefreshCw />
|
||||||
|
<LuChevronDown />
|
||||||
|
</Button>
|
||||||
|
</Menu.Trigger>
|
||||||
|
<Menu.Content>
|
||||||
|
{REWRITE_STYLES.map((style) => (
|
||||||
|
<Menu.Item key={style} value={style} onClick={() => onRewrite(style)}>
|
||||||
|
{style}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu.Content>
|
||||||
|
</Menu.Root>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('edit')}
|
||||||
|
size='xs'
|
||||||
|
variant='ghost'
|
||||||
|
onClick={() => setIsEditing(!isEditing)}
|
||||||
|
>
|
||||||
|
<LuPencil />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('delete')}
|
||||||
|
size='xs'
|
||||||
|
variant='ghost'
|
||||||
|
colorPalette='red'
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<LuTrash2 />
|
||||||
|
</IconButton>
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
</Card.Header>
|
||||||
|
|
||||||
|
<Card.Body pt={0}>
|
||||||
|
<VStack align='stretch' gap={3}>
|
||||||
|
{/* Visual Description & Generated Image */}
|
||||||
|
{/* Visual Section */}
|
||||||
|
{(segment.visualDescription || segment.generatedImageUrl) && (
|
||||||
|
<Box borderWidth="1px" borderRadius="md" p={3} borderColor="blue.100" bg="blue.50/50" _dark={{ bg: "blue.900/20", borderColor: "blue.800" }}>
|
||||||
|
<HStack align='start' gap={4}>
|
||||||
|
{segment.generatedImageUrl && (
|
||||||
|
<Box
|
||||||
|
flexShrink={0}
|
||||||
|
width="200px" // Larger size
|
||||||
|
height="112px" // 16:9 aspect ratio
|
||||||
|
borderRadius="md"
|
||||||
|
overflow="hidden"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
bg="gray.100"
|
||||||
|
shadow="sm"
|
||||||
|
cursor="zoom-in"
|
||||||
|
onClick={() => onImageClick(segment.generatedImageUrl!)}
|
||||||
|
_hover={{ shadow: 'md', transform: 'scale(1.02)', transition: 'all 0.2s' }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={segment.generatedImageUrl}
|
||||||
|
alt="Generated scene"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box flex={1}>
|
||||||
|
<Text fontSize='xs' fontWeight='bold' color='blue.500' mb={1} textTransform="uppercase" letterSpacing="wider">
|
||||||
|
🎬 {t('visual')}
|
||||||
|
</Text>
|
||||||
|
{segment.visualDescription && (
|
||||||
|
<Text fontSize='sm' color='gray.600'>
|
||||||
|
{segment.visualDescription}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Narrator Script */}
|
||||||
|
<Box>
|
||||||
|
<Text fontSize='xs' fontWeight='semibold' color='green.500'>
|
||||||
|
🎤 {t('narrator')}
|
||||||
|
</Text>
|
||||||
|
{isEditing ? (
|
||||||
|
<HStack align='start'>
|
||||||
|
<Textarea
|
||||||
|
value={editedScript}
|
||||||
|
onChange={(e) => setEditedScript(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
colorPalette='green'
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={updateSegment.isPending}
|
||||||
|
>
|
||||||
|
<LuSave />
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
) : (
|
||||||
|
<Text fontSize='sm'>{segment.narratorScript}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* AI Prompts (Image & Video) */}
|
||||||
|
<Box pt={2} borderTopWidth="1px" borderColor="gray.100">
|
||||||
|
<Text fontSize='xs' fontWeight='bold' color='purple.500' mb={2}>
|
||||||
|
🤖 AI Prompts
|
||||||
|
</Text>
|
||||||
|
<VStack align='stretch' gap={3}>
|
||||||
|
{/* Image Prompt */}
|
||||||
|
<Box bg="gray.50" p={2} borderRadius="md" _dark={{ bg: "whiteAlpha.100" }}>
|
||||||
|
<Flex justify="space-between" align="center" mb={1}>
|
||||||
|
<Text fontSize="xs" fontWeight="semibold" color="gray.500">Image Prompt</Text>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Copy image prompt"
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(segment.imagePrompt || segment.visualDescription || '');
|
||||||
|
toaster.create({ title: 'Copied to clipboard', type: 'success' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuCopy />
|
||||||
|
</IconButton>
|
||||||
|
</Flex>
|
||||||
|
<Text fontSize="xs" color="gray.600" fontFamily="mono" lineClamp={3}>
|
||||||
|
{segment.imagePrompt || segment.visualDescription || t('noVisualAssets')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Video Prompt */}
|
||||||
|
<Box bg="gray.50" p={2} borderRadius="md" _dark={{ bg: "whiteAlpha.100" }}>
|
||||||
|
<Flex justify="space-between" align="center" mb={1}>
|
||||||
|
<Text fontSize="xs" fontWeight="semibold" color="gray.500">Video Prompt</Text>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Copy video prompt"
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(segment.videoPrompt || segment.visualDescription || '');
|
||||||
|
toaster.create({ title: 'Copied to clipboard', type: 'success' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuCopy />
|
||||||
|
</IconButton>
|
||||||
|
</Flex>
|
||||||
|
<Text fontSize="xs" color="gray.600" fontFamily="mono" lineClamp={3}>
|
||||||
|
{segment.videoPrompt || segment.visualDescription || t('noVisualAssets')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Additional Info */}
|
||||||
|
<HStack gap={4} fontSize='xs' color='gray.500' flexWrap='wrap'>
|
||||||
|
{segment.onScreenText && (
|
||||||
|
<Text>📝 {segment.onScreenText}</Text>
|
||||||
|
)}
|
||||||
|
{segment.audioCues && (
|
||||||
|
<Text>🔊 {segment.audioCues}</Text>
|
||||||
|
)}
|
||||||
|
{segment.stockQuery && (
|
||||||
|
<Text>🎥 {segment.stockQuery}</Text>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/components/skriptai/tabs/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { default as ResearchTab } from './ResearchTab';
|
||||||
|
export { default as BriefTab } from './BriefTab';
|
||||||
|
export { default as CharactersTab } from './CharactersTab';
|
||||||
|
export { default as ScriptTab } from './ScriptTab';
|
||||||
|
export { default as AnalysisTab } from './AnalysisTab';
|
||||||
BIN
src/components/ui/.DS_Store
vendored
Normal file
53
src/components/ui/back-to-top.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Icon, IconButton, Presence } from '@chakra-ui/react';
|
||||||
|
import { FiChevronUp } from 'react-icons/fi';
|
||||||
|
|
||||||
|
const BackToTop = () => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
setIsVisible(window.pageYOffset > 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollToTop = () => {
|
||||||
|
window.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Presence
|
||||||
|
unmountOnExit
|
||||||
|
present={isVisible}
|
||||||
|
animationName={{ _open: 'fade-in', _closed: 'fade-out' }}
|
||||||
|
animationDuration='moderate'
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
variant={{ base: 'solid', _dark: 'subtle' }}
|
||||||
|
aria-label='Back to top'
|
||||||
|
position='fixed'
|
||||||
|
bottom='8'
|
||||||
|
right='8'
|
||||||
|
borderRadius='full'
|
||||||
|
size='lg'
|
||||||
|
shadow='lg'
|
||||||
|
zIndex='999'
|
||||||
|
onClick={scrollToTop}
|
||||||
|
>
|
||||||
|
<Icon>
|
||||||
|
<FiChevronUp />
|
||||||
|
</Icon>
|
||||||
|
</IconButton>
|
||||||
|
</Presence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BackToTop;
|
||||||
33
src/components/ui/buttons/button.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { ButtonProps as ChakraButtonProps } from '@chakra-ui/react';
|
||||||
|
import { AbsoluteCenter, Button as ChakraButton, Span, Spinner } from '@chakra-ui/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface ButtonLoadingProps {
|
||||||
|
loading?: boolean;
|
||||||
|
loadingText?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {}
|
||||||
|
|
||||||
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(props, ref) {
|
||||||
|
const { loading, disabled, loadingText, children, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<ChakraButton disabled={loading || disabled} ref={ref} {...rest}>
|
||||||
|
{loading && !loadingText ? (
|
||||||
|
<>
|
||||||
|
<AbsoluteCenter display='inline-flex'>
|
||||||
|
<Spinner size='inherit' color='inherit' />
|
||||||
|
</AbsoluteCenter>
|
||||||
|
<Span opacity={0}>{children}</Span>
|
||||||
|
</>
|
||||||
|
) : loading && loadingText ? (
|
||||||
|
<>
|
||||||
|
<Spinner size='inherit' color='inherit' />
|
||||||
|
{loadingText}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</ChakraButton>
|
||||||
|
);
|
||||||
|
});
|
||||||
14
src/components/ui/buttons/close-button.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ButtonProps } from '@chakra-ui/react';
|
||||||
|
import { IconButton as ChakraIconButton } from '@chakra-ui/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { LuX } from 'react-icons/lu';
|
||||||
|
|
||||||
|
export type CloseButtonProps = ButtonProps;
|
||||||
|
|
||||||
|
export const CloseButton = React.forwardRef<HTMLButtonElement, CloseButtonProps>(function CloseButton(props, ref) {
|
||||||
|
return (
|
||||||
|
<ChakraIconButton variant='ghost' aria-label='Close' ref={ref} {...props}>
|
||||||
|
{props.children ?? <LuX />}
|
||||||
|
</ChakraIconButton>
|
||||||
|
);
|
||||||
|
});
|
||||||
11
src/components/ui/buttons/link-button.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { HTMLChakraProps, RecipeProps } from '@chakra-ui/react';
|
||||||
|
import { createRecipeContext } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
export interface LinkButtonProps extends HTMLChakraProps<'a', RecipeProps<'button'>> {}
|
||||||
|
|
||||||
|
const { withContext } = createRecipeContext({ key: 'button' });
|
||||||
|
|
||||||
|
// Replace "a" with your framework's link component
|
||||||
|
export const LinkButton = withContext<HTMLAnchorElement, LinkButtonProps>('a');
|
||||||
44
src/components/ui/buttons/toggle.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ButtonProps } from '@chakra-ui/react';
|
||||||
|
import { Button, Toggle as ChakraToggle, useToggleContext } from '@chakra-ui/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface ToggleProps extends ChakraToggle.RootProps {
|
||||||
|
variant?: keyof typeof variantMap;
|
||||||
|
size?: ButtonProps['size'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantMap = {
|
||||||
|
solid: { on: 'solid', off: 'outline' },
|
||||||
|
surface: { on: 'surface', off: 'outline' },
|
||||||
|
subtle: { on: 'subtle', off: 'ghost' },
|
||||||
|
ghost: { on: 'subtle', off: 'ghost' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(function Toggle(props, ref) {
|
||||||
|
const { variant = 'subtle', size, children, ...rest } = props;
|
||||||
|
const variantConfig = variantMap[variant];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChakraToggle.Root asChild {...rest}>
|
||||||
|
<ToggleBaseButton size={size} variant={variantConfig} ref={ref}>
|
||||||
|
{children}
|
||||||
|
</ToggleBaseButton>
|
||||||
|
</ChakraToggle.Root>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ToggleBaseButtonProps extends Omit<ButtonProps, 'variant'> {
|
||||||
|
variant: Record<'on' | 'off', ButtonProps['variant']>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToggleBaseButton = React.forwardRef<HTMLButtonElement, ToggleBaseButtonProps>(
|
||||||
|
function ToggleBaseButton(props, ref) {
|
||||||
|
const toggle = useToggleContext();
|
||||||
|
const { variant, ...rest } = props;
|
||||||
|
return <Button variant={toggle.pressed ? variant.on : variant.off} ref={ref} {...rest} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ToggleIndicator = ChakraToggle.Indicator;
|
||||||
91
src/components/ui/collections/combobox.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Combobox as ChakraCombobox, Portal } from '@chakra-ui/react';
|
||||||
|
import { CloseButton } from '@/components/ui/buttons/close-button';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface ComboboxControlProps extends ChakraCombobox.ControlProps {
|
||||||
|
clearable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ComboboxControl = React.forwardRef<HTMLDivElement, ComboboxControlProps>(
|
||||||
|
function ComboboxControl(props, ref) {
|
||||||
|
const { children, clearable, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<ChakraCombobox.Control {...rest} ref={ref}>
|
||||||
|
{children}
|
||||||
|
<ChakraCombobox.IndicatorGroup>
|
||||||
|
{clearable && <ComboboxClearTrigger />}
|
||||||
|
<ChakraCombobox.Trigger />
|
||||||
|
</ChakraCombobox.IndicatorGroup>
|
||||||
|
</ChakraCombobox.Control>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const ComboboxClearTrigger = React.forwardRef<HTMLButtonElement, ChakraCombobox.ClearTriggerProps>(
|
||||||
|
function ComboboxClearTrigger(props, ref) {
|
||||||
|
return (
|
||||||
|
<ChakraCombobox.ClearTrigger asChild {...props} ref={ref}>
|
||||||
|
<CloseButton size='xs' variant='plain' focusVisibleRing='inside' focusRingWidth='2px' pointerEvents='auto' />
|
||||||
|
</ChakraCombobox.ClearTrigger>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ComboboxContentProps extends ChakraCombobox.ContentProps {
|
||||||
|
portalled?: boolean;
|
||||||
|
portalRef?: React.RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ComboboxContent = React.forwardRef<HTMLDivElement, ComboboxContentProps>(
|
||||||
|
function ComboboxContent(props, ref) {
|
||||||
|
const { portalled = true, portalRef, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<Portal disabled={!portalled} container={portalRef}>
|
||||||
|
<ChakraCombobox.Positioner>
|
||||||
|
<ChakraCombobox.Content {...rest} ref={ref} />
|
||||||
|
</ChakraCombobox.Positioner>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ComboboxItem = React.forwardRef<HTMLDivElement, ChakraCombobox.ItemProps>(
|
||||||
|
function ComboboxItem(props, ref) {
|
||||||
|
const { item, children, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<ChakraCombobox.Item key={item.value} item={item} {...rest} ref={ref}>
|
||||||
|
{children}
|
||||||
|
<ChakraCombobox.ItemIndicator />
|
||||||
|
</ChakraCombobox.Item>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ComboboxRoot = React.forwardRef<HTMLDivElement, ChakraCombobox.RootProps>(
|
||||||
|
function ComboboxRoot(props, ref) {
|
||||||
|
return <ChakraCombobox.Root {...props} ref={ref} positioning={{ sameWidth: true, ...props.positioning }} />;
|
||||||
|
},
|
||||||
|
) as ChakraCombobox.RootComponent;
|
||||||
|
|
||||||
|
interface ComboboxItemGroupProps extends ChakraCombobox.ItemGroupProps {
|
||||||
|
label: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ComboboxItemGroup = React.forwardRef<HTMLDivElement, ComboboxItemGroupProps>(
|
||||||
|
function ComboboxItemGroup(props, ref) {
|
||||||
|
const { children, label, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<ChakraCombobox.ItemGroup {...rest} ref={ref}>
|
||||||
|
<ChakraCombobox.ItemGroupLabel>{label}</ChakraCombobox.ItemGroupLabel>
|
||||||
|
{children}
|
||||||
|
</ChakraCombobox.ItemGroup>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ComboboxLabel = ChakraCombobox.Label;
|
||||||
|
export const ComboboxInput = ChakraCombobox.Input;
|
||||||
|
export const ComboboxEmpty = ChakraCombobox.Empty;
|
||||||
|
export const ComboboxItemText = ChakraCombobox.ItemText;
|
||||||
28
src/components/ui/collections/listbox.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Listbox as ChakraListbox } from '@chakra-ui/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export const ListboxRoot = React.forwardRef<HTMLDivElement, ChakraListbox.RootProps>(function ListboxRoot(props, ref) {
|
||||||
|
return <ChakraListbox.Root {...props} ref={ref} />;
|
||||||
|
}) as ChakraListbox.RootComponent;
|
||||||
|
|
||||||
|
export const ListboxContent = React.forwardRef<HTMLDivElement, ChakraListbox.ContentProps>(
|
||||||
|
function ListboxContent(props, ref) {
|
||||||
|
return <ChakraListbox.Content {...props} ref={ref} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ListboxItem = React.forwardRef<HTMLDivElement, ChakraListbox.ItemProps>(function ListboxItem(props, ref) {
|
||||||
|
const { children, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<ChakraListbox.Item {...rest} ref={ref}>
|
||||||
|
{children}
|
||||||
|
<ChakraListbox.ItemIndicator />
|
||||||
|
</ChakraListbox.Item>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ListboxLabel = ChakraListbox.Label;
|
||||||
|
export const ListboxItemText = ChakraListbox.ItemText;
|
||||||
|
export const ListboxEmpty = ChakraListbox.Empty;
|
||||||
118
src/components/ui/collections/select.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { CollectionItem } from '@chakra-ui/react';
|
||||||
|
import { Select as ChakraSelect, Portal } from '@chakra-ui/react';
|
||||||
|
import { CloseButton } from '../buttons/close-button';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface SelectTriggerProps extends ChakraSelect.ControlProps {
|
||||||
|
clearable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerProps>(
|
||||||
|
function SelectTrigger(props, ref) {
|
||||||
|
const { children, clearable, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<ChakraSelect.Control {...rest}>
|
||||||
|
<ChakraSelect.Trigger ref={ref}>{children}</ChakraSelect.Trigger>
|
||||||
|
<ChakraSelect.IndicatorGroup>
|
||||||
|
{clearable && <SelectClearTrigger />}
|
||||||
|
<ChakraSelect.Indicator />
|
||||||
|
</ChakraSelect.IndicatorGroup>
|
||||||
|
</ChakraSelect.Control>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const SelectClearTrigger = React.forwardRef<HTMLButtonElement, ChakraSelect.ClearTriggerProps>(
|
||||||
|
function SelectClearTrigger(props, ref) {
|
||||||
|
return (
|
||||||
|
<ChakraSelect.ClearTrigger asChild {...props} ref={ref}>
|
||||||
|
<CloseButton size='xs' variant='plain' focusVisibleRing='inside' focusRingWidth='2px' pointerEvents='auto' />
|
||||||
|
</ChakraSelect.ClearTrigger>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SelectContentProps extends ChakraSelect.ContentProps {
|
||||||
|
portalled?: boolean;
|
||||||
|
portalRef?: React.RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectContent = React.forwardRef<HTMLDivElement, SelectContentProps>(function SelectContent(props, ref) {
|
||||||
|
const { portalled = true, portalRef, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<Portal disabled={!portalled} container={portalRef}>
|
||||||
|
<ChakraSelect.Positioner>
|
||||||
|
<ChakraSelect.Content {...rest} ref={ref} />
|
||||||
|
</ChakraSelect.Positioner>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SelectItem = React.forwardRef<HTMLDivElement, ChakraSelect.ItemProps>(function SelectItem(props, ref) {
|
||||||
|
const { item, children, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<ChakraSelect.Item key={item.value} item={item} {...rest} ref={ref}>
|
||||||
|
{children}
|
||||||
|
<ChakraSelect.ItemIndicator />
|
||||||
|
</ChakraSelect.Item>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SelectValueTextProps extends Omit<ChakraSelect.ValueTextProps, 'children'> {
|
||||||
|
children?(items: CollectionItem[]): React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectValueText = React.forwardRef<HTMLSpanElement, SelectValueTextProps>(
|
||||||
|
function SelectValueText(props, ref) {
|
||||||
|
const { children, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<ChakraSelect.ValueText {...rest} ref={ref}>
|
||||||
|
<ChakraSelect.Context>
|
||||||
|
{(select) => {
|
||||||
|
const items = select.selectedItems;
|
||||||
|
if (items.length === 0) return props.placeholder;
|
||||||
|
if (children) return children(items);
|
||||||
|
if (items.length === 1) return select.collection.stringifyItem(items[0]);
|
||||||
|
return `${items.length} selected`;
|
||||||
|
}}
|
||||||
|
</ChakraSelect.Context>
|
||||||
|
</ChakraSelect.ValueText>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SelectRoot = React.forwardRef<HTMLDivElement, ChakraSelect.RootProps>(function SelectRoot(props, ref) {
|
||||||
|
return (
|
||||||
|
<ChakraSelect.Root {...props} ref={ref} positioning={{ sameWidth: true, ...props.positioning }}>
|
||||||
|
{props.asChild ? (
|
||||||
|
props.children
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChakraSelect.HiddenSelect />
|
||||||
|
{props.children}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ChakraSelect.Root>
|
||||||
|
);
|
||||||
|
}) as ChakraSelect.RootComponent;
|
||||||
|
|
||||||
|
interface SelectItemGroupProps extends ChakraSelect.ItemGroupProps {
|
||||||
|
label: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectItemGroup = React.forwardRef<HTMLDivElement, SelectItemGroupProps>(
|
||||||
|
function SelectItemGroup(props, ref) {
|
||||||
|
const { children, label, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<ChakraSelect.ItemGroup {...rest} ref={ref}>
|
||||||
|
<ChakraSelect.ItemGroupLabel>{label}</ChakraSelect.ItemGroupLabel>
|
||||||
|
{children}
|
||||||
|
</ChakraSelect.ItemGroup>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SelectLabel = ChakraSelect.Label;
|
||||||
|
export const SelectItemText = ChakraSelect.ItemText;
|
||||||
60
src/components/ui/collections/treeview.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { TreeView as ChakraTreeView } from '@chakra-ui/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export const TreeViewRoot = React.forwardRef<HTMLDivElement, ChakraTreeView.RootProps>(
|
||||||
|
function TreeViewRoot(props, ref) {
|
||||||
|
return <ChakraTreeView.Root {...props} ref={ref} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
interface TreeViewTreeProps extends ChakraTreeView.TreeProps {}
|
||||||
|
|
||||||
|
export const TreeViewTree = React.forwardRef<HTMLDivElement, TreeViewTreeProps>(function TreeViewTree(props, ref) {
|
||||||
|
const { ...rest } = props;
|
||||||
|
return <ChakraTreeView.Tree {...rest} ref={ref} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TreeViewBranch = React.forwardRef<HTMLDivElement, ChakraTreeView.BranchProps>(
|
||||||
|
function TreeViewBranch(props, ref) {
|
||||||
|
return <ChakraTreeView.Branch {...props} ref={ref} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TreeViewBranchControl = React.forwardRef<HTMLDivElement, ChakraTreeView.BranchControlProps>(
|
||||||
|
function TreeViewBranchControl(props, ref) {
|
||||||
|
return <ChakraTreeView.BranchControl {...props} ref={ref} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TreeViewItem = React.forwardRef<HTMLDivElement, ChakraTreeView.ItemProps>(
|
||||||
|
function TreeViewItem(props, ref) {
|
||||||
|
return <ChakraTreeView.Item {...props} ref={ref} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TreeViewLabel = ChakraTreeView.Label;
|
||||||
|
export const TreeViewBranchIndicator = ChakraTreeView.BranchIndicator;
|
||||||
|
export const TreeViewBranchText = ChakraTreeView.BranchText;
|
||||||
|
export const TreeViewBranchContent = ChakraTreeView.BranchContent;
|
||||||
|
export const TreeViewBranchIndentGuide = ChakraTreeView.BranchIndentGuide;
|
||||||
|
export const TreeViewItemText = ChakraTreeView.ItemText;
|
||||||
|
export const TreeViewNode = ChakraTreeView.Node;
|
||||||
|
export const TreeViewNodeProvider = ChakraTreeView.NodeProvider;
|
||||||
|
|
||||||
|
export const TreeView = {
|
||||||
|
Root: TreeViewRoot,
|
||||||
|
Label: TreeViewLabel,
|
||||||
|
Tree: TreeViewTree,
|
||||||
|
Branch: TreeViewBranch,
|
||||||
|
BranchControl: TreeViewBranchControl,
|
||||||
|
BranchIndicator: TreeViewBranchIndicator,
|
||||||
|
BranchText: TreeViewBranchText,
|
||||||
|
BranchContent: TreeViewBranchContent,
|
||||||
|
BranchIndentGuide: TreeViewBranchIndentGuide,
|
||||||
|
Item: TreeViewItem,
|
||||||
|
ItemText: TreeViewItemText,
|
||||||
|
Node: TreeViewNode,
|
||||||
|
NodeProvider: TreeViewNodeProvider,
|
||||||
|
};
|
||||||
108
src/components/ui/color-mode.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { IconButtonProps, SpanProps } from '@chakra-ui/react';
|
||||||
|
import { ClientOnly, IconButton, Skeleton, Span } from '@chakra-ui/react';
|
||||||
|
import { ThemeProvider, useTheme } from 'next-themes';
|
||||||
|
import type { ThemeProviderProps } from 'next-themes';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { LuMoon, LuSun } from 'react-icons/lu';
|
||||||
|
|
||||||
|
export interface ColorModeProviderProps extends ThemeProviderProps {}
|
||||||
|
|
||||||
|
export function ColorModeProvider(props: ColorModeProviderProps) {
|
||||||
|
return <ThemeProvider attribute='class' disableTransitionOnChange {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ColorMode = 'light' | 'dark';
|
||||||
|
|
||||||
|
export interface UseColorModeReturn {
|
||||||
|
colorMode: ColorMode;
|
||||||
|
setColorMode: (colorMode: ColorMode) => void;
|
||||||
|
toggleColorMode: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useColorMode(): UseColorModeReturn {
|
||||||
|
const { resolvedTheme, setTheme, forcedTheme } = useTheme();
|
||||||
|
const colorMode = forcedTheme || resolvedTheme;
|
||||||
|
const toggleColorMode = () => {
|
||||||
|
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
colorMode: colorMode as ColorMode,
|
||||||
|
setColorMode: setTheme,
|
||||||
|
toggleColorMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useColorModeValue<T>(light: T, dark: T) {
|
||||||
|
const { colorMode } = useColorMode();
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return light;
|
||||||
|
}
|
||||||
|
return colorMode === 'dark' ? dark : light;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColorModeIcon() {
|
||||||
|
const { colorMode } = useColorMode();
|
||||||
|
return colorMode === 'dark' ? <LuMoon /> : <LuSun />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColorModeButtonProps extends Omit<IconButtonProps, 'aria-label'> {}
|
||||||
|
|
||||||
|
export const ColorModeButton = React.forwardRef<HTMLButtonElement, ColorModeButtonProps>(
|
||||||
|
function ColorModeButton(props, ref) {
|
||||||
|
const { toggleColorMode } = useColorMode();
|
||||||
|
return (
|
||||||
|
<ClientOnly fallback={<Skeleton boxSize='9' />}>
|
||||||
|
<IconButton
|
||||||
|
onClick={toggleColorMode}
|
||||||
|
variant='ghost'
|
||||||
|
aria-label='Toggle color mode'
|
||||||
|
size='sm'
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
css={{
|
||||||
|
_icon: {
|
||||||
|
width: '5',
|
||||||
|
height: '5',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ColorModeIcon />
|
||||||
|
</IconButton>
|
||||||
|
</ClientOnly>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const LightMode = React.forwardRef<HTMLSpanElement, SpanProps>(function LightMode(props, ref) {
|
||||||
|
return (
|
||||||
|
<Span
|
||||||
|
color='fg'
|
||||||
|
display='contents'
|
||||||
|
className='chakra-theme light'
|
||||||
|
colorPalette='gray'
|
||||||
|
colorScheme='light'
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DarkMode = React.forwardRef<HTMLSpanElement, SpanProps>(function DarkMode(props, ref) {
|
||||||
|
return (
|
||||||
|
<Span
|
||||||
|
color='fg'
|
||||||
|
display='contents'
|
||||||
|
className='chakra-theme dark'
|
||||||
|
colorPalette='gray'
|
||||||
|
colorScheme='dark'
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
26
src/components/ui/data-display/avatar.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Avatar as ChakraAvatar, AvatarGroup as ChakraAvatarGroup } from '@chakra-ui/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
type ImageProps = React.ImgHTMLAttributes<HTMLImageElement>;
|
||||||
|
|
||||||
|
export interface AvatarProps extends ChakraAvatar.RootProps {
|
||||||
|
name?: string;
|
||||||
|
src?: string;
|
||||||
|
srcSet?: string;
|
||||||
|
loading?: ImageProps['loading'];
|
||||||
|
icon?: React.ReactElement;
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(function Avatar(props, ref) {
|
||||||
|
const { name, src, srcSet, loading, icon, fallback, children, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<ChakraAvatar.Root ref={ref} {...rest}>
|
||||||
|
<ChakraAvatar.Fallback name={name}>{icon || fallback}</ChakraAvatar.Fallback>
|
||||||
|
<ChakraAvatar.Image src={src} srcSet={srcSet} loading={loading} />
|
||||||
|
{children}
|
||||||
|
</ChakraAvatar.Root>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AvatarGroup = ChakraAvatarGroup;
|
||||||
79
src/components/ui/data-display/clipboard.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { ButtonProps, InputProps } from '@chakra-ui/react';
|
||||||
|
import { Button, Clipboard as ChakraClipboard, IconButton, Input } from '@chakra-ui/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { LuCheck, LuClipboard, LuLink } from 'react-icons/lu';
|
||||||
|
|
||||||
|
const ClipboardIcon = React.forwardRef<HTMLDivElement, ChakraClipboard.IndicatorProps>(
|
||||||
|
function ClipboardIcon(props, ref) {
|
||||||
|
return (
|
||||||
|
<ChakraClipboard.Indicator copied={<LuCheck />} {...props} ref={ref}>
|
||||||
|
<LuClipboard />
|
||||||
|
</ChakraClipboard.Indicator>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const ClipboardCopyText = React.forwardRef<HTMLDivElement, ChakraClipboard.IndicatorProps>(
|
||||||
|
function ClipboardCopyText(props, ref) {
|
||||||
|
return (
|
||||||
|
<ChakraClipboard.Indicator copied='Copied' {...props} ref={ref}>
|
||||||
|
Copy
|
||||||
|
</ChakraClipboard.Indicator>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ClipboardLabel = React.forwardRef<HTMLLabelElement, ChakraClipboard.LabelProps>(
|
||||||
|
function ClipboardLabel(props, ref) {
|
||||||
|
return (
|
||||||
|
<ChakraClipboard.Label textStyle='sm' fontWeight='medium' display='inline-block' mb='1' {...props} ref={ref} />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ClipboardButton = React.forwardRef<HTMLButtonElement, ButtonProps>(function ClipboardButton(props, ref) {
|
||||||
|
return (
|
||||||
|
<ChakraClipboard.Trigger asChild>
|
||||||
|
<Button ref={ref} size='sm' variant='surface' {...props}>
|
||||||
|
<ClipboardIcon />
|
||||||
|
<ClipboardCopyText />
|
||||||
|
</Button>
|
||||||
|
</ChakraClipboard.Trigger>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ClipboardLink = React.forwardRef<HTMLButtonElement, ButtonProps>(function ClipboardLink(props, ref) {
|
||||||
|
return (
|
||||||
|
<ChakraClipboard.Trigger asChild>
|
||||||
|
<Button unstyled variant='plain' size='xs' display='inline-flex' alignItems='center' gap='2' ref={ref} {...props}>
|
||||||
|
<LuLink />
|
||||||
|
<ClipboardCopyText />
|
||||||
|
</Button>
|
||||||
|
</ChakraClipboard.Trigger>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ClipboardIconButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
function ClipboardIconButton(props, ref) {
|
||||||
|
return (
|
||||||
|
<ChakraClipboard.Trigger asChild>
|
||||||
|
<IconButton ref={ref} size='xs' variant='subtle' {...props}>
|
||||||
|
<ClipboardIcon />
|
||||||
|
<ClipboardCopyText srOnly />
|
||||||
|
</IconButton>
|
||||||
|
</ChakraClipboard.Trigger>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ClipboardInput = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
function ClipboardInputElement(props, ref) {
|
||||||
|
return (
|
||||||
|
<ChakraClipboard.Input asChild>
|
||||||
|
<Input ref={ref} {...props} />
|
||||||
|
</ChakraClipboard.Input>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ClipboardRoot = ChakraClipboard.Root;
|
||||||
26
src/components/ui/data-display/data-list.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { DataList as ChakraDataList } from '@chakra-ui/react';
|
||||||
|
import { InfoTip } from '@/components/ui/overlays/toggle-tip';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export const DataListRoot = ChakraDataList.Root;
|
||||||
|
|
||||||
|
interface ItemProps extends ChakraDataList.ItemProps {
|
||||||
|
label: React.ReactNode;
|
||||||
|
value: React.ReactNode;
|
||||||
|
info?: React.ReactNode;
|
||||||
|
grow?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataListItem = React.forwardRef<HTMLDivElement, ItemProps>(function DataListItem(props, ref) {
|
||||||
|
const { label, info, value, children, grow, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<ChakraDataList.Item ref={ref} {...rest}>
|
||||||
|
<ChakraDataList.ItemLabel flex={grow ? '1' : undefined}>
|
||||||
|
{label}
|
||||||
|
{info && <InfoTip>{info}</InfoTip>}
|
||||||
|
</ChakraDataList.ItemLabel>
|
||||||
|
<ChakraDataList.ItemValue flex={grow ? '1' : undefined}>{value}</ChakraDataList.ItemValue>
|
||||||
|
{children}
|
||||||
|
</ChakraDataList.Item>
|
||||||
|
);
|
||||||
|
});
|
||||||
20
src/components/ui/data-display/qr-code.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { QrCode as ChakraQrCode } from '@chakra-ui/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export interface QrCodeProps extends Omit<ChakraQrCode.RootProps, 'fill' | 'overlay'> {
|
||||||
|
fill?: string;
|
||||||
|
overlay?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QrCode = React.forwardRef<HTMLDivElement, QrCodeProps>(function QrCode(props, ref) {
|
||||||
|
const { children, fill, overlay, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<ChakraQrCode.Root ref={ref} {...rest}>
|
||||||
|
<ChakraQrCode.Frame style={{ fill }}>
|
||||||
|
<ChakraQrCode.Pattern />
|
||||||
|
</ChakraQrCode.Frame>
|
||||||
|
{children}
|
||||||
|
{overlay && <ChakraQrCode.Overlay>{overlay}</ChakraQrCode.Overlay>}
|
||||||
|
</ChakraQrCode.Root>
|
||||||
|
);
|
||||||
|
});
|
||||||
53
src/components/ui/data-display/stat.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Badge, type BadgeProps, Stat as ChakraStat, FormatNumber } from '@chakra-ui/react';
|
||||||
|
import { InfoTip } from '@/components/ui/overlays/toggle-tip';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface StatLabelProps extends ChakraStat.LabelProps {
|
||||||
|
info?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatLabel = React.forwardRef<HTMLDivElement, StatLabelProps>(function StatLabel(props, ref) {
|
||||||
|
const { info, children, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<ChakraStat.Label {...rest} ref={ref}>
|
||||||
|
{children}
|
||||||
|
{info && <InfoTip>{info}</InfoTip>}
|
||||||
|
</ChakraStat.Label>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface StatValueTextProps extends ChakraStat.ValueTextProps {
|
||||||
|
value?: number;
|
||||||
|
formatOptions?: Intl.NumberFormatOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatValueText = React.forwardRef<HTMLDivElement, StatValueTextProps>(function StatValueText(props, ref) {
|
||||||
|
const { value, formatOptions, children, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<ChakraStat.ValueText {...rest} ref={ref}>
|
||||||
|
{children || (value != null && <FormatNumber value={value} {...formatOptions} />)}
|
||||||
|
</ChakraStat.ValueText>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StatUpTrend = React.forwardRef<HTMLDivElement, BadgeProps>(function StatUpTrend(props, ref) {
|
||||||
|
return (
|
||||||
|
<Badge colorPalette='green' gap='0' {...props} ref={ref}>
|
||||||
|
<ChakraStat.UpIndicator />
|
||||||
|
{props.children}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StatDownTrend = React.forwardRef<HTMLDivElement, BadgeProps>(function StatDownTrend(props, ref) {
|
||||||
|
return (
|
||||||
|
<Badge colorPalette='red' gap='0' {...props} ref={ref}>
|
||||||
|
<ChakraStat.DownIndicator />
|
||||||
|
{props.children}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StatRoot = ChakraStat.Root;
|
||||||
|
export const StatHelpText = ChakraStat.HelpText;
|
||||||
|
export const StatValueUnit = ChakraStat.ValueUnit;
|
||||||