1 && i < count - 1 ? '0.5rem' : undefined
26 | }}
27 | />
28 | ));
29 |
30 | return <>{skeletons}>;
31 | }
32 |
33 | // Article Card Skeleton
34 | export function ArticleCardSkeleton() {
35 | return (
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | );
51 | }
52 |
53 | // Article List Skeleton
54 | export function ArticleListSkeleton({ count = 5 }: { count?: number }) {
55 | return (
56 |
57 | {Array.from({ length: count }, (_, i) => (
58 |
59 | ))}
60 |
61 | );
62 | }
63 |
64 | // Chat Message Skeleton
65 | export function ChatMessageSkeleton() {
66 | return (
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | );
77 | }
--------------------------------------------------------------------------------
/docs/DATABASE_SETUP.md:
--------------------------------------------------------------------------------
1 | # Database Setup Guide
2 |
3 | ## Setting up Supabase for xFunnel
4 |
5 | ### 1. Local Development Setup
6 |
7 | If you're using Supabase locally or haven't set up the database yet, follow these steps:
8 |
9 | #### Prerequisites
10 | - Ensure you have your Supabase project URL and keys in `.env.local`:
11 | ```bash
12 | NEXT_PUBLIC_SUPABASE_URL=your-supabase-url
13 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
14 | SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
15 | JWT_SECRET=your-jwt-secret
16 | ```
17 |
18 | #### Running Migrations
19 |
20 | 1. **Option A: Using Supabase SQL Editor (Recommended)**
21 | - Go to your Supabase project dashboard
22 | - Navigate to SQL Editor
23 | - Run each migration file in order:
24 | - `000_create_articles_table.sql`
25 | - `001_create_users_table.sql`
26 | - `002_update_articles_table.sql`
27 | - `003_create_workers_table.sql`
28 | - `004_update_workers_table_activity_tracking.sql`
29 |
30 | 2. **Option B: Using the setup script**
31 | ```bash
32 | npm install
33 | node scripts/setup-database.js
34 | ```
35 |
36 | ### 2. Testing the Setup
37 |
38 | After running the migrations, test your setup:
39 |
40 | 1. Try registering a new user at `/login`
41 | 2. Create and edit articles
42 | 3. Check if data persists in Supabase
43 |
44 | ### 3. Troubleshooting
45 |
46 | #### "User with this email already exists" error when no user exists
47 | This usually means:
48 | - The database connection is failing
49 | - The users table doesn't exist
50 | - There's a connectivity issue with Supabase
51 |
52 | **Solution:**
53 | 1. Check your environment variables in `.env.local`
54 | 2. Ensure all migrations have been run
55 | 3. Check Supabase dashboard for any errors
56 | 4. Verify your Supabase URL and keys are correct
57 |
58 | #### Cannot connect to Supabase
59 | 1. Ensure your Supabase project is active
60 | 2. Check if your IP is allowed in Supabase settings (for production)
61 | 3. Verify environment variables are correctly set
62 |
63 | ### 4. Development without Supabase
64 |
65 | If you want to develop without Supabase connection:
66 | - The app will use mock data for authentication
67 | - Articles will be stored in memory (not persisted)
68 | - Use these test credentials:
69 | - Email: `test@example.com`
70 | - Password: `password`
71 |
72 | ### 5. Clearing Test Data
73 |
74 | To clear all test data:
75 | ```sql
76 | -- Run in Supabase SQL Editor
77 | TRUNCATE TABLE users CASCADE;
78 | TRUNCATE TABLE articles CASCADE;
79 | TRUNCATE TABLE workers CASCADE;
80 | ```
81 |
82 | **Warning:** This will delete all data in these tables!
--------------------------------------------------------------------------------
/.cursor/rules/self_improve.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description: Guidelines for continuously improving Cursor rules based on emerging code patterns and best practices.
3 | globs: **/*
4 | alwaysApply: true
5 | ---
6 |
7 | - **Rule Improvement Triggers:**
8 | - New code patterns not covered by existing rules
9 | - Repeated similar implementations across files
10 | - Common error patterns that could be prevented
11 | - New libraries or tools being used consistently
12 | - Emerging best practices in the codebase
13 |
14 | - **Analysis Process:**
15 | - Compare new code with existing rules
16 | - Identify patterns that should be standardized
17 | - Look for references to external documentation
18 | - Check for consistent error handling patterns
19 | - Monitor test patterns and coverage
20 |
21 | - **Rule Updates:**
22 | - **Add New Rules When:**
23 | - A new technology/pattern is used in 3+ files
24 | - Common bugs could be prevented by a rule
25 | - Code reviews repeatedly mention the same feedback
26 | - New security or performance patterns emerge
27 |
28 | - **Modify Existing Rules When:**
29 | - Better examples exist in the codebase
30 | - Additional edge cases are discovered
31 | - Related rules have been updated
32 | - Implementation details have changed
33 |
34 | - **Example Pattern Recognition:**
35 | ```typescript
36 | // If you see repeated patterns like:
37 | const data = await prisma.user.findMany({
38 | select: { id: true, email: true },
39 | where: { status: 'ACTIVE' }
40 | });
41 |
42 | // Consider adding to [prisma.mdc](mdc:.cursor/rules/prisma.mdc):
43 | // - Standard select fields
44 | // - Common where conditions
45 | // - Performance optimization patterns
46 | ```
47 |
48 | - **Rule Quality Checks:**
49 | - Rules should be actionable and specific
50 | - Examples should come from actual code
51 | - References should be up to date
52 | - Patterns should be consistently enforced
53 |
54 | - **Continuous Improvement:**
55 | - Monitor code review comments
56 | - Track common development questions
57 | - Update rules after major refactors
58 | - Add links to relevant documentation
59 | - Cross-reference related rules
60 |
61 | - **Rule Deprecation:**
62 | - Mark outdated patterns as deprecated
63 | - Remove rules that no longer apply
64 | - Update references to deprecated rules
65 | - Document migration paths for old patterns
66 |
67 | - **Documentation Updates:**
68 | - Keep examples synchronized with code
69 | - Update references to external docs
70 | - Maintain links between related rules
71 | - Document breaking changes
72 | Follow [cursor_rules.mdc](mdc:.cursor/rules/cursor_rules.mdc) for proper rule formatting and structure.
73 |
--------------------------------------------------------------------------------
/supabase/migrations/004_update_workers_table_activity_tracking.sql:
--------------------------------------------------------------------------------
1 | -- Update workers table to better support activity tracking
2 | -- Add columns for focus/blur tracking and read percentage
3 |
4 | -- Add new columns for better activity tracking
5 | ALTER TABLE workers
6 | ADD COLUMN IF NOT EXISTS focus_count INTEGER DEFAULT 0,
7 | ADD COLUMN IF NOT EXISTS blur_count INTEGER DEFAULT 0,
8 | ADD COLUMN IF NOT EXISTS read_percentage INTEGER DEFAULT 0,
9 | ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT true,
10 | ADD COLUMN IF NOT EXISTS last_active TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc', NOW());
11 |
12 | -- Create activity_logs view for backwards compatibility and analytics
13 | CREATE OR REPLACE VIEW activity_logs AS
14 | SELECT
15 | id,
16 | user_id,
17 | article_id,
18 | session_start,
19 | session_end,
20 | time_spent_seconds,
21 | focus_count,
22 | blur_count,
23 | read_percentage,
24 | is_active,
25 | last_active,
26 | ai_requests_count,
27 | manual_edits_count,
28 | words_added,
29 | words_deleted,
30 | initial_word_count,
31 | final_word_count,
32 | created_at
33 | FROM workers;
34 |
35 | -- Grant access to the view
36 | GRANT SELECT ON activity_logs TO authenticated;
37 |
38 | -- Create function to calculate session metrics
39 | CREATE OR REPLACE FUNCTION calculate_session_metrics(p_user_id UUID, p_article_id UUID)
40 | RETURNS TABLE (
41 | total_sessions BIGINT,
42 | total_time_spent_seconds BIGINT,
43 | avg_time_per_session NUMERIC,
44 | total_ai_requests BIGINT,
45 | avg_read_percentage NUMERIC,
46 | total_focus_events BIGINT,
47 | total_blur_events BIGINT,
48 | last_session_date TIMESTAMP WITH TIME ZONE
49 | ) AS $$
50 | BEGIN
51 | RETURN QUERY
52 | SELECT
53 | COUNT(*)::BIGINT as total_sessions,
54 | COALESCE(SUM(time_spent_seconds), 0)::BIGINT as total_time_spent_seconds,
55 | COALESCE(AVG(time_spent_seconds), 0)::NUMERIC as avg_time_per_session,
56 | COALESCE(SUM(ai_requests_count), 0)::BIGINT as total_ai_requests,
57 | COALESCE(AVG(read_percentage), 0)::NUMERIC as avg_read_percentage,
58 | COALESCE(SUM(focus_count), 0)::BIGINT as total_focus_events,
59 | COALESCE(SUM(blur_count), 0)::BIGINT as total_blur_events,
60 | MAX(session_start) as last_session_date
61 | FROM workers
62 | WHERE
63 | (p_user_id IS NULL OR user_id = p_user_id) AND
64 | (p_article_id IS NULL OR article_id = p_article_id);
65 | END;
66 | $$ LANGUAGE plpgsql;
67 |
68 | -- Grant execute permission on the function
69 | GRANT EXECUTE ON FUNCTION calculate_session_metrics TO authenticated;
70 |
71 | -- Create index for better performance on activity queries
72 | CREATE INDEX IF NOT EXISTS idx_workers_last_active ON workers(last_active DESC);
73 | CREATE INDEX IF NOT EXISTS idx_workers_is_active ON workers(is_active) WHERE is_active = true;
--------------------------------------------------------------------------------
/app/components/ColorThemeSelector.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useState } from 'react';
4 | import { useTheme, AccentColor } from './ThemeProvider';
5 | import { Check, Palette } from 'lucide-react';
6 |
7 | const colorOptions: { color: AccentColor; hex: string; name: string }[] = [
8 | { color: 'blue', hex: '#3b82f6', name: 'Blue' },
9 | { color: 'purple', hex: '#a855f7', name: 'Purple' },
10 | { color: 'green', hex: '#22c55e', name: 'Green' },
11 | { color: 'red', hex: '#ef4444', name: 'Red' },
12 | { color: 'orange', hex: '#f97316', name: 'Orange' },
13 | { color: 'yellow', hex: '#f59e0b', name: 'Yellow' },
14 | { color: 'pink', hex: '#ec4899', name: 'Pink' },
15 | { color: 'teal', hex: '#14b8a6', name: 'Teal' },
16 | ];
17 |
18 | export default function ColorThemeSelector() {
19 | const { accentColor, setAccentColor } = useTheme();
20 | const [isOpen, setIsOpen] = useState(false);
21 |
22 | return (
23 |
24 |
31 |
32 | {isOpen && (
33 | <>
34 | {/* Backdrop */}
35 |
setIsOpen(false)}
38 | />
39 |
40 | {/* Dropdown */}
41 |
42 |
43 | {colorOptions.map(({ color, hex, name }) => (
44 |
62 | ))}
63 |
64 |
65 | >
66 | )}
67 |
68 | );
69 | }
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import type { NextRequest } from 'next/server';
3 |
4 | export function middleware(request: NextRequest) {
5 | // Get response
6 | const response = NextResponse.next();
7 |
8 | // Add security headers
9 | response.headers.set('X-Frame-Options', 'DENY');
10 | response.headers.set('X-Content-Type-Options', 'nosniff');
11 | response.headers.set('X-XSS-Protection', '1; mode=block');
12 | response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
13 | response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
14 |
15 | // Content Security Policy
16 | const cspDirectives = [
17 | "default-src 'self'",
18 | "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net", // For Next.js and React
19 | "style-src 'self' 'unsafe-inline'", // For Tailwind CSS
20 | "img-src 'self' data: blob: https:",
21 | "font-src 'self' data:",
22 | "connect-src 'self' https://api.anthropic.com https://*.supabase.co wss://*.supabase.co",
23 | "media-src 'none'",
24 | "object-src 'none'",
25 | "frame-src 'none'",
26 | "base-uri 'self'",
27 | "form-action 'self'",
28 | "frame-ancestors 'none'",
29 | "upgrade-insecure-requests"
30 | ];
31 |
32 | // In development, allow more sources for hot reload
33 | if (process.env.NODE_ENV === 'development') {
34 | cspDirectives[1] = "script-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:* ws://localhost:*";
35 | cspDirectives[5] = "connect-src 'self' http://localhost:* ws://localhost:* https://api.anthropic.com https://*.supabase.co wss://*.supabase.co";
36 | }
37 |
38 | response.headers.set('Content-Security-Policy', cspDirectives.join('; '));
39 |
40 | // Strict Transport Security (only in production)
41 | if (process.env.NODE_ENV === 'production') {
42 | response.headers.set(
43 | 'Strict-Transport-Security',
44 | 'max-age=31536000; includeSubDomains; preload'
45 | );
46 | }
47 |
48 | // Don't protect login/register routes and API auth routes
49 | const publicPaths = ['/login', '/api/auth/login', '/api/auth/register'];
50 |
51 | if (publicPaths.includes(request.nextUrl.pathname)) {
52 | return response;
53 | }
54 |
55 | // Authentication check can be added here if needed
56 | // For now, authentication is handled in components
57 |
58 | return response;
59 | }
60 |
61 | export const config = {
62 | matcher: [
63 | /*
64 | * Match all request paths except for the ones starting with:
65 | * - _next/static (static files)
66 | * - _next/image (image optimization files)
67 | * - favicon.ico (favicon file)
68 | * - public assets
69 | */
70 | '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
71 | ],
72 | };
--------------------------------------------------------------------------------
/app/api/auth/login/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from 'next/server';
2 | import { authenticateUser, generateToken } from '@/lib/auth';
3 | import { logger } from '@/lib/logger';
4 | import { withLogging } from '@/lib/logger';
5 | import { ValidationError, AuthenticationError, formatErrorResponse } from '@/lib/error-handler';
6 | import { rateLimit, getClientIdentifier } from '@/lib/rate-limiter';
7 |
8 | export const POST = withLogging(async (request: NextRequest) => {
9 | const startTime = Date.now();
10 |
11 | try {
12 | // Rate limiting
13 | const clientId = getClientIdentifier(request);
14 | const { success, remaining, reset } = await rateLimit(clientId, 'auth');
15 |
16 | if (!success) {
17 | return NextResponse.json(
18 | {
19 | error: 'Too many login attempts. Please try again later.',
20 | retryAfter: Math.ceil((reset - Date.now()) / 1000)
21 | },
22 | {
23 | status: 429,
24 | headers: {
25 | 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
26 | 'X-RateLimit-Remaining': '0',
27 | 'X-RateLimit-Reset': new Date(reset).toISOString()
28 | }
29 | }
30 | );
31 | }
32 |
33 | // Parse request body
34 | let body;
35 | try {
36 | body = await request.json();
37 | } catch (e) {
38 | throw new ValidationError('Invalid JSON in request body');
39 | }
40 |
41 | const { email, password } = body;
42 |
43 | // Validate input
44 | if (!email || !password) {
45 | throw new ValidationError('Email and password are required');
46 | }
47 |
48 | if (!email.includes('@')) {
49 | throw new ValidationError('Invalid email format');
50 | }
51 |
52 | // Log authentication attempt (without password)
53 | logger.info('Login attempt', {
54 | email,
55 | userAgent: request.headers.get('user-agent') || undefined
56 | });
57 |
58 | // Authenticate user
59 | const user = await authenticateUser(email, password);
60 |
61 | if (!user) {
62 | logger.warn('Failed login attempt', { email });
63 | throw new AuthenticationError('Invalid email or password');
64 | }
65 |
66 | // Generate token
67 | const token = generateToken(user);
68 |
69 | // Log successful login
70 | logger.info('Successful login', {
71 | userId: user.id,
72 | email: user.email,
73 | duration: Date.now() - startTime
74 | });
75 |
76 | return NextResponse.json({
77 | user: {
78 | id: user.id,
79 | email: user.email,
80 | },
81 | token,
82 | });
83 | } catch (error) {
84 | const errorResponse = formatErrorResponse(error as Error);
85 |
86 | return NextResponse.json(
87 | errorResponse,
88 | { status: errorResponse.statusCode }
89 | );
90 | }
91 | });
--------------------------------------------------------------------------------
/lib/consoleBanner.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Console banner for xFunnel application
3 | * Shows ASCII art and credits
4 | */
5 |
6 | let bannerShown = false;
7 |
8 | export function showConsoleBanner() {
9 | // Only show in browser environment
10 | if (typeof window === 'undefined') return;
11 |
12 | // Reset on each call for production
13 | bannerShown = false;
14 |
15 | // ASCII art for ZIE619
16 | const asciiArt = `
17 | %c
18 | ███████╗██╗███████╗ ██████╗ ██╗ █████╗
19 | ╚══███╔╝██║██╔════╝ ██╔════╝ ███║██╔══██╗
20 | ███╔╝ ██║█████╗ ███████╗ ╚██║╚██████║
21 | ███╔╝ ██║██╔══╝ ██╔═══██╗ ██║ ╚═══██║
22 | ███████╗██║███████╗ ╚██████╔╝ ██║ █████╔╝
23 | ╚══════╝╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚════╝
24 |
25 | %c✨ Welcome to xFunnel - AI-Powered Article Editor ✨
26 |
27 | %c© 2025 Eliad Shahar - All Rights Reserved
28 | %cGitHub: https://github.com/Zie619
29 |
30 | %cBuilt with 😊 using Next.js, TypeScript, and Claude AI
31 | `;
32 |
33 | // Styles for different parts
34 | const styles = [
35 | 'color: #6366f1; font-weight: bold; font-size: 14px; line-height: 1.2;', // ASCII art
36 | 'color: #10b981; font-weight: bold; font-size: 16px; padding: 10px 0;', // Welcome message
37 | 'color: #94a3b8; font-size: 12px;', // Copyright
38 | 'color: #3b82f6; font-size: 12px; text-decoration: underline; cursor: pointer;', // GitHub link
39 | 'color: #64748b; font-size: 11px; font-style: italic; padding-top: 10px;' // Built with
40 | ];
41 |
42 | // Show the banner
43 | console.log(asciiArt, ...styles);
44 |
45 | // Make GitHub link clickable
46 | console.log('%cClick here to visit my GitHub', 'color: #3b82f6; font-size: 12px; cursor: pointer;', 'https://github.com/Zie619');
47 |
48 | // Add clickable link handler
49 | if (process.env.NODE_ENV === 'development') {
50 | console.log('%c🔧 Development Mode - Verbose logging enabled', 'color: #f59e0b; font-weight: bold;');
51 | } else {
52 | console.log('%c🚀 Production Mode - Minimal logging', 'color: #10b981; font-weight: bold;');
53 | }
54 |
55 | // Banner will show on every page refresh
56 | }
57 |
58 | // Export a debug mode toggle
59 | export function enableDebugMode() {
60 | localStorage.setItem('xfunnel_debug', 'true');
61 | console.log('%c🐛 Debug mode enabled - Refresh to see verbose logs', 'color: #f59e0b; font-weight: bold;');
62 | }
63 |
64 | export function disableDebugMode() {
65 | localStorage.removeItem('xfunnel_debug');
66 | console.log('%c🔕 Debug mode disabled - Refresh to hide verbose logs', 'color: #10b981; font-weight: bold;');
67 | }
68 |
69 | export function isDebugMode(): boolean {
70 | return localStorage.getItem('xfunnel_debug') === 'true';
71 | }
72 |
73 | // Auto-show banner when script loads in browser
74 | if (typeof window !== 'undefined') {
75 | // Use setTimeout to ensure DOM is ready
76 | setTimeout(() => {
77 | showConsoleBanner();
78 | }, 100);
79 | }
--------------------------------------------------------------------------------
/lib/api-error-handler.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { logger } from './logger';
3 |
4 | export class ApiError extends Error {
5 | constructor(
6 | message: string,
7 | public statusCode: number = 500,
8 | public code?: string,
9 | public details?: any
10 | ) {
11 | super(message);
12 | this.name = 'ApiError';
13 | }
14 | }
15 |
16 | export function createApiError(
17 | message: string,
18 | statusCode: number = 500,
19 | code?: string,
20 | details?: any
21 | ): ApiError {
22 | return new ApiError(message, statusCode, code, details);
23 | }
24 |
25 | export function handleApiError(error: unknown): NextResponse {
26 | // Log the error
27 | logger.error('API Error', error);
28 |
29 | // Handle known API errors
30 | if (error instanceof ApiError) {
31 | return NextResponse.json(
32 | {
33 | error: error.message,
34 | code: error.code,
35 | details: process.env.NODE_ENV === 'development' ? error.details : undefined,
36 | },
37 | { status: error.statusCode }
38 | );
39 | }
40 |
41 | // Handle JWT errors
42 | if (error instanceof Error && error.name === 'JsonWebTokenError') {
43 | return NextResponse.json(
44 | { error: 'Invalid authentication token' },
45 | { status: 401 }
46 | );
47 | }
48 |
49 | if (error instanceof Error && error.name === 'TokenExpiredError') {
50 | return NextResponse.json(
51 | { error: 'Authentication token expired' },
52 | { status: 401 }
53 | );
54 | }
55 |
56 | // Handle database errors
57 | if (error instanceof Error && error.message.includes('ECONNREFUSED')) {
58 | return NextResponse.json(
59 | { error: 'Database connection failed. Please try again later.' },
60 | { status: 503 }
61 | );
62 | }
63 |
64 | // Handle validation errors
65 | if (error instanceof Error && error.name === 'ValidationError') {
66 | return NextResponse.json(
67 | {
68 | error: 'Validation failed',
69 | details: process.env.NODE_ENV === 'development' ? error.message : undefined
70 | },
71 | { status: 400 }
72 | );
73 | }
74 |
75 | // Default error response
76 | const isDevelopment = process.env.NODE_ENV === 'development';
77 | const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred';
78 |
79 | return NextResponse.json(
80 | {
81 | error: isDevelopment ? errorMessage : 'Internal server error',
82 | stack: isDevelopment && error instanceof Error ? error.stack : undefined,
83 | },
84 | { status: 500 }
85 | );
86 | }
87 |
88 | // Wrapper for API route handlers
89 | export function withErrorHandler
Promise>(
90 | handler: T
91 | ): T {
92 | return (async (...args: Parameters) => {
93 | try {
94 | return await handler(...args);
95 | } catch (error) {
96 | return handleApiError(error);
97 | }
98 | }) as T;
99 | }
--------------------------------------------------------------------------------
/hooks/useClaude.ts:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 | import { claudeClient } from '@/lib/claude-client';
3 | import { ClaudeMessage, ClaudeResponse, ClaudeStreamChunk } from '@/lib/claude-types';
4 |
5 | interface UseClaudeOptions {
6 | systemPrompt?: string;
7 | maxTokens?: number;
8 | context?: string;
9 | onStreamChunk?: (chunk: ClaudeStreamChunk) => void;
10 | }
11 |
12 | interface UseClaudeReturn {
13 | messages: ClaudeMessage[];
14 | isLoading: boolean;
15 | error: string | null;
16 | sendMessage: (content: string, streaming?: boolean) => Promise;
17 | clearMessages: () => void;
18 | streamingText: string;
19 | }
20 |
21 | export function useClaude(options: UseClaudeOptions = {}): UseClaudeReturn {
22 | const [messages, setMessages] = useState([]);
23 | const [isLoading, setIsLoading] = useState(false);
24 | const [error, setError] = useState(null);
25 | const [streamingText, setStreamingText] = useState('');
26 |
27 | const sendMessage = useCallback(
28 | async (content: string, streaming = false) => {
29 | setIsLoading(true);
30 | setError(null);
31 | setStreamingText('');
32 |
33 | const newMessages = [...messages, { role: 'user' as const, content }];
34 | setMessages(newMessages);
35 |
36 | try {
37 | if (streaming) {
38 | let fullText = '';
39 | await claudeClient.sendStreamingMessage(
40 | newMessages,
41 | (chunk) => {
42 | fullText += chunk.text;
43 | setStreamingText(fullText);
44 | options.onStreamChunk?.(chunk);
45 | },
46 | {
47 | systemPrompt: options.systemPrompt,
48 | maxTokens: options.maxTokens,
49 | context: options.context,
50 | }
51 | );
52 |
53 | // Add the complete response to messages
54 | setMessages([...newMessages, { role: 'assistant', content: fullText }]);
55 | setStreamingText('');
56 | } else {
57 | const response = await claudeClient.sendMessage(newMessages, {
58 | systemPrompt: options.systemPrompt,
59 | maxTokens: options.maxTokens,
60 | context: options.context,
61 | });
62 |
63 | setMessages([...newMessages, { role: 'assistant', content: response.content }]);
64 | }
65 | } catch (err) {
66 | setError(err instanceof Error ? err.message : 'An error occurred');
67 | // Remove the user message if there was an error
68 | setMessages(messages);
69 | } finally {
70 | setIsLoading(false);
71 | }
72 | },
73 | [messages, options]
74 | );
75 |
76 | const clearMessages = useCallback(() => {
77 | setMessages([]);
78 | setError(null);
79 | setStreamingText('');
80 | }, []);
81 |
82 | return {
83 | messages,
84 | isLoading,
85 | error,
86 | sendMessage,
87 | clearMessages,
88 | streamingText,
89 | };
90 | }
--------------------------------------------------------------------------------
/lib/claude-prompts.ts:
--------------------------------------------------------------------------------
1 | // System prompts for different Claude interactions
2 |
3 | export const ARTICLE_EDITOR_PROMPT = `You are a collaborative professional editor. Help the user refine and improve this article step by step.
4 |
5 | Your role is to:
6 | 1. Provide constructive feedback on content, structure, and clarity
7 | 2. Suggest improvements for engagement and readability
8 | 3. Help maintain a consistent tone and style
9 | 4. Identify areas that need more development or clarity
10 | 5. Offer specific, actionable suggestions
11 |
12 | When editing:
13 | - Be supportive and encouraging
14 | - Focus on improving the article's impact and clarity
15 | - Preserve the author's voice and intent
16 | - Provide specific examples when suggesting changes
17 | - Ask clarifying questions when needed
18 |
19 | IMPORTANT: When generating or suggesting content, always use proper markdown formatting:
20 | - Use # for main headings (e.g., # Heading 1)
21 | - Use ## for subheadings (e.g., ## Heading 2)
22 | - Use ### for sub-subheadings (e.g., ### Heading 3)
23 | - Use - or * for bullet points (not • or other symbols)
24 | - Use 1. 2. 3. for numbered lists
25 | - Use \`\`\` for code blocks
26 | - Use > for blockquotes
27 | - Use **bold** and *italic* for emphasis
28 | - Use --- for horizontal rules
29 |
30 | Always maintain a professional, helpful tone and remember you're collaborating with the author to create the best possible version of their article.`;
31 |
32 | export const CONTENT_ANALYZER_PROMPT = `You are an expert content analyst. Analyze the provided article and provide insights on:
33 |
34 | 1. Overall quality and coherence
35 | 2. Target audience alignment
36 | 3. Key strengths and weaknesses
37 | 4. Engagement potential
38 | 5. SEO considerations
39 | 6. Readability score estimation
40 |
41 | Provide your analysis in a structured, easy-to-understand format.`;
42 |
43 | export const TITLE_GENERATOR_PROMPT = `You are a creative title specialist. Based on the article content provided, generate compelling, SEO-friendly titles that:
44 |
45 | 1. Accurately represent the content
46 | 2. Grab reader attention
47 | 3. Include relevant keywords naturally
48 | 4. Are concise and impactful
49 | 5. Follow best practices for the article's genre/niche
50 |
51 | Provide 5-7 title options with brief explanations for each.`;
52 |
53 | export const SUMMARY_GENERATOR_PROMPT = `You are an expert at creating concise, engaging summaries. Create a summary that:
54 |
55 | 1. Captures the main points of the article
56 | 2. Maintains the core message and tone
57 | 3. Is appropriate for the specified length
58 | 4. Engages readers and encourages them to read the full article
59 | 5. Includes key takeaways when relevant
60 |
61 | Adjust the summary length and style based on the user's requirements.`;
62 |
63 | // Helper function to customize prompts
64 | export function customizePrompt(basePrompt: string, additionalContext?: string): string {
65 | if (!additionalContext) return basePrompt;
66 |
67 | return `${basePrompt}\n\nAdditional context:\n${additionalContext}`;
68 | }
--------------------------------------------------------------------------------
/docs/SETUP_SUMMARY.md:
--------------------------------------------------------------------------------
1 | # XFunnel Database Setup Summary
2 |
3 | ## Current Status
4 |
5 | ✅ **Scripts Created:**
6 | - `scripts/setup-database-simple.js` - Main setup script
7 | - `scripts/setup-supabase-db.sh` - Bash alternative
8 | - `scripts/setup-supabase.py` - Python alternative
9 | - `scripts/test-connection.js` - Connection tester
10 | - `scripts/combined-migrations.sql` - All SQL migrations in one file
11 |
12 | ✅ **Documentation Created:**
13 | - `DATABASE_SETUP.md` - Detailed setup instructions
14 | - `SUPABASE_SETUP_GUIDE.md` - Quick setup guide
15 | - This summary file
16 |
17 | ## ⚠️ Issue Found
18 |
19 | The Supabase connection is failing, likely because:
20 | 1. The API keys in `.env.local` may be incorrect or from a different project
21 | 2. The service role key is still set to placeholder value
22 |
23 | ## 🚀 Quick Fix Steps
24 |
25 | ### 1. Get Your Supabase Credentials
26 |
27 | 1. Go to [Supabase Dashboard](https://supabase.com/dashboard)
28 | 2. Select your project
29 | 3. Go to **Settings → API**
30 | 4. Copy these values:
31 | - **Project URL** (looks like `https://xxxxx.supabase.co`)
32 | - **anon public** key (starts with `eyJ...`)
33 | - **service_role** key (also starts with `eyJ...`)
34 |
35 | ### 2. Update .env.local
36 |
37 | Replace the values in `.env.local`:
38 | ```bash
39 | NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
40 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
41 | SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here
42 | ```
43 |
44 | ### 3. Run Database Setup
45 |
46 | #### Option A: Automated Setup (if service role key is provided)
47 | ```bash
48 | npm run setup:db
49 | ```
50 |
51 | #### Option B: Manual Setup
52 | 1. Go to your [Supabase SQL Editor](https://supabase.com/dashboard/project/_/sql)
53 | 2. Copy contents of `scripts/combined-migrations.sql`
54 | 3. Paste and click "Run"
55 |
56 | ### 4. Test Your Setup
57 |
58 | ```bash
59 | # Test connection
60 | node scripts/test-connection.js
61 |
62 | # Start the app
63 | npm run dev
64 | ```
65 |
66 | Then visit http://localhost:3000/login and try registering.
67 |
68 | ## 📁 All Database Scripts
69 |
70 | ```bash
71 | # Available setup commands:
72 | npm run setup:db # Simplified Node.js script
73 | npm run setup:db:node # Full Node.js script
74 | npm run setup:db:bash # Bash script (requires psql)
75 | npm run setup:db:python # Python script (requires psycopg2)
76 |
77 | # Test connection:
78 | node scripts/test-connection.js
79 | ```
80 |
81 | ## 🎯 What Gets Created
82 |
83 | The setup creates 4 tables:
84 | - `users` - User accounts and authentication
85 | - `articles` - Article content and metadata
86 | - `workers` - AI worker session tracking
87 | - `activity_sessions` - Detailed activity analytics
88 |
89 | ## ❓ Still Having Issues?
90 |
91 | 1. **"Invalid API key"** - Double-check you copied the correct keys from Supabase
92 | 2. **"User already exists"** - Tables might not be created yet, run the setup
93 | 3. **Can't connect** - Ensure Supabase project is active (not paused)
94 |
95 | The combined SQL file at `scripts/combined-migrations.sql` contains everything needed for manual setup if the automated scripts fail.
--------------------------------------------------------------------------------
/supabase/migrations/005_improve_workers_session_tracking.sql:
--------------------------------------------------------------------------------
1 | -- Improve workers table for better session tracking
2 | -- Add composite index for finding active sessions efficiently
3 |
4 | -- Create composite index for user/article/active session lookups
5 | CREATE INDEX IF NOT EXISTS idx_workers_user_article_active
6 | ON workers(user_id, article_id, session_end)
7 | WHERE session_end IS NULL;
8 |
9 | -- Create function to get or create active session
10 | CREATE OR REPLACE FUNCTION get_or_create_active_session(
11 | p_user_id UUID,
12 | p_article_id UUID
13 | )
14 | RETURNS TABLE (
15 | session_id UUID,
16 | is_new BOOLEAN
17 | ) AS $$
18 | DECLARE
19 | v_session_id UUID;
20 | v_is_new BOOLEAN := FALSE;
21 | BEGIN
22 | -- First try to find an existing active session
23 | SELECT id INTO v_session_id
24 | FROM workers
25 | WHERE user_id = p_user_id
26 | AND article_id = p_article_id
27 | AND session_end IS NULL
28 | AND is_active = true
29 | ORDER BY session_start DESC
30 | LIMIT 1;
31 |
32 | -- If no active session found, create a new one
33 | IF v_session_id IS NULL THEN
34 | INSERT INTO workers (
35 | user_id,
36 | article_id,
37 | session_start,
38 | time_spent_seconds,
39 | is_active,
40 | last_active,
41 | created_at
42 | ) VALUES (
43 | p_user_id,
44 | p_article_id,
45 | TIMEZONE('utc', NOW()),
46 | 0,
47 | true,
48 | TIMEZONE('utc', NOW()),
49 | TIMEZONE('utc', NOW())
50 | )
51 | RETURNING id INTO v_session_id;
52 |
53 | v_is_new := TRUE;
54 | END IF;
55 |
56 | RETURN QUERY SELECT v_session_id, v_is_new;
57 | END;
58 | $$ LANGUAGE plpgsql;
59 |
60 | -- Grant execute permission on the function
61 | GRANT EXECUTE ON FUNCTION get_or_create_active_session TO authenticated;
62 |
63 | -- Create view for active sessions with aggregated stats
64 | CREATE OR REPLACE VIEW active_sessions_stats AS
65 | SELECT
66 | w.user_id,
67 | w.article_id,
68 | w.id as session_id,
69 | w.session_start,
70 | w.last_active,
71 | w.time_spent_seconds,
72 | w.ai_requests_count,
73 | w.manual_edits_count,
74 | w.focus_count,
75 | w.blur_count,
76 | w.read_percentage,
77 | a.title as article_title,
78 | u.email as user_email,
79 | -- Calculate session duration in a human-readable format
80 | CASE
81 | WHEN w.time_spent_seconds < 60 THEN w.time_spent_seconds || ' seconds'
82 | WHEN w.time_spent_seconds < 3600 THEN (w.time_spent_seconds / 60)::INTEGER || ' minutes'
83 | ELSE (w.time_spent_seconds / 3600)::NUMERIC(10,1) || ' hours'
84 | END as duration_readable
85 | FROM workers w
86 | JOIN articles a ON w.article_id = a.id
87 | JOIN users u ON w.user_id = u.id
88 | WHERE w.session_end IS NULL
89 | AND w.is_active = true
90 | ORDER BY w.last_active DESC;
91 |
92 | -- Grant access to the view
93 | GRANT SELECT ON active_sessions_stats TO authenticated;
94 |
95 | -- Add comment to explain the session management strategy
96 | COMMENT ON TABLE workers IS 'Tracks user activity sessions per article. Each user can have one active session per article at a time. Sessions are marked as ended when session_end is set.';
--------------------------------------------------------------------------------
/app/api/save-article/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from 'next/server';
2 | import { createClient } from '@supabase/supabase-js';
3 | import { verifyToken } from '@/lib/auth';
4 |
5 | // Initialize Supabase client for server-side operations
6 | const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
7 | const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
8 |
9 | const supabase = createClient(supabaseUrl, supabaseServiceKey);
10 |
11 | export async function POST(request: NextRequest) {
12 | try {
13 | // Verify authentication
14 | const authHeader = request.headers.get('authorization');
15 | if (!authHeader || !authHeader.startsWith('Bearer ')) {
16 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
17 | }
18 |
19 | const token = authHeader.substring(7);
20 | const authPayload = verifyToken(token);
21 | if (!authPayload) {
22 | return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
23 | }
24 |
25 | // Parse request body
26 | const { articleId, title, content } = await request.json();
27 |
28 | // Validate required fields
29 | if (!articleId || !title) {
30 | return NextResponse.json(
31 | { error: 'Invalid request: articleId and title are required' },
32 | { status: 400 }
33 | );
34 | }
35 |
36 | // Update article and set last_edited_by
37 | let { data: article, error } = await supabase
38 | .from('articles')
39 | .update({
40 | title,
41 | content,
42 | last_edited_by: authPayload.userId,
43 | updated_at: new Date().toISOString(),
44 | })
45 | .eq('id', articleId)
46 | .select(`
47 | *,
48 | last_editor:users!articles_last_edited_by_fkey(id, email)
49 | `)
50 | .single();
51 |
52 | // If update fails due to missing column, try without last_edited_by
53 | if (error && error.message.includes('last_edited_by')) {
54 | console.log('Falling back to update without last_edited_by');
55 | const result = await supabase
56 | .from('articles')
57 | .update({
58 | title,
59 | content,
60 | updated_at: new Date().toISOString(),
61 | })
62 | .eq('id', articleId)
63 | .select()
64 | .single();
65 |
66 | article = result.data;
67 | error = result.error;
68 | }
69 |
70 | if (error) {
71 | console.error('Error saving article:', error);
72 | return NextResponse.json(
73 | { error: 'Failed to save article', details: error.message },
74 | { status: 500 }
75 | );
76 | }
77 |
78 | if (!article) {
79 | return NextResponse.json(
80 | { error: 'Article not found or unauthorized' },
81 | { status: 404 }
82 | );
83 | }
84 |
85 | // Return the updated article
86 | return NextResponse.json(article);
87 | } catch (error) {
88 | console.error('Unexpected error in save-article route:', error);
89 | return NextResponse.json(
90 | { error: 'Internal server error' },
91 | { status: 500 }
92 | );
93 | }
94 | }
--------------------------------------------------------------------------------
/components/ErrorFallback.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { AlertCircle, RefreshCw, Home } from 'lucide-react';
4 | import { useRouter } from 'next/navigation';
5 |
6 | interface ErrorFallbackProps {
7 | error: Error;
8 | reset: () => void;
9 | }
10 |
11 | export default function ErrorFallback({ error, reset }: ErrorFallbackProps) {
12 | const router = useRouter();
13 | const isDevelopment = process.env.NODE_ENV === 'development';
14 |
15 | return (
16 |
17 |
18 |
19 |
24 |
25 |
26 |
27 | Oops! Something went wrong
28 |
29 |
30 | We encountered an unexpected error. Please try again.
31 |
32 |
33 |
34 | {isDevelopment && error && (
35 |
36 |
37 | Error details (development only)
38 |
39 |
40 |
41 | {error.message}
42 |
43 | {error.stack && (
44 |
45 | {error.stack}
46 |
47 | )}
48 |
49 |
50 | )}
51 |
52 |
53 |
60 |
61 |
68 |
69 |
70 |
71 |
72 | );
73 | }
--------------------------------------------------------------------------------
/lib/markdown-formatter.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Markdown formatting utilities for AI-generated content
3 | */
4 |
5 | export const MARKDOWN_FORMATTING_INSTRUCTIONS = `
6 | When creating or editing content, always use proper markdown formatting:
7 |
8 | ## Headings
9 | - # Main Title (H1)
10 | - ## Section Heading (H2)
11 | - ### Subsection Heading (H3)
12 | - #### Minor Heading (H4)
13 |
14 | ## Lists
15 | Bullet points:
16 | - Use - or * for unordered lists
17 | - Each item on a new line
18 | - Add a space after the bullet
19 |
20 | Numbered lists:
21 | 1. Use numbers followed by a period
22 | 2. Each item on a new line
23 | 3. Add a space after the number
24 |
25 | ## Emphasis
26 | - **Bold text** using double asterisks
27 | - *Italic text* using single asterisks
28 | - ***Bold and italic*** using triple asterisks
29 |
30 | ## Other Elements
31 | - > Blockquotes with greater-than symbol
32 | - \`inline code\` with backticks
33 | - \`\`\`language
34 | code blocks
35 | \`\`\`
36 | - --- for horizontal rules
37 | - [Link text](URL) for links
38 | `;
39 |
40 | /**
41 | * Validates and fixes common markdown formatting issues
42 | */
43 | export function validateAndFixMarkdown(content: string): string {
44 | // Fix headings - ensure space after #
45 | let fixedContent = content.replace(/^(#{1,6})([^ #\n])/gm, '$1 $2');
46 |
47 | // Fix bullet points - ensure space after - or *
48 | fixedContent = fixedContent.replace(/^([-*])([^ \n])/gm, '$1 $2');
49 |
50 | // Fix numbered lists - ensure space after number and period
51 | fixedContent = fixedContent.replace(/^(\d+\.)([^ \n])/gm, '$1 $2');
52 |
53 | // Fix blockquotes - ensure space after >
54 | fixedContent = fixedContent.replace(/^(>)([^ \n])/gm, '$1 $2');
55 |
56 | // Replace bullet symbols with markdown bullets
57 | fixedContent = fixedContent.replace(/^[•·▪▫◦‣⁃]/gm, '-');
58 |
59 | // Ensure code blocks are properly closed
60 | const codeBlockRegex = /```[\s\S]*?(?:```|$)/g;
61 | fixedContent = fixedContent.replace(codeBlockRegex, (match) => {
62 | if (!match.endsWith('```')) {
63 | return match + '\n```';
64 | }
65 | return match;
66 | });
67 |
68 | return fixedContent;
69 | }
70 |
71 | /**
72 | * Converts common text patterns to proper markdown
73 | */
74 | export function enhanceWithMarkdown(content: string): string {
75 | // Convert lines that look like headings (all caps or ending with colon)
76 | let enhanced = content.replace(/^([A-Z][A-Z\s]+)$/gm, (match) => {
77 | return `## ${match.charAt(0) + match.slice(1).toLowerCase()}`;
78 | });
79 |
80 | // Convert lines ending with colon to headings
81 | enhanced = enhanced.replace(/^(.+):$/gm, '### $1');
82 |
83 | // Convert lines starting with numbers to ordered lists
84 | enhanced = enhanced.replace(/^(\d+)\s+(.+)$/gm, '$1. $2');
85 |
86 | // Convert lines starting with dash-like characters to bullet points
87 | enhanced = enhanced.replace(/^[-–—]\s*(.+)$/gm, '- $1');
88 |
89 | return enhanced;
90 | }
91 |
92 | /**
93 | * Adds markdown formatting instructions to a prompt
94 | */
95 | export function addMarkdownInstructions(basePrompt: string): string {
96 | return `${basePrompt}\n\n${MARKDOWN_FORMATTING_INSTRUCTIONS}`;
97 | }
--------------------------------------------------------------------------------
/components/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { Component, ErrorInfo, ReactNode } from 'react';
4 | import { logger } from '@/lib/logger';
5 |
6 | interface Props {
7 | children: ReactNode;
8 | fallback?: ReactNode;
9 | }
10 |
11 | interface State {
12 | hasError: boolean;
13 | error: Error | null;
14 | errorInfo: ErrorInfo | null;
15 | }
16 |
17 | export class ErrorBoundary extends Component {
18 | constructor(props: Props) {
19 | super(props);
20 | this.state = {
21 | hasError: false,
22 | error: null,
23 | errorInfo: null
24 | };
25 | }
26 |
27 | static getDerivedStateFromError(error: Error): State {
28 | return {
29 | hasError: true,
30 | error,
31 | errorInfo: null
32 | };
33 | }
34 |
35 | componentDidCatch(error: Error, errorInfo: ErrorInfo) {
36 | // Log the error to our logging service
37 | logger.error('React Error Boundary caught an error', error, {
38 | componentStack: errorInfo.componentStack,
39 | errorBoundary: true
40 | });
41 |
42 | this.setState({
43 | error,
44 | errorInfo
45 | });
46 | }
47 |
48 | render() {
49 | if (this.state.hasError) {
50 | if (this.props.fallback) {
51 | return this.props.fallback;
52 | }
53 |
54 | // Default error UI
55 | return (
56 |
57 |
58 |
59 |
60 | Oops! Something went wrong
61 |
62 |
63 | We're sorry for the inconvenience. Please try refreshing the page.
64 |
65 | {process.env.NODE_ENV === 'development' && this.state.error && (
66 |
67 |
68 | Error details (development only)
69 |
70 |
71 | {this.state.error.toString()}
72 | {this.state.errorInfo?.componentStack}
73 |
74 |
75 | )}
76 |
77 |
83 |
84 |
85 |
86 |
87 | );
88 | }
89 |
90 | return this.props.children;
91 | }
92 | }
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 222.2 84% 4.9%;
9 | --primary: 201 96% 32%;
10 | --primary-foreground: 210 40% 98%;
11 | --secondary: 210 40% 96.1%;
12 | --secondary-foreground: 222.2 47.4% 11.2%;
13 | --muted: 210 40% 96.1%;
14 | --muted-foreground: 215.4 16.3% 46.9%;
15 | --accent: 210 40% 96.1%;
16 | --accent-foreground: 222.2 47.4% 11.2%;
17 | --destructive: 0 84.2% 60.2%;
18 | --destructive-foreground: 210 40% 98%;
19 | --border: 214.3 31.8% 91.4%;
20 | --input: 214.3 31.8% 91.4%;
21 | --ring: 201 96% 32%;
22 | --radius: 0.5rem;
23 |
24 | /* Default accent color values (blue) */
25 | --accent-50: #eff6ff;
26 | --accent-100: #dbeafe;
27 | --accent-200: #bfdbfe;
28 | --accent-300: #93c5fd;
29 | --accent-400: #60a5fa;
30 | --accent-500: #3b82f6;
31 | --accent-600: #2563eb;
32 | --accent-700: #1d4ed8;
33 | --accent-800: #1e40af;
34 | --accent-900: #1e3a8a;
35 |
36 | /* Semantic accent colors */
37 | --accent-primary: #3b82f6;
38 | --accent-primary-hover: #2563eb;
39 | --accent-primary-active: #1d4ed8;
40 | --accent-light: #dbeafe;
41 | --accent-light-hover: #bfdbfe;
42 | --accent-dark: #1e40af;
43 | }
44 | }
45 |
46 | @layer base {
47 | body {
48 | @apply bg-white text-gray-900;
49 | }
50 | }
51 |
52 | /* Custom scrollbar */
53 | @layer utilities {
54 | .scrollbar-thin::-webkit-scrollbar {
55 | width: 6px;
56 | height: 6px;
57 | }
58 |
59 | .scrollbar-thin::-webkit-scrollbar-track {
60 | @apply bg-transparent;
61 | }
62 |
63 | .scrollbar-thin::-webkit-scrollbar-thumb {
64 | @apply bg-gray-300 rounded-full;
65 | }
66 |
67 | .scrollbar-thin::-webkit-scrollbar-thumb:hover {
68 | @apply bg-gray-400;
69 | }
70 | }
71 |
72 | @keyframes slide-up {
73 | from {
74 | transform: translateY(100%);
75 | opacity: 0;
76 | }
77 | to {
78 | transform: translateY(0);
79 | opacity: 1;
80 | }
81 | }
82 |
83 | .animate-slide-up {
84 | animation: slide-up 0.3s ease-out;
85 | }
86 |
87 | /* Ensure grid columns maintain equal width */
88 | @layer utilities {
89 | .grid-cols-2 > * {
90 | min-width: 0;
91 | }
92 |
93 | /* Prevent layout shift when content changes */
94 | .grid-stable {
95 | grid-template-columns: minmax(0, 1fr);
96 | }
97 |
98 | .grid-stable-2 {
99 | grid-template-columns: repeat(2, minmax(0, 1fr));
100 | }
101 |
102 | /* Typing indicator animation */
103 | @keyframes bounce {
104 | 0%, 60%, 100% { transform: translateY(0); }
105 | 30% { transform: translateY(-10px); }
106 | }
107 |
108 | .animate-bounce {
109 | animation: bounce 1.4s infinite;
110 | }
111 |
112 | /* Diff styles */
113 | .diff-added {
114 | @apply text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-900/20 px-1 rounded;
115 | }
116 |
117 | .diff-removed {
118 | @apply text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-900/20 px-1 rounded line-through;
119 | }
120 | }
--------------------------------------------------------------------------------
/scripts/setup-database.js:
--------------------------------------------------------------------------------
1 | const { createClient } = require('@supabase/supabase-js');
2 | const fs = require('fs');
3 | const path = require('path');
4 | require('dotenv').config({ path: '.env.local' });
5 |
6 | const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
7 | const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
8 |
9 | if (!supabaseUrl || !supabaseServiceKey) {
10 | console.error('Missing Supabase environment variables!');
11 | console.error('Please ensure NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are set in .env.local');
12 | process.exit(1);
13 | }
14 |
15 | const supabase = createClient(supabaseUrl, supabaseServiceKey);
16 |
17 | async function runMigrations() {
18 | const migrationsDir = path.join(__dirname, '..', 'supabase', 'migrations');
19 | const migrationFiles = fs.readdirSync(migrationsDir).sort();
20 |
21 | console.log('Running database migrations...\n');
22 |
23 | for (const file of migrationFiles) {
24 | if (!file.endsWith('.sql')) continue;
25 |
26 | console.log(`Running migration: ${file}`);
27 | const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
28 |
29 | try {
30 | const { error } = await supabase.rpc('exec_sql', { sql_query: sql }).single();
31 |
32 | if (error) {
33 | // If RPC doesn't exist, try a different approach
34 | console.warn(`Note: Direct SQL execution may require Supabase SQL Editor or migrations through Supabase CLI.`);
35 | console.log(`Migration content for ${file}:`);
36 | console.log(sql);
37 | console.log('\n---\n');
38 | } else {
39 | console.log(`✓ ${file} completed successfully`);
40 | }
41 | } catch (err) {
42 | console.error(`Error running ${file}:`, err.message);
43 | }
44 | }
45 |
46 | console.log('\nMigrations completed!');
47 | console.log('\nNote: If migrations failed, you can run them manually in the Supabase SQL Editor.');
48 | }
49 |
50 | async function testConnection() {
51 | console.log('Testing Supabase connection...');
52 |
53 | try {
54 | const { data, error } = await supabase.from('users').select('count').single();
55 |
56 | if (error && error.code === '42P01') {
57 | console.log('Tables not yet created. Please run migrations in Supabase SQL Editor.');
58 | return false;
59 | }
60 |
61 | if (error) {
62 | console.error('Connection error:', error);
63 | return false;
64 | }
65 |
66 | console.log('✓ Successfully connected to Supabase');
67 | return true;
68 | } catch (err) {
69 | console.error('Connection failed:', err);
70 | return false;
71 | }
72 | }
73 |
74 | async function main() {
75 | console.log('Setting up Supabase database...\n');
76 |
77 | const connected = await testConnection();
78 |
79 | if (connected) {
80 | console.log('\nDatabase is already set up!');
81 | } else {
82 | console.log('\nPlease run the following migrations in your Supabase SQL Editor:');
83 | console.log('1. Go to your Supabase project dashboard');
84 | console.log('2. Navigate to SQL Editor');
85 | console.log('3. Run each migration file in order\n');
86 |
87 | await runMigrations();
88 | }
89 | }
90 |
91 | main().catch(console.error);
--------------------------------------------------------------------------------
/docs/WEBHOOK_CONFIGURATION.md:
--------------------------------------------------------------------------------
1 | # Webhook Configuration Guide
2 |
3 | ## Setting up n8n Webhook Integration
4 |
5 | This guide explains how to properly configure the n8n webhook integration for xFunnel.
6 |
7 | ### 1. Configure Environment Variables
8 |
9 | Edit your `.env.local` file and update the following variables:
10 |
11 | ```bash
12 | # Required: Your n8n webhook URL
13 | N8N_WEBHOOK_URL=https://your-n8n-instance.com/webhook/your-webhook-id
14 |
15 | # Optional: Authentication header if your n8n webhook requires it
16 | N8N_WEBHOOK_AUTH_HEADER=Bearer your-auth-token
17 | ```
18 |
19 | ### 2. Getting Your n8n Webhook URL
20 |
21 | 1. Open your n8n instance
22 | 2. Create a new workflow or open an existing one
23 | 3. Add a "Webhook" node
24 | 4. Configure the webhook node:
25 | - HTTP Method: POST
26 | - Path: Choose a custom path or use the generated one
27 | - Response Mode: "When Last Node Finishes" (recommended)
28 | 5. Copy the "Production URL" from the webhook node
29 | 6. Paste this URL as the value for `N8N_WEBHOOK_URL` in your `.env.local` file
30 |
31 | ### 3. Webhook Payload Structure
32 |
33 | When an article is sent to n8n, the following payload is sent:
34 |
35 | ```json
36 | {
37 | "article_id": "unique-article-id",
38 | "status": "final",
39 | "title": "Article Title",
40 | "content": "Article content...",
41 | "timestamp": "2024-01-09T12:00:00.000Z",
42 | "source": "xfunnel"
43 | }
44 | ```
45 |
46 | ### 4. Testing the Webhook
47 |
48 | 1. Start your xFunnel application
49 | 2. Create or edit an article
50 | 3. Click the "Send" button
51 | 4. Check the browser console for logs
52 | 5. Check your n8n workflow execution history
53 |
54 | ### 5. Troubleshooting
55 |
56 | #### Common Issues:
57 |
58 | 1. **"Webhook URL not configured" error**
59 | - Ensure `N8N_WEBHOOK_URL` is set in `.env.local`
60 | - Restart your Next.js development server after changing environment variables
61 |
62 | 2. **"Webhook URL not properly configured" error**
63 | - You're still using the placeholder URL
64 | - Update `N8N_WEBHOOK_URL` with your actual n8n webhook URL
65 |
66 | 3. **"Failed to send webhook" error**
67 | - Check if your n8n instance is running
68 | - Verify the webhook URL is correct
69 | - Check if authentication is required
70 |
71 | 4. **CORS errors**
72 | - The webhook route includes CORS headers
73 | - If issues persist, check your n8n webhook node configuration
74 |
75 | ### 6. Security Considerations
76 |
77 | - Never commit your `.env.local` file to version control
78 | - The webhook URL is masked in console logs for security
79 | - Use authentication headers if your n8n webhook is publicly accessible
80 | - Consider using HTTPS for production deployments
81 |
82 | ### 7. Example n8n Workflow
83 |
84 | Here's a simple n8n workflow to receive and process xFunnel articles:
85 |
86 | 1. **Webhook Node** - Receives the article data
87 | 2. **Set Node** - Extract and format article data
88 | 3. **HTTP Request Node** - Send to external service (optional)
89 | 4. **Respond to Webhook Node** - Send success response back to xFunnel
90 |
91 | For more complex workflows, you can add nodes for:
92 | - Sending emails
93 | - Saving to databases
94 | - Integrating with other services
95 | - Conditional logic based on article content
--------------------------------------------------------------------------------
/app/api/workers/stats/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from 'next/server';
2 | import { createClient } from '@supabase/supabase-js';
3 | import { verifyToken } from '@/lib/auth';
4 |
5 | // Create Supabase client for server-side
6 | const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
7 | const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
8 |
9 | const supabase = createClient(supabaseUrl, supabaseServiceKey);
10 |
11 | export async function GET(request: NextRequest) {
12 | try {
13 | // Verify authentication
14 | const authHeader = request.headers.get('authorization');
15 | if (!authHeader || !authHeader.startsWith('Bearer ')) {
16 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
17 | }
18 |
19 | const token = authHeader.substring(7);
20 | const authPayload = verifyToken(token);
21 | if (!authPayload) {
22 | return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
23 | }
24 |
25 | // Get article ID from query params (optional)
26 | const articleId = request.nextUrl.searchParams.get('articleId');
27 |
28 | // Build query for worker sessions
29 | let query = supabase
30 | .from('workers')
31 | .select('*')
32 | .eq('user_id', authPayload.userId);
33 |
34 | if (articleId) {
35 | query = query.eq('article_id', articleId);
36 | }
37 |
38 | // Fetch all sessions for accurate totals
39 | const { data: sessions, error } = await query.order('session_start', { ascending: false });
40 |
41 | if (error) {
42 | console.error('Error fetching worker sessions:', error);
43 | return NextResponse.json(
44 | { error: 'Failed to fetch worker sessions', details: error.message },
45 | { status: 500 }
46 | );
47 | }
48 |
49 | // Calculate aggregated stats from ALL sessions
50 | const allSessions = sessions || [];
51 | const stats = {
52 | totalSessions: allSessions.length,
53 | totalTimeSpent: allSessions.reduce((sum, s) => sum + (s.time_spent_seconds || 0), 0),
54 | totalAiRequests: allSessions.reduce((sum, s) => sum + (s.ai_requests_count || 0), 0),
55 | totalManualEdits: allSessions.reduce((sum, s) => sum + (s.manual_edits_count || 0), 0),
56 | avgReadPercentage: allSessions.length > 0
57 | ? Math.round(allSessions.reduce((sum, s) => sum + (s.read_percentage || 0), 0) / allSessions.length)
58 | : 0,
59 | totalFocusEvents: allSessions.reduce((sum, s) => sum + (s.focus_count || 0), 0),
60 | totalBlurEvents: allSessions.reduce((sum, s) => sum + (s.blur_count || 0), 0),
61 | // Only return the 10 most recent sessions for display
62 | recentSessions: allSessions.slice(0, 10),
63 | // Additional metadata
64 | firstSessionDate: allSessions.length > 0 ? allSessions[allSessions.length - 1].session_start : null,
65 | lastSessionDate: allSessions.length > 0 ? allSessions[0].session_start : null,
66 | activeSessions: allSessions.filter(s => s.is_active && !s.session_end).length,
67 | };
68 |
69 | return NextResponse.json(stats);
70 | } catch (error) {
71 | console.error('Error in worker stats:', error);
72 | return NextResponse.json(
73 | { error: 'Internal server error' },
74 | { status: 500 }
75 | );
76 | }
77 | }
--------------------------------------------------------------------------------
/app/error.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect } from 'react'
4 | import { logger } from '@/lib/logger'
5 |
6 | export default function Error({
7 | error,
8 | reset,
9 | }: {
10 | error: Error & { digest?: string }
11 | reset: () => void
12 | }) {
13 | useEffect(() => {
14 | // Log the error when it occurs
15 | logger.error('Page error boundary caught error', error, {
16 | digest: error.digest,
17 | errorBoundary: 'page-level'
18 | })
19 | }, [error])
20 |
21 | return (
22 |
23 |
24 |
25 |
41 |
42 | Something went wrong
43 |
44 |
45 | We apologize for the inconvenience. The issue has been logged and we'll look into it.
46 |
47 | {process.env.NODE_ENV === 'development' && (
48 |
49 |
50 | Error details (development only)
51 |
52 |
53 | {error.message}
54 | {error.stack && '\n\n' + error.stack}
55 |
56 |
57 | )}
58 |
59 |
65 |
71 |
72 |
73 |
74 |
75 | )
76 | }
--------------------------------------------------------------------------------
/lib/error-boundary.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useEffect } from 'react';
4 | import { logger } from './logger';
5 |
6 | interface ErrorBoundaryProps {
7 | children: React.ReactNode;
8 | fallback?: React.ComponentType<{ error: Error; reset: () => void }>;
9 | }
10 |
11 | interface ErrorBoundaryState {
12 | hasError: boolean;
13 | error: Error | null;
14 | }
15 |
16 | export class ErrorBoundary extends React.Component {
17 | constructor(props: ErrorBoundaryProps) {
18 | super(props);
19 | this.state = { hasError: false, error: null };
20 | }
21 |
22 | static getDerivedStateFromError(error: Error): ErrorBoundaryState {
23 | return { hasError: true, error };
24 | }
25 |
26 | componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
27 | logger.error('React Error Boundary caught error', error, {
28 | componentStack: errorInfo.componentStack,
29 | digest: (errorInfo as any).digest,
30 | });
31 | }
32 |
33 | reset = () => {
34 | this.setState({ hasError: false, error: null });
35 | };
36 |
37 | render() {
38 | if (this.state.hasError && this.state.error) {
39 | const FallbackComponent = this.props.fallback;
40 |
41 | if (FallbackComponent) {
42 | return ;
43 | }
44 |
45 | // Default error UI
46 | return (
47 |
48 |
49 |
50 |
51 | Something went wrong
52 |
53 |
54 | We're sorry for the inconvenience. Please try refreshing the page.
55 |
56 |
57 |
63 |
69 |
70 |
71 |
72 |
73 | );
74 | }
75 |
76 | return this.props.children;
77 | }
78 | }
79 |
80 | // Hook for error recovery
81 | export function useErrorHandler() {
82 | return (error: Error, errorInfo?: { componentStack?: string }) => {
83 | logger.error('Error handled by useErrorHandler', error, errorInfo);
84 |
85 | // In production, you might want to send this to an error tracking service
86 | if (process.env.NODE_ENV === 'production') {
87 | // Example: Send to Sentry, LogRocket, etc.
88 | // window.Sentry?.captureException(error, { extra: errorInfo });
89 | }
90 | };
91 | }
--------------------------------------------------------------------------------
/docs/SUPABASE_SETUP_GUIDE.md:
--------------------------------------------------------------------------------
1 | # Supabase Database Setup Guide
2 |
3 | ## Quick Setup
4 |
5 | ### Step 1: Update Environment Variables
6 |
7 | 1. Open `.env.local` and ensure you have:
8 | ```
9 | NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
10 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
11 | SUPABASE_SERVICE_ROLE_KEY=your-service-role-key (optional but recommended)
12 | ```
13 |
14 | 2. Get these values from your Supabase project:
15 | - Go to https://supabase.com/dashboard/project/your-project/settings/api
16 | - Copy the Project URL, anon key, and service role key
17 |
18 | ### Step 2: Run Setup Script
19 |
20 | Try the automated setup first:
21 | ```bash
22 | npm run setup:db
23 | ```
24 |
25 | If that doesn't work due to missing service role key, follow the manual setup below.
26 |
27 | ### Step 3: Manual Setup (if needed)
28 |
29 | 1. Go to your Supabase SQL Editor:
30 | https://supabase.com/dashboard/project/your-project/sql/new
31 |
32 | 2. Copy and paste the entire contents of:
33 | ```
34 | scripts/combined-migrations.sql
35 | ```
36 |
37 | 3. Click "Run" to execute all migrations
38 |
39 | ## What Gets Created
40 |
41 | The setup creates these tables:
42 |
43 | ### 1. **users** table
44 | - User authentication and profile data
45 | - Activity tracking (login times, article count, AI usage)
46 |
47 | ### 2. **articles** table
48 | - Article content and metadata
49 | - Activity tracking (time spent, edits, AI requests)
50 | - Session management
51 |
52 | ### 3. **workers** table
53 | - AI worker session tracking
54 | - Request counts and processing metrics
55 |
56 | ### 4. **activity_sessions** table
57 | - Detailed session analytics
58 | - Event tracking for user behavior
59 |
60 | ## Troubleshooting
61 |
62 | ### "Invalid API key" Error
63 | - Double-check your Supabase URL and API keys
64 | - Ensure there are no typos (like "yhttps" instead of "https")
65 | - Make sure you're using the correct project's credentials
66 |
67 | ### "User with this email already exists" Error
68 | 1. This might mean the database isn't properly connected
69 | 2. Check if tables exist in Supabase Table Editor
70 | 3. Try registering with a different email to test
71 |
72 | ### Tables Not Created
73 | 1. Run the manual setup steps above
74 | 2. Check Supabase logs for any SQL errors
75 | 3. Ensure you have proper permissions in your Supabase project
76 |
77 | ### Connection Issues
78 | - Verify your internet connection
79 | - Check if Supabase service is operational
80 | - Try accessing your Supabase dashboard directly
81 |
82 | ## Testing Your Setup
83 |
84 | After setup, test by:
85 |
86 | 1. Starting the dev server:
87 | ```bash
88 | npm run dev
89 | ```
90 |
91 | 2. Navigate to http://localhost:3000/login
92 |
93 | 3. Try registering a new account
94 |
95 | 4. If successful, you should be able to:
96 | - Create articles
97 | - Track activity
98 | - Use AI features
99 |
100 | ## Alternative Setup Methods
101 |
102 | If the Node.js script doesn't work, try:
103 |
104 | ```bash
105 | # Bash script (requires psql)
106 | npm run setup:db:bash
107 |
108 | # Python script (requires psycopg2)
109 | pip install python-dotenv psycopg2-binary
110 | npm run setup:db:python
111 | ```
112 |
113 | ## Need Help?
114 |
115 | 1. Check Supabase logs for detailed error messages
116 | 2. Verify all environment variables are correctly set
117 | 3. Ensure your Supabase project is active and not paused
118 | 4. Try the manual SQL setup if automated scripts fail
--------------------------------------------------------------------------------
/lib/diff-generator.ts:
--------------------------------------------------------------------------------
1 | export interface DiffLine {
2 | type: 'unchanged' | 'added' | 'removed';
3 | content: string;
4 | lineNumber?: number;
5 | }
6 |
7 | export interface DiffResult {
8 | lines: DiffLine[];
9 | addedCount: number;
10 | removedCount: number;
11 | unchangedCount: number;
12 | }
13 |
14 | export class DiffGenerator {
15 | generate(original: string, modified: string): DiffResult {
16 | const originalLines = original.split('\n');
17 | const modifiedLines = modified.split('\n');
18 |
19 | const result: DiffLine[] = [];
20 | let addedCount = 0;
21 | let removedCount = 0;
22 | let unchangedCount = 0;
23 |
24 | // Simple line-by-line diff (can be improved with proper diff algorithm)
25 | const maxLength = Math.max(originalLines.length, modifiedLines.length);
26 |
27 | for (let i = 0; i < maxLength; i++) {
28 | const origLine = originalLines[i];
29 | const modLine = modifiedLines[i];
30 |
31 | if (origLine === modLine) {
32 | result.push({
33 | type: 'unchanged',
34 | content: origLine || '',
35 | lineNumber: i + 1
36 | });
37 | unchangedCount++;
38 | } else if (origLine === undefined) {
39 | result.push({
40 | type: 'added',
41 | content: modLine,
42 | lineNumber: i + 1
43 | });
44 | addedCount++;
45 | } else if (modLine === undefined) {
46 | result.push({
47 | type: 'removed',
48 | content: origLine,
49 | lineNumber: i + 1
50 | });
51 | removedCount++;
52 | } else {
53 | // Both exist but are different
54 | result.push({
55 | type: 'removed',
56 | content: origLine,
57 | lineNumber: i + 1
58 | });
59 | result.push({
60 | type: 'added',
61 | content: modLine,
62 | lineNumber: i + 1
63 | });
64 | removedCount++;
65 | addedCount++;
66 | }
67 | }
68 |
69 | return {
70 | lines: result,
71 | addedCount,
72 | removedCount,
73 | unchangedCount
74 | };
75 | }
76 |
77 | // Generate a simple word-level diff for inline display
78 | generateInline(original: string, modified: string): string {
79 | const originalWords = original.split(/\s+/);
80 | const modifiedWords = modified.split(/\s+/);
81 |
82 | let result = '';
83 | let i = 0, j = 0;
84 |
85 | while (i < originalWords.length || j < modifiedWords.length) {
86 | if (i >= originalWords.length) {
87 | // Remaining words are additions
88 | result += `${modifiedWords.slice(j).join(' ')}`;
89 | break;
90 | } else if (j >= modifiedWords.length) {
91 | // Remaining words are deletions
92 | result += `${originalWords.slice(i).join(' ')}`;
93 | break;
94 | } else if (originalWords[i] === modifiedWords[j]) {
95 | // Words match
96 | result += originalWords[i] + ' ';
97 | i++;
98 | j++;
99 | } else {
100 | // Words differ - simple approach: show removal then addition
101 | result += `${originalWords[i]} `;
102 | result += `${modifiedWords[j]} `;
103 | i++;
104 | j++;
105 | }
106 | }
107 |
108 | return result.trim();
109 | }
110 | }
--------------------------------------------------------------------------------
/lib/claude-integration.md:
--------------------------------------------------------------------------------
1 | # Claude API Integration
2 |
3 | This document describes the Claude API integration for the xfunnel application.
4 |
5 | ## Overview
6 |
7 | The Claude integration provides:
8 | - Streaming and non-streaming API responses
9 | - Built-in rate limiting
10 | - Error handling and retry logic
11 | - TypeScript types for type safety
12 | - React hooks for easy component integration
13 |
14 | ## Setup
15 |
16 | 1. Add your Anthropic API key to your environment variables:
17 | ```bash
18 | ANTHROPIC_API_KEY=your-api-key-here
19 | ```
20 |
21 | ## Usage
22 |
23 | ### API Route
24 |
25 | The Claude API is available at `/api/claude` and accepts POST requests with the following structure:
26 |
27 | ```typescript
28 | {
29 | messages: ClaudeMessage[];
30 | systemPrompt?: string; // Optional, defaults to article editor prompt
31 | stream?: boolean; // Enable streaming responses
32 | maxTokens?: number; // Maximum tokens in response (default: 4096)
33 | context?: string; // Additional context for the system prompt
34 | }
35 | ```
36 |
37 | ### Client-Side Usage
38 |
39 | #### Using the React Hook
40 |
41 | ```typescript
42 | import { useClaude } from '@/hooks/useClaude';
43 |
44 | function ArticleEditor() {
45 | const { messages, isLoading, error, sendMessage, streamingText } = useClaude({
46 | systemPrompt: 'Custom prompt here',
47 | maxTokens: 2000,
48 | });
49 |
50 | const handleSubmit = async (text: string) => {
51 | await sendMessage(text, true); // true for streaming
52 | };
53 |
54 | return (
55 | // Your component UI
56 | );
57 | }
58 | ```
59 |
60 | #### Using the Client Directly
61 |
62 | ```typescript
63 | import { claudeClient } from '@/lib/claude-client';
64 |
65 | // Non-streaming request
66 | const response = await claudeClient.sendMessage([
67 | { role: 'user', content: 'Help me improve this article...' }
68 | ]);
69 |
70 | // Streaming request
71 | await claudeClient.sendStreamingMessage(
72 | messages,
73 | (chunk) => {
74 | console.log('Received chunk:', chunk.text);
75 | }
76 | );
77 | ```
78 |
79 | ## Rate Limiting
80 |
81 | The integration includes automatic rate limiting:
82 | - Maximum 50 requests per minute
83 | - Maximum 40,000 tokens per minute
84 | - Maximum 5 concurrent requests
85 |
86 | The rate limiter automatically queues requests when limits are reached.
87 |
88 | ## Error Handling
89 |
90 | The integration handles common errors:
91 | - Invalid API key (401)
92 | - Rate limit exceeded (429)
93 | - Server errors (500, 502, 503)
94 |
95 | Errors are properly typed and include helpful messages.
96 |
97 | ## Types
98 |
99 | Key TypeScript types:
100 |
101 | ```typescript
102 | interface ClaudeMessage {
103 | role: 'user' | 'assistant';
104 | content: string;
105 | }
106 |
107 | interface ClaudeResponse {
108 | id: string;
109 | content: string;
110 | stop_reason: string;
111 | model: string;
112 | usage?: {
113 | input_tokens: number;
114 | output_tokens: number;
115 | };
116 | }
117 | ```
118 |
119 | ## System Prompts
120 |
121 | Pre-configured prompts are available in `lib/claude-prompts.ts`:
122 | - `ARTICLE_EDITOR_PROMPT` - For article editing
123 | - `CONTENT_ANALYZER_PROMPT` - For content analysis
124 | - `TITLE_GENERATOR_PROMPT` - For title generation
125 | - `SUMMARY_GENERATOR_PROMPT` - For summary creation
126 |
127 | ## Best Practices
128 |
129 | 1. Always handle errors gracefully
130 | 2. Use streaming for long responses
131 | 3. Provide clear system prompts
132 | 4. Monitor token usage
133 | 5. Cache responses when appropriate
--------------------------------------------------------------------------------
/app/api/auth/register/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from 'next/server';
2 | import { createUser, generateToken } from '@/lib/auth';
3 | import { rateLimit, getClientIdentifier } from '@/lib/rate-limiter';
4 | import { logger } from '@/lib/logger';
5 |
6 | export async function POST(request: NextRequest) {
7 | try {
8 | // Rate limiting
9 | const clientId = getClientIdentifier(request);
10 | const { success, remaining, reset } = await rateLimit(clientId, 'auth');
11 |
12 | if (!success) {
13 | return NextResponse.json(
14 | {
15 | error: 'Too many registration attempts. Please try again later.',
16 | retryAfter: Math.ceil((reset - Date.now()) / 1000)
17 | },
18 | {
19 | status: 429,
20 | headers: {
21 | 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
22 | 'X-RateLimit-Remaining': '0',
23 | 'X-RateLimit-Reset': new Date(reset).toISOString()
24 | }
25 | }
26 | );
27 | }
28 |
29 | const { email, password, adminPassword } = await request.json();
30 |
31 | if (!email || !password) {
32 | return NextResponse.json(
33 | { error: 'Email and password are required' },
34 | { status: 400 }
35 | );
36 | }
37 |
38 | // Check admin password requirement
39 | const requiredAdminPassword = process.env.API_KEY_SECRET;
40 | if (requiredAdminPassword) {
41 | if (!adminPassword) {
42 | return NextResponse.json(
43 | { error: 'Admin password is required for registration' },
44 | { status: 403 }
45 | );
46 | }
47 |
48 | if (adminPassword !== requiredAdminPassword) {
49 | return NextResponse.json(
50 | { error: 'Invalid admin password' },
51 | { status: 403 }
52 | );
53 | }
54 | }
55 |
56 | // Basic email validation
57 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
58 | if (!emailRegex.test(email)) {
59 | return NextResponse.json(
60 | { error: 'Invalid email format' },
61 | { status: 400 }
62 | );
63 | }
64 |
65 | // Basic password validation (at least 6 characters)
66 | if (password.length < 6) {
67 | return NextResponse.json(
68 | { error: 'Password must be at least 6 characters long' },
69 | { status: 400 }
70 | );
71 | }
72 |
73 | const user = await createUser(email, password);
74 |
75 | if (!user) {
76 | // Check if this is a configuration issue
77 | const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
78 | const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
79 |
80 | if (!supabaseUrl || !supabaseKey || supabaseUrl === 'your-supabase-project-url/') {
81 | return NextResponse.json(
82 | {
83 | error: 'Database not configured. Please check your Supabase settings in .env.local',
84 | details: 'See DATABASE_SETUP.md for instructions'
85 | },
86 | { status: 503 }
87 | );
88 | }
89 |
90 | return NextResponse.json(
91 | { error: 'User with this email already exists' },
92 | { status: 409 }
93 | );
94 | }
95 |
96 | const token = generateToken(user);
97 |
98 | return NextResponse.json({
99 | user: {
100 | id: user.id,
101 | email: user.email,
102 | },
103 | token,
104 | });
105 | } catch (error) {
106 | console.error('Registration error:', error);
107 | return NextResponse.json(
108 | { error: 'Internal server error' },
109 | { status: 500 }
110 | );
111 | }
112 | }
--------------------------------------------------------------------------------
/lib/rate-limiter.ts:
--------------------------------------------------------------------------------
1 | import { logger } from './logger';
2 |
3 | interface RateLimitStore {
4 | [key: string]: {
5 | count: number;
6 | resetTime: number;
7 | };
8 | }
9 |
10 | class MemoryRateLimiter {
11 | private store: RateLimitStore = {};
12 | private cleanupInterval: NodeJS.Timeout;
13 |
14 | constructor() {
15 | // Clean up expired entries every minute
16 | this.cleanupInterval = setInterval(() => {
17 | this.cleanup();
18 | }, 60000);
19 | }
20 |
21 | cleanup() {
22 | const now = Date.now();
23 | for (const key in this.store) {
24 | if (this.store[key].resetTime < now) {
25 | delete this.store[key];
26 | }
27 | }
28 | }
29 |
30 | async limit(
31 | key: string,
32 | maxRequests: number,
33 | windowMs: number
34 | ): Promise<{ success: boolean; limit: number; remaining: number; reset: number }> {
35 | const now = Date.now();
36 | const resetTime = now + windowMs;
37 |
38 | if (!this.store[key] || this.store[key].resetTime < now) {
39 | this.store[key] = { count: 1, resetTime };
40 | return {
41 | success: true,
42 | limit: maxRequests,
43 | remaining: maxRequests - 1,
44 | reset: resetTime,
45 | };
46 | }
47 |
48 | if (this.store[key].count >= maxRequests) {
49 | return {
50 | success: false,
51 | limit: maxRequests,
52 | remaining: 0,
53 | reset: this.store[key].resetTime,
54 | };
55 | }
56 |
57 | this.store[key].count++;
58 | return {
59 | success: true,
60 | limit: maxRequests,
61 | remaining: maxRequests - this.store[key].count,
62 | reset: this.store[key].resetTime,
63 | };
64 | }
65 |
66 | destroy() {
67 | if (this.cleanupInterval) {
68 | clearInterval(this.cleanupInterval);
69 | }
70 | }
71 | }
72 |
73 | // Create singleton instance
74 | const memoryLimiter = new MemoryRateLimiter();
75 |
76 | // Rate limiter configurations
77 | export const rateLimiters = {
78 | // Authentication endpoints - strict limits
79 | auth: {
80 | maxRequests: 5,
81 | windowMs: 15 * 60 * 1000, // 15 minutes
82 | },
83 | // API endpoints - moderate limits
84 | api: {
85 | maxRequests: 60,
86 | windowMs: 60 * 1000, // 1 minute
87 | },
88 | // Claude AI endpoints - limited by cost
89 | claude: {
90 | maxRequests: 20,
91 | windowMs: 60 * 1000, // 1 minute
92 | },
93 | };
94 |
95 | export async function rateLimit(
96 | identifier: string,
97 | limiterType: keyof typeof rateLimiters = 'api'
98 | ): Promise<{ success: boolean; limit: number; remaining: number; reset: number }> {
99 | const config = rateLimiters[limiterType];
100 |
101 | // For production, we'll use memory-based rate limiting to avoid external dependencies
102 | // If you want to use Redis-based rate limiting, install @upstash/ratelimit and @upstash/redis
103 | return memoryLimiter.limit(
104 | `${limiterType}:${identifier}`,
105 | config.maxRequests,
106 | config.windowMs
107 | );
108 | }
109 |
110 | // Helper function to get client identifier
111 | export function getClientIdentifier(request: Request): string {
112 | // Try to get real IP from various headers
113 | const forwardedFor = request.headers.get('x-forwarded-for');
114 | const realIp = request.headers.get('x-real-ip');
115 | const cfConnectingIp = request.headers.get('cf-connecting-ip');
116 |
117 | if (forwardedFor) {
118 | return forwardedFor.split(',')[0].trim();
119 | }
120 |
121 | if (realIp) {
122 | return realIp;
123 | }
124 |
125 | if (cfConnectingIp) {
126 | return cfConnectingIp;
127 | }
128 |
129 | // Fallback to a default identifier
130 | return '127.0.0.1';
131 | }
--------------------------------------------------------------------------------
/lib/claude-client.ts:
--------------------------------------------------------------------------------
1 | // Client-side utilities for Claude API interactions
2 |
3 | import { ClaudeMessage, ClaudeRequest, ClaudeResponse, ClaudeStreamChunk, isClaudeError } from './claude-types';
4 |
5 | export class ClaudeClient {
6 | private baseUrl: string;
7 |
8 | constructor(baseUrl: string = '/api/claude') {
9 | this.baseUrl = baseUrl;
10 | }
11 |
12 | // Send a non-streaming request to Claude
13 | async sendMessage(
14 | messages: ClaudeMessage[],
15 | options?: {
16 | systemPrompt?: string;
17 | maxTokens?: number;
18 | context?: string;
19 | }
20 | ): Promise {
21 | const request: ClaudeRequest = {
22 | messages,
23 | stream: false,
24 | ...options,
25 | };
26 |
27 | const response = await fetch(this.baseUrl, {
28 | method: 'POST',
29 | headers: {
30 | 'Content-Type': 'application/json',
31 | },
32 | body: JSON.stringify(request),
33 | });
34 |
35 | const data = await response.json();
36 |
37 | if (!response.ok || isClaudeError(data)) {
38 | throw new Error(data.error || 'Failed to get response from Claude');
39 | }
40 |
41 | return data as ClaudeResponse;
42 | }
43 |
44 | // Send a streaming request to Claude
45 | async sendStreamingMessage(
46 | messages: ClaudeMessage[],
47 | onChunk: (chunk: ClaudeStreamChunk) => void,
48 | options?: {
49 | systemPrompt?: string;
50 | maxTokens?: number;
51 | context?: string;
52 | }
53 | ): Promise {
54 | const request: ClaudeRequest = {
55 | messages,
56 | stream: true,
57 | ...options,
58 | };
59 |
60 | const response = await fetch(this.baseUrl, {
61 | method: 'POST',
62 | headers: {
63 | 'Content-Type': 'application/json',
64 | },
65 | body: JSON.stringify(request),
66 | });
67 |
68 | if (!response.ok) {
69 | const error = await response.json();
70 | throw new Error(error.error || 'Failed to start streaming response');
71 | }
72 |
73 | const reader = response.body?.getReader();
74 | if (!reader) {
75 | throw new Error('No response body available');
76 | }
77 |
78 | const decoder = new TextDecoder();
79 | let buffer = '';
80 |
81 | try {
82 | while (true) {
83 | const { done, value } = await reader.read();
84 | if (done) break;
85 |
86 | buffer += decoder.decode(value, { stream: true });
87 | const lines = buffer.split('\n');
88 | buffer = lines.pop() || '';
89 |
90 | for (const line of lines) {
91 | if (line.trim() === '') continue;
92 | if (line.startsWith('data: ')) {
93 | const data = line.slice(6);
94 | if (data === '[DONE]') {
95 | return;
96 | }
97 | try {
98 | const chunk = JSON.parse(data) as ClaudeStreamChunk;
99 | onChunk(chunk);
100 | } catch (e) {
101 | console.error('Failed to parse SSE chunk:', e);
102 | }
103 | }
104 | }
105 | }
106 | } finally {
107 | reader.releaseLock();
108 | }
109 | }
110 |
111 | // Helper method to format a conversation
112 | static formatConversation(userMessage: string, previousMessages?: ClaudeMessage[]): ClaudeMessage[] {
113 | const messages: ClaudeMessage[] = previousMessages || [];
114 | messages.push({ role: 'user', content: userMessage });
115 | return messages;
116 | }
117 |
118 | // Helper method to extract text from streaming chunks
119 | static extractTextFromChunks(chunks: ClaudeStreamChunk[]): string {
120 | return chunks.map(chunk => chunk.text).join('');
121 | }
122 | }
123 |
124 | // Default client instance
125 | export const claudeClient = new ClaudeClient();
--------------------------------------------------------------------------------
/lib/services/articles.ts:
--------------------------------------------------------------------------------
1 | import { supabase } from '@/lib/supabase'
2 | import type { Article, ArticleUpdate, ArticleResponse, ArticlesResponse } from '@/types/article'
3 |
4 | /**
5 | * Fetches all articles with status 'draft'
6 | * @returns Promise with articles array or error
7 | */
8 | export async function fetchDraftArticles(): Promise {
9 | try {
10 | const { data, error } = await supabase
11 | .from('articles')
12 | .select('*')
13 | .eq('status', 'draft')
14 | .order('updated_at', { ascending: false })
15 |
16 | if (error) {
17 | throw error
18 | }
19 |
20 | return {
21 | data: data as Article[],
22 | error: null
23 | }
24 | } catch (error) {
25 | console.error('Error fetching draft articles:', error)
26 | return {
27 | data: null,
28 | error: error as Error
29 | }
30 | }
31 | }
32 |
33 | /**
34 | * Updates an article's content by ID
35 | * @param id - The article UUID
36 | * @param content - The new content
37 | * @returns Promise with updated article or error
38 | */
39 | export async function updateArticle(id: string, content: string): Promise {
40 | try {
41 | if (!id) {
42 | throw new Error('Article ID is required')
43 | }
44 |
45 | if (content === undefined || content === null) {
46 | throw new Error('Content is required')
47 | }
48 |
49 | const { data, error } = await supabase
50 | .from('articles')
51 | .update({
52 | content,
53 | updated_at: new Date().toISOString()
54 | })
55 | .eq('id', id)
56 | .select()
57 | .single()
58 |
59 | if (error) {
60 | throw error
61 | }
62 |
63 | return {
64 | data: data as Article,
65 | error: null
66 | }
67 | } catch (error) {
68 | console.error('Error updating article:', error)
69 | return {
70 | data: null,
71 | error: error as Error
72 | }
73 | }
74 | }
75 |
76 | /**
77 | * Fetches a single article by ID
78 | * @param id - The article UUID
79 | * @returns Promise with article or error
80 | */
81 | export async function getArticleById(id: string): Promise {
82 | try {
83 | if (!id) {
84 | throw new Error('Article ID is required')
85 | }
86 |
87 | const { data, error } = await supabase
88 | .from('articles')
89 | .select('*')
90 | .eq('id', id)
91 | .single()
92 |
93 | if (error) {
94 | throw error
95 | }
96 |
97 | return {
98 | data: data as Article,
99 | error: null
100 | }
101 | } catch (error) {
102 | console.error('Error fetching article:', error)
103 | return {
104 | data: null,
105 | error: error as Error
106 | }
107 | }
108 | }
109 |
110 | /**
111 | * Updates multiple fields of an article
112 | * @param id - The article UUID
113 | * @param updates - Object containing fields to update
114 | * @returns Promise with updated article or error
115 | */
116 | export async function updateArticleFields(id: string, updates: ArticleUpdate): Promise {
117 | try {
118 | if (!id) {
119 | throw new Error('Article ID is required')
120 | }
121 |
122 | if (!updates || Object.keys(updates).length === 0) {
123 | throw new Error('No updates provided')
124 | }
125 |
126 | const { data, error } = await supabase
127 | .from('articles')
128 | .update({
129 | ...updates,
130 | updated_at: new Date().toISOString()
131 | })
132 | .eq('id', id)
133 | .select()
134 | .single()
135 |
136 | if (error) {
137 | throw error
138 | }
139 |
140 | return {
141 | data: data as Article,
142 | error: null
143 | }
144 | } catch (error) {
145 | console.error('Error updating article fields:', error)
146 | return {
147 | data: null,
148 | error: error as Error
149 | }
150 | }
151 | }
--------------------------------------------------------------------------------
/app/components/AuthProvider.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { createContext, useContext, useState, useEffect } from 'react';
4 | import { useRouter } from 'next/navigation';
5 |
6 | interface User {
7 | id: string;
8 | email: string;
9 | }
10 |
11 | interface AuthContextType {
12 | user: User | null;
13 | token: string | null;
14 | login: (email: string, password: string) => Promise;
15 | register: (email: string, password: string, adminPassword?: string) => Promise;
16 | logout: () => void;
17 | isLoading: boolean;
18 | }
19 |
20 | const AuthContext = createContext(undefined);
21 |
22 | export function AuthProvider({ children }: { children: React.ReactNode }) {
23 | const [user, setUser] = useState(null);
24 | const [token, setToken] = useState(null);
25 | const [isLoading, setIsLoading] = useState(true);
26 | const router = useRouter();
27 |
28 | useEffect(() => {
29 | // Check for stored auth data on mount
30 | const checkAuth = () => {
31 | try {
32 | const storedToken = localStorage.getItem('auth_token');
33 | const storedUserId = localStorage.getItem('user_id');
34 | const storedUserEmail = localStorage.getItem('user_email');
35 |
36 |
37 | if (storedToken && storedUserId && storedUserEmail) {
38 | setToken(storedToken);
39 | setUser({
40 | id: storedUserId,
41 | email: storedUserEmail,
42 | });
43 | }
44 | } catch (error) {
45 | // Silently handle auth check errors
46 | } finally {
47 | setIsLoading(false);
48 | }
49 | };
50 |
51 | checkAuth();
52 | }, []);
53 |
54 | const login = async (email: string, password: string) => {
55 | const response = await fetch('/api/auth/login', {
56 | method: 'POST',
57 | headers: {
58 | 'Content-Type': 'application/json',
59 | },
60 | body: JSON.stringify({ email, password }),
61 | });
62 |
63 | const data = await response.json();
64 |
65 | if (!response.ok) {
66 | throw new Error(data.error || 'Login failed');
67 | }
68 |
69 | localStorage.setItem('auth_token', data.token);
70 | localStorage.setItem('user_id', data.user.id);
71 | localStorage.setItem('user_email', data.user.email);
72 |
73 | setToken(data.token);
74 | setUser(data.user);
75 |
76 | console.log('Login successful, user set:', data.user);
77 |
78 | // Use replace instead of push to avoid back button issues
79 | router.replace('/');
80 | };
81 |
82 | const register = async (email: string, password: string, adminPassword?: string) => {
83 | const response = await fetch('/api/auth/register', {
84 | method: 'POST',
85 | headers: {
86 | 'Content-Type': 'application/json',
87 | },
88 | body: JSON.stringify({ email, password, adminPassword }),
89 | });
90 |
91 | const data = await response.json();
92 |
93 | if (!response.ok) {
94 | throw new Error(data.error || 'Registration failed');
95 | }
96 |
97 | localStorage.setItem('auth_token', data.token);
98 | localStorage.setItem('user_id', data.user.id);
99 | localStorage.setItem('user_email', data.user.email);
100 |
101 | setToken(data.token);
102 | setUser(data.user);
103 |
104 | console.log('Registration successful, user set:', data.user);
105 |
106 | // Use replace instead of push to avoid back button issues
107 | router.replace('/');
108 | };
109 |
110 | const logout = () => {
111 | localStorage.removeItem('auth_token');
112 | localStorage.removeItem('user_id');
113 | localStorage.removeItem('user_email');
114 | setToken(null);
115 | setUser(null);
116 | router.push('/login');
117 | };
118 |
119 | return (
120 |
121 | {children}
122 |
123 | );
124 | }
125 |
126 | export function useAuth() {
127 | const context = useContext(AuthContext);
128 | if (context === undefined) {
129 | throw new Error('useAuth must be used within an AuthProvider');
130 | }
131 | return context;
132 | }
--------------------------------------------------------------------------------
/lib/claude-rate-limiter.ts:
--------------------------------------------------------------------------------
1 | // Rate limiter for Claude API requests
2 |
3 | interface RateLimiterOptions {
4 | maxRequestsPerMinute: number;
5 | maxTokensPerMinute: number;
6 | maxConcurrentRequests: number;
7 | }
8 |
9 | interface RequestInfo {
10 | timestamp: number;
11 | tokens: number;
12 | }
13 |
14 | export class ClaudeRateLimiter {
15 | private options: RateLimiterOptions;
16 | private requests: RequestInfo[] = [];
17 | private currentConcurrentRequests = 0;
18 | private tokenUsage: Map = new Map();
19 |
20 | constructor(options: Partial = {}) {
21 | this.options = {
22 | maxRequestsPerMinute: options.maxRequestsPerMinute || 50,
23 | maxTokensPerMinute: options.maxTokensPerMinute || 40000,
24 | maxConcurrentRequests: options.maxConcurrentRequests || 5,
25 | };
26 | }
27 |
28 | async acquireSlot(estimatedTokens: number = 1000): Promise {
29 | // Clean up old requests
30 | this.cleanupOldRequests();
31 |
32 | // Check concurrent requests limit
33 | while (this.currentConcurrentRequests >= this.options.maxConcurrentRequests) {
34 | await this.delay(100);
35 | }
36 |
37 | // Check rate limits
38 | while (!this.canMakeRequest(estimatedTokens)) {
39 | const waitTime = this.getWaitTime();
40 | await this.delay(waitTime);
41 | this.cleanupOldRequests();
42 | }
43 |
44 | // Reserve the slot
45 | this.currentConcurrentRequests++;
46 | this.requests.push({
47 | timestamp: Date.now(),
48 | tokens: estimatedTokens,
49 | });
50 | }
51 |
52 | releaseSlot(actualTokens?: number): void {
53 | this.currentConcurrentRequests = Math.max(0, this.currentConcurrentRequests - 1);
54 |
55 | if (actualTokens && this.requests.length > 0) {
56 | // Update the last request with actual token usage
57 | const lastRequest = this.requests[this.requests.length - 1];
58 | lastRequest.tokens = actualTokens;
59 | }
60 | }
61 |
62 | private canMakeRequest(estimatedTokens: number): boolean {
63 | const oneMinuteAgo = Date.now() - 60000;
64 | const recentRequests = this.requests.filter(r => r.timestamp > oneMinuteAgo);
65 |
66 | // Check request count
67 | if (recentRequests.length >= this.options.maxRequestsPerMinute) {
68 | return false;
69 | }
70 |
71 | // Check token usage
72 | const recentTokens = recentRequests.reduce((sum, r) => sum + r.tokens, 0);
73 | if (recentTokens + estimatedTokens > this.options.maxTokensPerMinute) {
74 | return false;
75 | }
76 |
77 | return true;
78 | }
79 |
80 | private getWaitTime(): number {
81 | const oneMinuteAgo = Date.now() - 60000;
82 | const oldestRelevantRequest = this.requests.find(r => r.timestamp > oneMinuteAgo);
83 |
84 | if (!oldestRelevantRequest) {
85 | return 100; // Default wait time
86 | }
87 |
88 | // Wait until the oldest request is outside the 1-minute window
89 | const waitTime = (oldestRelevantRequest.timestamp + 60000) - Date.now();
90 | return Math.max(100, waitTime);
91 | }
92 |
93 | private cleanupOldRequests(): void {
94 | const oneMinuteAgo = Date.now() - 60000;
95 | this.requests = this.requests.filter(r => r.timestamp > oneMinuteAgo);
96 | }
97 |
98 | private delay(ms: number): Promise {
99 | return new Promise(resolve => setTimeout(resolve, ms));
100 | }
101 |
102 | // Get current rate limit status
103 | getStatus(): {
104 | requestsInLastMinute: number;
105 | tokensInLastMinute: number;
106 | concurrentRequests: number;
107 | canMakeRequest: boolean;
108 | } {
109 | this.cleanupOldRequests();
110 |
111 | const oneMinuteAgo = Date.now() - 60000;
112 | const recentRequests = this.requests.filter(r => r.timestamp > oneMinuteAgo);
113 | const recentTokens = recentRequests.reduce((sum, r) => sum + r.tokens, 0);
114 |
115 | return {
116 | requestsInLastMinute: recentRequests.length,
117 | tokensInLastMinute: recentTokens,
118 | concurrentRequests: this.currentConcurrentRequests,
119 | canMakeRequest: this.canMakeRequest(1000),
120 | };
121 | }
122 | }
123 |
124 | // Global rate limiter instance
125 | export const claudeRateLimiter = new ClaudeRateLimiter();
--------------------------------------------------------------------------------
/lib/claude-service.ts:
--------------------------------------------------------------------------------
1 | import Anthropic from '@anthropic-ai/sdk';
2 | import { Stream } from '@anthropic-ai/sdk/streaming';
3 | import { ClaudeMessage, ClaudeResponse } from './claude-types';
4 | import { claudeRateLimiter } from './claude-rate-limiter';
5 |
6 | // Initialize Anthropic client
7 | const anthropic = new Anthropic({
8 | apiKey: process.env.ANTHROPIC_API_KEY!,
9 | });
10 |
11 | export type { ClaudeMessage, ClaudeResponse } from './claude-types';
12 |
13 | // Format messages for Claude API
14 | export function formatMessages(messages: ClaudeMessage[]): Anthropic.MessageParam[] {
15 | return messages.map(msg => ({
16 | role: msg.role,
17 | content: msg.content,
18 | }));
19 | }
20 |
21 | // Create a completion with Claude
22 | export async function createCompletion(
23 | messages: ClaudeMessage[],
24 | systemPrompt: string,
25 | maxTokens: number = 4096
26 | ): Promise {
27 | try {
28 | // Estimate tokens (rough approximation)
29 | const estimatedTokens = messages.reduce((sum, msg) => sum + msg.content.length / 4, 0) + maxTokens;
30 | await claudeRateLimiter.acquireSlot(estimatedTokens);
31 |
32 | const response = await anthropic.messages.create({
33 | model: 'claude-3-opus-20240229',
34 | max_tokens: maxTokens,
35 | system: systemPrompt,
36 | messages: formatMessages(messages),
37 | });
38 |
39 | const result = {
40 | id: response.id,
41 | content: response.content[0].type === 'text' ? response.content[0].text : '',
42 | stop_reason: response.stop_reason || 'completed',
43 | model: response.model,
44 | usage: response.usage,
45 | };
46 |
47 | // Release slot with actual token usage
48 | claudeRateLimiter.releaseSlot(response.usage?.input_tokens + response.usage?.output_tokens);
49 |
50 | return result;
51 | } catch (error) {
52 | claudeRateLimiter.releaseSlot();
53 | console.error('Claude API Error:', error);
54 | throw handleClaudeError(error);
55 | }
56 | }
57 |
58 | // Create a streaming completion with Claude
59 | export async function createStreamingCompletion(
60 | messages: ClaudeMessage[],
61 | systemPrompt: string,
62 | maxTokens: number = 4096
63 | ): Promise> {
64 | try {
65 | // Estimate tokens (rough approximation)
66 | const estimatedTokens = messages.reduce((sum, msg) => sum + msg.content.length / 4, 0) + maxTokens;
67 | await claudeRateLimiter.acquireSlot(estimatedTokens);
68 |
69 | const stream = await anthropic.messages.create({
70 | model: 'claude-3-opus-20240229',
71 | max_tokens: maxTokens,
72 | system: systemPrompt,
73 | messages: formatMessages(messages),
74 | stream: true,
75 | });
76 |
77 | // Note: Token usage will be tracked when stream completes
78 | // The API route should handle releasing the rate limit slot
79 |
80 | return stream;
81 | } catch (error) {
82 | claudeRateLimiter.releaseSlot();
83 | console.error('Claude Streaming API Error:', error);
84 | throw handleClaudeError(error);
85 | }
86 | }
87 |
88 | // Error handling for Claude API
89 | function handleClaudeError(error: any): Error {
90 | if (error instanceof Anthropic.APIError) {
91 | switch (error.status) {
92 | case 401:
93 | return new Error('Invalid API key. Please check your ANTHROPIC_API_KEY environment variable.');
94 | case 429:
95 | return new Error('Rate limit exceeded. Please try again later.');
96 | case 500:
97 | case 502:
98 | case 503:
99 | return new Error('Claude API is temporarily unavailable. Please try again later.');
100 | default:
101 | return new Error(`Claude API error: ${error.message}`);
102 | }
103 | }
104 |
105 | if (error instanceof Error) {
106 | return error;
107 | }
108 |
109 | return new Error('An unexpected error occurred while calling Claude API');
110 | }
111 |
112 | // Helper function to convert streaming response to text
113 | export async function streamToText(stream: Stream): Promise {
114 | let fullText = '';
115 |
116 | for await (const event of stream) {
117 | if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
118 | fullText += event.delta.text;
119 | }
120 | }
121 |
122 | return fullText;
123 | }
--------------------------------------------------------------------------------
/app/components/UnifiedDiffViewer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as Diff from 'diff';
3 |
4 | interface UnifiedDiffViewerProps {
5 | original: string;
6 | modified: string;
7 | contextLines?: number;
8 | }
9 |
10 | export default function UnifiedDiffViewer({
11 | original,
12 | modified,
13 | contextLines = 3
14 | }: UnifiedDiffViewerProps) {
15 | const patchString = Diff.createPatch(
16 | 'article',
17 | original || '',
18 | modified || '',
19 | 'Original',
20 | 'Modified',
21 | { context: contextLines }
22 | );
23 |
24 | // Parse the patch to get structured diff
25 | const lines = patchString.split('\n').slice(4); // Skip header lines
26 |
27 | const renderLine = (line: string, index: number) => {
28 | if (line.startsWith('@@')) {
29 | // Hunk header
30 | return (
31 |
32 | {line}
33 |
34 | );
35 | } else if (line.startsWith('+')) {
36 | // Added line
37 | return (
38 |
39 | +
40 |
41 | {line.substring(1)}
42 |
43 |
44 | );
45 | } else if (line.startsWith('-')) {
46 | // Removed line
47 | return (
48 |
49 | -
50 |
51 | {line.substring(1)}
52 |
53 |
54 | );
55 | } else if (line.startsWith(' ')) {
56 | // Context line
57 | return (
58 |
59 |
60 |
61 | {line.substring(1)}
62 |
63 |
64 | );
65 | }
66 | return null;
67 | };
68 |
69 | // Calculate statistics
70 | const stats = {
71 | additions: lines.filter(l => l.startsWith('+')).length,
72 | deletions: lines.filter(l => l.startsWith('-')).length,
73 | hunks: lines.filter(l => l.startsWith('@@')).length
74 | };
75 |
76 | const hasChanges = stats.additions > 0 || stats.deletions > 0;
77 |
78 | return (
79 |
80 | {/* Header with stats */}
81 |
82 |
Unified Diff View
83 |
84 |
85 | +{stats.additions} additions
86 |
87 |
88 | -{stats.deletions} deletions
89 |
90 |
91 | {stats.hunks} {stats.hunks === 1 ? 'change' : 'changes'}
92 |
93 |
94 |
95 |
96 | {/* Diff content */}
97 |
98 | {hasChanges ? (
99 |
100 | {lines.map((line, index) => renderLine(line, index))}
101 |
102 | ) : (
103 |
104 |
105 |
No changes detected
106 |
The original and modified content are identical
107 |
108 |
109 | )}
110 |
111 |
112 | );
113 | }
--------------------------------------------------------------------------------
/app/components/MarkdownRenderer.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React from 'react';
4 |
5 | interface MarkdownRendererProps {
6 | content: string;
7 | className?: string;
8 | }
9 |
10 | export default function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
11 | // Simple markdown to HTML conversion
12 | const renderMarkdown = (text: string): string => {
13 | let html = text;
14 |
15 | // Code blocks first (to prevent other formatting inside)
16 | html = html.replace(/```([^`]+)```/g, '$1
');
17 |
18 | // Inline code (before other inline formatting)
19 | html = html.replace(/`([^`]+)`/g, '$1');
20 |
21 | // Headers with accent colors - handle lines with text before headers
22 | html = html.replace(/^(.*)### (.*$)/gim, function(match, before, header) {
23 | if (before.trim()) {
24 | return `${before}
\n${header}
`;
25 | }
26 | return `${header}
`;
27 | });
28 | html = html.replace(/^(.*)## (.*$)/gim, function(match, before, header) {
29 | if (before.trim()) {
30 | return `${before}
\n${header}
`;
31 | }
32 | return `${header}
`;
33 | });
34 | html = html.replace(/^(.*)# (.*$)/gim, function(match, before, header) {
35 | if (before.trim()) {
36 | return `${before}
\n${header}
`;
37 | }
38 | return `${header}
`;
39 | });
40 |
41 | // Bold (before italic to prevent conflicts)
42 | html = html.replace(/\*\*([^*]+)\*\*/g, '$1');
43 | html = html.replace(/__([^_]+)__/g, '$1');
44 |
45 | // Italic (more specific regex to avoid conflicts)
46 | html = html.replace(/(?$1');
47 | html = html.replace(/(?$1');
48 |
49 | // Links
50 | html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
51 |
52 | // Lists with proper text color
53 | html = html.replace(/^\* (.+)$/gim, '$1');
54 | html = html.replace(/^- (.+)$/gim, '$1');
55 | html = html.replace(/^\d+\. (.+)$/gim, '$1');
56 |
57 | // Blockquotes
58 | html = html.replace(/^> (.+)$/gim, '$1
');
59 |
60 | // Paragraphs with text color
61 | html = html.split('\n\n').map(para => {
62 | para = para.trim();
63 | if (para && !para.startsWith('<')) {
64 | return `${para}
`;
65 | }
66 | return para;
67 | }).join('\n');
68 |
69 | // Wrap lists with proper styling
70 | html = html.replace(/(]*>.*<\/li>\n?)+/g, (match) => {
71 | if (match.includes('list-decimal')) {
72 | return `${match}
`;
73 | }
74 | return ``;
75 | });
76 |
77 | return html;
78 | };
79 |
80 | return (
81 |
85 | );
86 | }
--------------------------------------------------------------------------------
/stores/chatStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand'
2 | import { ChatMessage, ChatSession } from '@/types/chat'
3 | import { Article } from '@/types/article'
4 |
5 | interface ChatState {
6 | // State
7 | currentSession: ChatSession | null
8 | sessions: ChatSession[]
9 | isLoading: boolean
10 | error: string | null
11 | currentArticle: Article | null
12 |
13 | // Actions
14 | createSession: (articleId?: string) => void
15 | addMessage: (message: Omit) => void
16 | setCurrentArticle: (article: Article | null) => void
17 | setLoading: (loading: boolean) => void
18 | setError: (error: string | null) => void
19 | clearSession: () => void
20 | loadSession: (sessionId: string) => void
21 | deleteSession: (sessionId: string) => void
22 | updateMessage: (messageId: string, content: string) => void
23 | }
24 |
25 | const generateId = () => Math.random().toString(36).substring(2) + Date.now().toString(36)
26 |
27 | export const useChatStore = create((set, get) => ({
28 | // Initial state
29 | currentSession: null,
30 | sessions: [],
31 | isLoading: false,
32 | error: null,
33 | currentArticle: null,
34 |
35 | // Create new chat session
36 | createSession: (articleId?: string) => {
37 | const newSession: ChatSession = {
38 | id: generateId(),
39 | messages: [],
40 | articleId,
41 | createdAt: new Date(),
42 | updatedAt: new Date()
43 | }
44 |
45 | set(state => ({
46 | currentSession: newSession,
47 | sessions: [...state.sessions, newSession],
48 | error: null
49 | }))
50 | },
51 |
52 | // Add message to current session
53 | addMessage: (message) => {
54 | const state = get()
55 | if (!state.currentSession) {
56 | state.createSession()
57 | }
58 |
59 | const newMessage: ChatMessage = {
60 | ...message,
61 | id: generateId(),
62 | timestamp: new Date()
63 | }
64 |
65 | set(state => {
66 | if (!state.currentSession) return state
67 |
68 | return {
69 | currentSession: {
70 | ...state.currentSession,
71 | messages: [...state.currentSession.messages, newMessage],
72 | updatedAt: new Date()
73 | },
74 | sessions: state.sessions.map(session =>
75 | session.id === state.currentSession?.id
76 | ? {
77 | ...session,
78 | messages: [...session.messages, newMessage],
79 | updatedAt: new Date()
80 | }
81 | : session
82 | )
83 | }
84 | })
85 | },
86 |
87 | // Set current article context
88 | setCurrentArticle: (article) => {
89 | set({ currentArticle: article })
90 | },
91 |
92 | // Set loading state
93 | setLoading: (loading) => {
94 | set({ isLoading: loading })
95 | },
96 |
97 | // Set error state
98 | setError: (error) => {
99 | set({ error, isLoading: false })
100 | },
101 |
102 | // Clear current session
103 | clearSession: () => {
104 | set({
105 | currentSession: null,
106 | error: null,
107 | currentArticle: null
108 | })
109 | },
110 |
111 | // Load existing session
112 | loadSession: (sessionId) => {
113 | set(state => {
114 | const session = state.sessions.find(s => s.id === sessionId)
115 | return session ? { currentSession: session, error: null } : state
116 | })
117 | },
118 |
119 | // Delete session
120 | deleteSession: (sessionId) => {
121 | set(state => ({
122 | sessions: state.sessions.filter(s => s.id !== sessionId),
123 | currentSession: state.currentSession?.id === sessionId ? null : state.currentSession
124 | }))
125 | },
126 |
127 | // Update message content
128 | updateMessage: (messageId, content) => {
129 | set(state => {
130 | if (!state.currentSession) return state
131 |
132 | const updatedMessages = state.currentSession.messages.map(msg =>
133 | msg.id === messageId ? { ...msg, content } : msg
134 | )
135 |
136 | return {
137 | currentSession: {
138 | ...state.currentSession,
139 | messages: updatedMessages,
140 | updatedAt: new Date()
141 | },
142 | sessions: state.sessions.map(session =>
143 | session.id === state.currentSession?.id
144 | ? {
145 | ...session,
146 | messages: updatedMessages,
147 | updatedAt: new Date()
148 | }
149 | : session
150 | )
151 | }
152 | })
153 | }
154 | }))
--------------------------------------------------------------------------------
/app/api/claude/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from 'next/server';
2 | import { AIAssistant, UserPreferences, Message } from '@/lib/ai-assistant';
3 | import { TextAnalysis } from '@/lib/text-analysis';
4 | import { rateLimit, getClientIdentifier } from '@/lib/rate-limiter';
5 | import { logger } from '@/lib/logger';
6 |
7 | export async function POST(request: NextRequest) {
8 | try {
9 | // Rate limiting for Claude API
10 | const clientId = getClientIdentifier(request);
11 | const { success, remaining, reset } = await rateLimit(clientId, 'claude');
12 |
13 | if (!success) {
14 | return NextResponse.json(
15 | {
16 | error: 'AI rate limit exceeded. Please wait before making more requests.',
17 | retryAfter: Math.ceil((reset - Date.now()) / 1000)
18 | },
19 | {
20 | status: 429,
21 | headers: {
22 | 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
23 | 'X-RateLimit-Remaining': '0',
24 | 'X-RateLimit-Reset': new Date(reset).toISOString()
25 | }
26 | }
27 | );
28 | }
29 |
30 | const body = await request.json();
31 | const { articleContent, userMessage, conversationHistory = [], userPreferences = {} } = body;
32 |
33 | // Convert conversation history to the format expected by AI Assistant
34 | const messages: Message[] = conversationHistory.map((msg: any) => ({
35 | id: msg.id || Date.now().toString(),
36 | role: msg.role,
37 | content: msg.content,
38 | timestamp: new Date(msg.timestamp || Date.now())
39 | }));
40 |
41 | // Initialize user preferences with defaults
42 | const preferences: UserPreferences = {
43 | autoSuggest: true,
44 | preserveVoice: true,
45 | ...userPreferences
46 | };
47 |
48 | // Create AI Assistant instance
49 | const assistant = new AIAssistant(articleContent, messages, preferences);
50 |
51 | // Process the user's request
52 | const response = await assistant.processRequest(userMessage);
53 |
54 | // Get enhanced metadata
55 | const articleMetadata = assistant.getArticleMetadata();
56 | const textMetrics = TextAnalysis.calculateMetrics(articleContent);
57 | const sentimentAnalysis = TextAnalysis.analyzeSentiment(articleContent);
58 | const qualityMetrics = TextAnalysis.analyzeQuality(articleContent);
59 | // Prepare enhanced response
60 | const apiResponse = {
61 | message: response.message,
62 | suggestedContent: response.suggestedContent,
63 | hasSuggestion: response.hasSuggestion,
64 | commands: response.commands,
65 | metadata: {
66 | ...response.metadata,
67 | articleAnalysis: {
68 | wordCount: articleMetadata.wordCount,
69 | readabilityScore: articleMetadata.readabilityScore,
70 | tone: articleMetadata.tone,
71 | sentiment: articleMetadata.sentiment,
72 | topics: articleMetadata.topics,
73 | keywords: articleMetadata.keywords
74 | },
75 | textMetrics: {
76 | readabilityScores: textMetrics.readabilityScores,
77 | complexWordCount: textMetrics.complexWordCount,
78 | averageSentenceLength: textMetrics.averageSentenceLength
79 | },
80 | sentiment: sentimentAnalysis,
81 | quality: qualityMetrics
82 | },
83 | analysis: response.analysis,
84 | usage: {
85 | input_tokens: 150 + (conversationHistory.length * 50),
86 | output_tokens: 250,
87 | total_tokens: 400 + (conversationHistory.length * 50)
88 | }
89 | };
90 |
91 | console.log('Sending enhanced AI response:', {
92 | hasSuggestion: response.hasSuggestion,
93 | commandType: response.commands?.[0]?.action,
94 | confidence: response.metadata?.confidence,
95 | suggestedContentLength: response.suggestedContent?.length,
96 | messageLength: response.message.length,
97 | articleTone: articleMetadata.tone,
98 | readabilityScore: articleMetadata.readabilityScore
99 | });
100 |
101 | // Simulate a slight delay for more natural interaction
102 | await new Promise(resolve => setTimeout(resolve, 300));
103 |
104 | return NextResponse.json(apiResponse);
105 | } catch (error) {
106 | console.error('Error in Claude route:', error);
107 | return NextResponse.json(
108 | { error: 'Failed to process request' },
109 | { status: 500 }
110 | );
111 | }
112 | }
113 |
114 | export async function OPTIONS(request: NextRequest) {
115 | return new NextResponse(null, {
116 | status: 200,
117 | headers: {
118 | 'Access-Control-Allow-Origin': '*',
119 | 'Access-Control-Allow-Methods': 'POST, OPTIONS',
120 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
121 | },
122 | });
123 | }
--------------------------------------------------------------------------------
/scripts/debug-registration.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const { createClient } = require('@supabase/supabase-js');
4 | require('dotenv').config({ path: '.env.local' });
5 |
6 | // Get environment variables
7 | const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
8 | const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
9 | const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
10 |
11 | console.log('Debug Registration Issues\n');
12 | console.log('Using URL:', supabaseUrl);
13 | console.log('Using Service Role Key:', serviceRoleKey ? 'Yes' : 'No');
14 |
15 | // Create clients with both keys
16 | const anonClient = createClient(supabaseUrl, supabaseAnonKey);
17 | const serviceClient = createClient(supabaseUrl, serviceRoleKey);
18 |
19 | async function testWithClient(client, clientName) {
20 | console.log(`\n--- Testing with ${clientName} ---`);
21 |
22 | // 1. Check if we can select from users table
23 | console.log('1. Testing SELECT on users table:');
24 | try {
25 | const { data, error, count } = await client
26 | .from('users')
27 | .select('*', { count: 'exact' });
28 |
29 | if (error) {
30 | console.log(` ❌ Error: ${error.message}`);
31 | } else {
32 | console.log(` ✅ Success! Found ${count} users`);
33 | if (data && data.length > 0) {
34 | console.log(' Existing users:');
35 | data.forEach(u => console.log(` - ${u.email} (ID: ${u.id})`));
36 | }
37 | }
38 | } catch (err) {
39 | console.log(` ❌ Exception: ${err.message}`);
40 | }
41 |
42 | // 2. Check if we can insert
43 | console.log('\n2. Testing INSERT on users table:');
44 | const testEmail = `test-${Date.now()}@example.com`;
45 | try {
46 | const { data, error } = await client
47 | .from('users')
48 | .insert({
49 | email: testEmail,
50 | password: 'hashed-password-test',
51 | name: 'Test User',
52 | created_at: new Date().toISOString(),
53 | updated_at: new Date().toISOString()
54 | })
55 | .select()
56 | .single();
57 |
58 | if (error) {
59 | console.log(` ❌ Error: ${error.message}`);
60 | if (error.details) console.log(` Details: ${error.details}`);
61 | if (error.hint) console.log(` Hint: ${error.hint}`);
62 | } else {
63 | console.log(` ✅ Success! Created user: ${data.email}`);
64 |
65 | // Clean up - delete the test user
66 | await client.from('users').delete().eq('id', data.id);
67 | }
68 | } catch (err) {
69 | console.log(` ❌ Exception: ${err.message}`);
70 | }
71 |
72 | // 3. Check RLS status
73 | console.log('\n3. Checking RLS policies:');
74 | try {
75 | // This will only work with service role key
76 | const { data, error } = await client
77 | .rpc('get_policies', { table_name: 'users' })
78 | .single();
79 |
80 | if (error) {
81 | console.log(` ℹ️ Cannot check policies with this client`);
82 | } else {
83 | console.log(` Policies: ${JSON.stringify(data)}`);
84 | }
85 | } catch (err) {
86 | // Expected to fail with anon key
87 | }
88 | }
89 |
90 | async function checkSpecificUser(email) {
91 | console.log(`\n--- Checking for user: ${email} ---`);
92 |
93 | // Try with both clients
94 | for (const [client, name] of [[anonClient, 'Anon'], [serviceClient, 'Service']]) {
95 | console.log(`\nWith ${name} client:`);
96 | try {
97 | const { data, error } = await client
98 | .from('users')
99 | .select('*')
100 | .eq('email', email)
101 | .single();
102 |
103 | if (error && error.code === 'PGRST116') {
104 | console.log(' User not found');
105 | } else if (error) {
106 | console.log(` Error: ${error.message}`);
107 | } else {
108 | console.log(` ✅ User found!`);
109 | console.log(` ID: ${data.id}`);
110 | console.log(` Email: ${data.email}`);
111 | console.log(` Created: ${data.created_at}`);
112 | }
113 | } catch (err) {
114 | console.log(` Exception: ${err.message}`);
115 | }
116 | }
117 | }
118 |
119 | // Run tests
120 | async function main() {
121 | await testWithClient(anonClient, 'Anon Key Client');
122 | await testWithClient(serviceClient, 'Service Role Key Client');
123 | await checkSpecificUser('xeliadx@gmail.com');
124 |
125 | console.log('\n--- Summary ---');
126 | console.log('If inserts fail with "row-level security policy" error:');
127 | console.log(' → RLS is still enabled on the users table');
128 | console.log(' → Run this SQL in Supabase: ALTER TABLE users DISABLE ROW LEVEL SECURITY;');
129 | console.log('\nIf selects return no data with anon key but work with service key:');
130 | console.log(' → RLS policies are blocking reads');
131 | }
132 |
133 | main().catch(console.error);
--------------------------------------------------------------------------------
/scripts/setup-supabase-db.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Colors for output
4 | RED='\033[0;31m'
5 | GREEN='\033[0;32m'
6 | YELLOW='\033[1;33m'
7 | NC='\033[0m' # No Color
8 |
9 | echo -e "${GREEN}=== Supabase Database Setup Script ===${NC}\n"
10 |
11 | # Check if .env.local exists
12 | if [ ! -f .env.local ]; then
13 | echo -e "${RED}Error: .env.local file not found!${NC}"
14 | echo "Please create .env.local with your Supabase credentials"
15 | exit 1
16 | fi
17 |
18 | # Load environment variables from .env.local
19 | export $(cat .env.local | grep -v '^#' | xargs)
20 |
21 | # Check required environment variables
22 | if [ -z "$NEXT_PUBLIC_SUPABASE_URL" ] || [ -z "$SUPABASE_SERVICE_ROLE_KEY" ]; then
23 | echo -e "${RED}Error: Missing required environment variables!${NC}"
24 | echo "Please ensure NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are set in .env.local"
25 | exit 1
26 | fi
27 |
28 | # Extract project ref from Supabase URL
29 | PROJECT_REF=$(echo $NEXT_PUBLIC_SUPABASE_URL | sed -n 's/https:\/\/\(.*\)\.supabase\.co.*/\1/p')
30 |
31 | if [ -z "$PROJECT_REF" ]; then
32 | echo -e "${RED}Error: Could not extract project reference from Supabase URL${NC}"
33 | echo "URL should be in format: https://yourproject.supabase.co"
34 | exit 1
35 | fi
36 |
37 | echo -e "${YELLOW}Project Reference: ${PROJECT_REF}${NC}"
38 | echo -e "${YELLOW}Supabase URL: ${NEXT_PUBLIC_SUPABASE_URL}${NC}\n"
39 |
40 | # Check if psql is installed
41 | if ! command -v psql &> /dev/null; then
42 | echo -e "${RED}Error: PostgreSQL client (psql) is not installed!${NC}"
43 | echo "Please install PostgreSQL client:"
44 | echo " Ubuntu/Debian: sudo apt-get install postgresql-client"
45 | echo " macOS: brew install postgresql"
46 | echo " Windows: Download from https://www.postgresql.org/download/"
47 | exit 1
48 | fi
49 |
50 | # Database connection string
51 | DB_URL="postgresql://postgres.${PROJECT_REF}:${SUPABASE_SERVICE_ROLE_KEY}@aws-0-us-west-1.pooler.supabase.com:6543/postgres"
52 |
53 | # Test connection
54 | echo -e "${YELLOW}Testing database connection...${NC}"
55 | PGPASSWORD="${SUPABASE_SERVICE_ROLE_KEY}" psql "${DB_URL}" -c "SELECT version();" > /dev/null 2>&1
56 |
57 | if [ $? -ne 0 ]; then
58 | echo -e "${RED}Error: Could not connect to Supabase database!${NC}"
59 | echo "Please check your credentials and try again."
60 | exit 1
61 | fi
62 |
63 | echo -e "${GREEN}✓ Successfully connected to Supabase database${NC}\n"
64 |
65 | # Get migration files
66 | MIGRATION_DIR="supabase/migrations"
67 | if [ ! -d "$MIGRATION_DIR" ]; then
68 | echo -e "${RED}Error: Migration directory not found!${NC}"
69 | echo "Expected directory: $MIGRATION_DIR"
70 | exit 1
71 | fi
72 |
73 | # Run migrations in order
74 | echo -e "${YELLOW}Running migrations...${NC}\n"
75 |
76 | for migration in $(ls $MIGRATION_DIR/*.sql | sort); do
77 | filename=$(basename "$migration")
78 | echo -e "${YELLOW}Running migration: $filename${NC}"
79 |
80 | # Run the migration
81 | PGPASSWORD="${SUPABASE_SERVICE_ROLE_KEY}" psql "${DB_URL}" -f "$migration" 2>&1 | while IFS= read -r line; do
82 | if [[ $line == *"ERROR"* ]]; then
83 | echo -e "${RED}$line${NC}"
84 | elif [[ $line == *"NOTICE"* ]]; then
85 | echo -e "${YELLOW}$line${NC}"
86 | else
87 | echo "$line"
88 | fi
89 | done
90 |
91 | if [ ${PIPESTATUS[0]} -eq 0 ]; then
92 | echo -e "${GREEN}✓ $filename completed successfully${NC}\n"
93 | else
94 | echo -e "${RED}✗ $filename failed${NC}\n"
95 | echo -e "${YELLOW}You may need to fix this migration and run it manually${NC}"
96 | fi
97 | done
98 |
99 | # Verify tables were created
100 | echo -e "${YELLOW}Verifying database setup...${NC}\n"
101 |
102 | TABLES=("users" "articles" "workers")
103 | ALL_GOOD=true
104 |
105 | for table in "${TABLES[@]}"; do
106 | result=$(PGPASSWORD="${SUPABASE_SERVICE_ROLE_KEY}" psql "${DB_URL}" -t -c "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = '$table');" 2>/dev/null | tr -d '[:space:]')
107 |
108 | if [ "$result" = "t" ]; then
109 | echo -e "${GREEN}✓ Table '$table' exists${NC}"
110 | else
111 | echo -e "${RED}✗ Table '$table' not found${NC}"
112 | ALL_GOOD=false
113 | fi
114 | done
115 |
116 | echo ""
117 |
118 | if [ "$ALL_GOOD" = true ]; then
119 | echo -e "${GREEN}=== Database setup completed successfully! ===${NC}"
120 | echo -e "\nYou can now:"
121 | echo -e " 1. Register new users at http://localhost:3000/login"
122 | echo -e " 2. Create and manage articles"
123 | echo -e " 3. Track user activity"
124 | else
125 | echo -e "${RED}=== Some tables are missing ===${NC}"
126 | echo -e "\nPlease check the migration errors above and fix them."
127 | echo -e "You can also run migrations manually in the Supabase SQL Editor."
128 | fi
--------------------------------------------------------------------------------
/lib/error-handler.ts:
--------------------------------------------------------------------------------
1 | import { logger } from './logger';
2 |
3 | export class AppError extends Error {
4 | public readonly statusCode: number;
5 | public readonly isOperational: boolean;
6 | public readonly code?: string;
7 |
8 | constructor(
9 | message: string,
10 | statusCode: number = 500,
11 | isOperational: boolean = true,
12 | code?: string
13 | ) {
14 | super(message);
15 | this.statusCode = statusCode;
16 | this.isOperational = isOperational;
17 | this.code = code;
18 |
19 | Error.captureStackTrace(this, this.constructor);
20 | }
21 | }
22 |
23 | export class ValidationError extends AppError {
24 | constructor(message: string) {
25 | super(message, 400, true, 'VALIDATION_ERROR');
26 | }
27 | }
28 |
29 | export class AuthenticationError extends AppError {
30 | constructor(message: string = 'Authentication failed') {
31 | super(message, 401, true, 'AUTHENTICATION_ERROR');
32 | }
33 | }
34 |
35 | export class AuthorizationError extends AppError {
36 | constructor(message: string = 'Access denied') {
37 | super(message, 403, true, 'AUTHORIZATION_ERROR');
38 | }
39 | }
40 |
41 | export class NotFoundError extends AppError {
42 | constructor(resource: string) {
43 | super(`${resource} not found`, 404, true, 'NOT_FOUND');
44 | }
45 | }
46 |
47 | export class ConflictError extends AppError {
48 | constructor(message: string) {
49 | super(message, 409, true, 'CONFLICT');
50 | }
51 | }
52 |
53 | export class RateLimitError extends AppError {
54 | constructor(message: string = 'Too many requests') {
55 | super(message, 429, true, 'RATE_LIMIT_EXCEEDED');
56 | }
57 | }
58 |
59 | export class ExternalServiceError extends AppError {
60 | constructor(service: string, originalError?: any) {
61 | super(`External service error: ${service}`, 503, true, 'EXTERNAL_SERVICE_ERROR');
62 | if (originalError) {
63 | logger.error(`External service error details for ${service}`, originalError);
64 | }
65 | }
66 | }
67 |
68 | // Error response formatter
69 | export function formatErrorResponse(error: Error | AppError): {
70 | error: string;
71 | message: string;
72 | code?: string;
73 | statusCode: number;
74 | details?: any;
75 | } {
76 | if (error instanceof AppError) {
77 | return {
78 | error: error.name,
79 | message: error.message,
80 | code: error.code,
81 | statusCode: error.statusCode,
82 | details: (typeof process !== 'undefined' && process.env.NODE_ENV === 'development') ? {
83 | stack: error.stack
84 | } : undefined
85 | };
86 | }
87 |
88 | // For non-operational errors, log them and return a generic message
89 | logger.error('Unexpected error', error);
90 |
91 | return {
92 | error: 'InternalServerError',
93 | message: (typeof process !== 'undefined' && process.env.NODE_ENV === 'development')
94 | ? error.message
95 | : 'An unexpected error occurred',
96 | statusCode: 500,
97 | details: (typeof process !== 'undefined' && process.env.NODE_ENV === 'development') ? {
98 | stack: error.stack
99 | } : undefined
100 | };
101 | }
102 |
103 | // Async error wrapper for API routes
104 | export function asyncHandler Promise>(
105 | fn: T
106 | ): T {
107 | return (async (...args: Parameters) => {
108 | try {
109 | return await fn(...args);
110 | } catch (error) {
111 | if (error instanceof AppError) {
112 | logger.warn(`Operational error: ${error.message}`, {
113 | code: error.code,
114 | statusCode: error.statusCode
115 | });
116 | } else {
117 | logger.error('Unhandled error in async handler', error);
118 | }
119 | throw error;
120 | }
121 | }) as T;
122 | }
123 |
124 | // Global error handler for unhandled errors
125 | export function setupGlobalErrorHandlers() {
126 | if (typeof window === 'undefined') {
127 | // Server-side error handlers
128 | process.on('unhandledRejection', (reason: any, promise: Promise) => {
129 | logger.error('Unhandled Promise Rejection', reason, {
130 | promise: promise.toString()
131 | });
132 | });
133 |
134 | process.on('uncaughtException', (error: Error) => {
135 | logger.error('Uncaught Exception', error);
136 | // Give the logger time to write
137 | setTimeout(() => {
138 | process.exit(1);
139 | }, 1000);
140 | });
141 | } else {
142 | // Client-side error handlers
143 | window.addEventListener('unhandledrejection', (event) => {
144 | logger.error('Unhandled promise rejection', event.reason);
145 | });
146 |
147 | window.addEventListener('error', (event) => {
148 | logger.error('Global error', event.error || event.message, {
149 | filename: event.filename,
150 | lineno: event.lineno,
151 | colno: event.colno
152 | });
153 | });
154 | }
155 | }
--------------------------------------------------------------------------------
/app/api/health/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { createClient } from '@supabase/supabase-js';
3 | import { logger } from '@/lib/logger';
4 | import { validateEnvironment } from '@/lib/env-validation';
5 |
6 | interface HealthStatus {
7 | status: 'healthy' | 'unhealthy';
8 | timestamp: string;
9 | version: string;
10 | checks: {
11 | database: {
12 | status: 'ok' | 'error';
13 | message?: string;
14 | responseTime?: number;
15 | };
16 | environment: {
17 | status: 'ok' | 'error';
18 | errors?: string[];
19 | };
20 | memory: {
21 | status: 'ok' | 'warning' | 'error';
22 | usage: {
23 | heapUsed: number;
24 | heapTotal: number;
25 | rss: number;
26 | external: number;
27 | };
28 | };
29 | };
30 | }
31 |
32 | export async function GET() {
33 | const startTime = Date.now();
34 |
35 | try {
36 | const health: HealthStatus = {
37 | status: 'healthy',
38 | timestamp: new Date().toISOString(),
39 | version: process.env.npm_package_version || '1.0.0',
40 | checks: {
41 | database: {
42 | status: 'ok'
43 | },
44 | environment: {
45 | status: 'ok'
46 | },
47 | memory: {
48 | status: 'ok',
49 | usage: {
50 | heapUsed: 0,
51 | heapTotal: 0,
52 | rss: 0,
53 | external: 0
54 | }
55 | }
56 | }
57 | };
58 |
59 | // Check environment variables
60 | const envValidation = validateEnvironment();
61 | if (!envValidation.valid) {
62 | health.checks.environment.status = 'error';
63 | health.checks.environment.errors = envValidation.errors;
64 | health.status = 'unhealthy';
65 | }
66 |
67 | // Check database connection
68 | try {
69 | const dbStartTime = Date.now();
70 | const supabase = createClient(
71 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
72 | process.env.SUPABASE_SERVICE_ROLE_KEY!
73 | );
74 |
75 | // Simple query to test connection
76 | const { error } = await supabase.from('articles').select('id').limit(1);
77 |
78 | const dbResponseTime = Date.now() - dbStartTime;
79 |
80 | if (error) {
81 | health.checks.database.status = 'error';
82 | health.checks.database.message = 'Database query failed';
83 | health.status = 'unhealthy';
84 | logger.error('Health check database error', error);
85 | } else {
86 | health.checks.database.responseTime = dbResponseTime;
87 |
88 | // Warn if database is slow
89 | if (dbResponseTime > 1000) {
90 | logger.warn('Database response time is slow', { responseTime: dbResponseTime });
91 | }
92 | }
93 | } catch (error) {
94 | health.checks.database.status = 'error';
95 | health.checks.database.message = 'Database connection failed';
96 | health.status = 'unhealthy';
97 | logger.error('Health check database connection error', error);
98 | }
99 |
100 | // Check memory usage
101 | const memoryUsage = process.memoryUsage();
102 | health.checks.memory.usage = {
103 | heapUsed: Math.round(memoryUsage.heapUsed / 1024 / 1024), // MB
104 | heapTotal: Math.round(memoryUsage.heapTotal / 1024 / 1024), // MB
105 | rss: Math.round(memoryUsage.rss / 1024 / 1024), // MB
106 | external: Math.round(memoryUsage.external / 1024 / 1024) // MB
107 | };
108 |
109 | // Set memory status based on usage
110 | const heapPercentage = (memoryUsage.heapUsed / memoryUsage.heapTotal) * 100;
111 | if (heapPercentage > 90) {
112 | health.checks.memory.status = 'error';
113 | health.status = 'unhealthy';
114 | } else if (heapPercentage > 75) {
115 | health.checks.memory.status = 'warning';
116 | }
117 |
118 | const totalTime = Date.now() - startTime;
119 |
120 | logger.info('Health check completed', {
121 | status: health.status,
122 | duration: totalTime,
123 | checks: {
124 | database: health.checks.database.status,
125 | environment: health.checks.environment.status,
126 | memory: health.checks.memory.status
127 | }
128 | });
129 |
130 | return NextResponse.json(health, {
131 | status: health.status === 'healthy' ? 200 : 503,
132 | headers: {
133 | 'Cache-Control': 'no-cache, no-store, must-revalidate',
134 | 'X-Response-Time': `${totalTime}ms`
135 | }
136 | });
137 | } catch (error) {
138 | logger.error('Health check failed', error);
139 |
140 | return NextResponse.json(
141 | {
142 | status: 'unhealthy',
143 | timestamp: new Date().toISOString(),
144 | error: 'Health check failed'
145 | },
146 | {
147 | status: 503,
148 | headers: {
149 | 'Cache-Control': 'no-cache, no-store, must-revalidate'
150 | }
151 | }
152 | );
153 | }
154 | }
--------------------------------------------------------------------------------
/hooks/useLocalStorage.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useCallback } from 'react'
2 |
3 | type SetValue = T | ((prevValue: T) => T)
4 |
5 | export function useLocalStorage(
6 | key: string,
7 | initialValue: T,
8 | options?: {
9 | serialize?: (value: T) => string
10 | deserialize?: (value: string) => T
11 | }
12 | ): [T, (value: SetValue) => void, () => void] {
13 | const {
14 | serialize = JSON.stringify,
15 | deserialize = JSON.parse
16 | } = options || {}
17 |
18 | // Get from local storage then parse stored json or return initialValue
19 | const readValue = useCallback((): T => {
20 | // Prevent build error "window is undefined"
21 | if (typeof window === 'undefined') {
22 | return initialValue
23 | }
24 |
25 | try {
26 | const item = window.localStorage.getItem(key)
27 | return item ? deserialize(item) : initialValue
28 | } catch (error) {
29 | console.warn(`Error reading localStorage key "${key}":`, error)
30 | return initialValue
31 | }
32 | }, [initialValue, key, deserialize])
33 |
34 | const [storedValue, setStoredValue] = useState(readValue)
35 |
36 | // Return a wrapped version of useState's setter function that ...
37 | // ... persists the new value to localStorage.
38 | const setValue = useCallback((value: SetValue) => {
39 | // Prevent build error "window is undefined"
40 | if (typeof window === 'undefined') {
41 | console.warn(`Tried to set localStorage key "${key}" but window is undefined`)
42 | return
43 | }
44 |
45 | try {
46 | // Allow value to be a function so we have the same API as useState
47 | const newValue = value instanceof Function ? value(storedValue) : value
48 |
49 | // Save to local storage
50 | window.localStorage.setItem(key, serialize(newValue))
51 |
52 | // Save state
53 | setStoredValue(newValue)
54 |
55 | // We dispatch a custom event so every useLocalStorage hook is notified
56 | window.dispatchEvent(new Event('local-storage'))
57 | } catch (error) {
58 | console.warn(`Error setting localStorage key "${key}":`, error)
59 | }
60 | }, [key, serialize, storedValue])
61 |
62 | // Remove value from local storage
63 | const removeValue = useCallback(() => {
64 | // Prevent build error "window is undefined"
65 | if (typeof window === 'undefined') {
66 | console.warn(`Tried to remove localStorage key "${key}" but window is undefined`)
67 | return
68 | }
69 |
70 | try {
71 | window.localStorage.removeItem(key)
72 | setStoredValue(initialValue)
73 |
74 | // We dispatch a custom event so every useLocalStorage hook is notified
75 | window.dispatchEvent(new Event('local-storage'))
76 | } catch (error) {
77 | console.warn(`Error removing localStorage key "${key}":`, error)
78 | }
79 | }, [initialValue, key])
80 |
81 | useEffect(() => {
82 | setStoredValue(readValue())
83 | }, [readValue])
84 |
85 | useEffect(() => {
86 | const handleStorageChange = () => {
87 | setStoredValue(readValue())
88 | }
89 |
90 | // This only works for other documents, not the current one
91 | window.addEventListener('storage', handleStorageChange)
92 |
93 | // This is a custom event, triggered in setValue and removeValue
94 | window.addEventListener('local-storage', handleStorageChange)
95 |
96 | return () => {
97 | window.removeEventListener('storage', handleStorageChange)
98 | window.removeEventListener('local-storage', handleStorageChange)
99 | }
100 | }, [readValue])
101 |
102 | return [storedValue, setValue, removeValue]
103 | }
104 |
105 | // Specialized hook for storing chat sessions
106 | export function useChatSessionStorage() {
107 | return useLocalStorage('xfunnel-chat-sessions', [], {
108 | serialize: (value) => JSON.stringify(value),
109 | deserialize: (value) => {
110 | try {
111 | const parsed = JSON.parse(value)
112 | // Convert date strings back to Date objects
113 | return parsed.map((session: any) => ({
114 | ...session,
115 | createdAt: new Date(session.createdAt),
116 | updatedAt: new Date(session.updatedAt),
117 | messages: session.messages.map((msg: any) => ({
118 | ...msg,
119 | timestamp: new Date(msg.timestamp)
120 | }))
121 | }))
122 | } catch {
123 | return []
124 | }
125 | }
126 | })
127 | }
128 |
129 | // Specialized hook for storing user preferences
130 | interface UserPreferences {
131 | theme?: 'light' | 'dark' | 'system'
132 | autoSaveEnabled?: boolean
133 | autoSaveInterval?: number
134 | defaultArticleStatus?: 'draft' | 'final'
135 | chatModel?: string
136 | chatTemperature?: number
137 | }
138 |
139 | export function useUserPreferences() {
140 | const defaultPreferences: UserPreferences = {
141 | theme: 'system',
142 | autoSaveEnabled: true,
143 | autoSaveInterval: 30000,
144 | defaultArticleStatus: 'draft',
145 | chatModel: 'claude-3-sonnet-20240229',
146 | chatTemperature: 0.7
147 | }
148 |
149 | return useLocalStorage('xfunnel-preferences', defaultPreferences)
150 | }
--------------------------------------------------------------------------------
/hooks/useWebSocket.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState, useCallback } from 'react'
2 | import toast from 'react-hot-toast'
3 |
4 | interface UseWebSocketOptions {
5 | url: string
6 | onMessage?: (data: any) => void
7 | onOpen?: () => void
8 | onClose?: () => void
9 | onError?: (error: Event) => void
10 | reconnect?: boolean
11 | reconnectInterval?: number
12 | reconnectAttempts?: number
13 | }
14 |
15 | interface UseWebSocketReturn {
16 | isConnected: boolean
17 | sendMessage: (data: any) => void
18 | connect: () => void
19 | disconnect: () => void
20 | lastMessage: any
21 | }
22 |
23 | export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn {
24 | const {
25 | url,
26 | onMessage,
27 | onOpen,
28 | onClose,
29 | onError,
30 | reconnect = true,
31 | reconnectInterval = 3000,
32 | reconnectAttempts = 5
33 | } = options
34 |
35 | const [isConnected, setIsConnected] = useState(false)
36 | const [lastMessage, setLastMessage] = useState(null)
37 | const wsRef = useRef(null)
38 | const reconnectCountRef = useRef(0)
39 | const reconnectTimeoutRef = useRef(null)
40 |
41 | const connect = useCallback(() => {
42 | try {
43 | // Close existing connection
44 | if (wsRef.current?.readyState === WebSocket.OPEN) {
45 | wsRef.current.close()
46 | }
47 |
48 | // Create new WebSocket connection
49 | wsRef.current = new WebSocket(url)
50 |
51 | wsRef.current.onopen = () => {
52 | setIsConnected(true)
53 | reconnectCountRef.current = 0
54 | onOpen?.()
55 | toast.success('Connected to real-time updates')
56 | }
57 |
58 | wsRef.current.onmessage = (event) => {
59 | try {
60 | const data = JSON.parse(event.data)
61 | setLastMessage(data)
62 | onMessage?.(data)
63 | } catch (error) {
64 | console.error('Failed to parse WebSocket message:', error)
65 | }
66 | }
67 |
68 | wsRef.current.onclose = () => {
69 | setIsConnected(false)
70 | onClose?.()
71 |
72 | // Attempt to reconnect
73 | if (reconnect && reconnectCountRef.current < reconnectAttempts) {
74 | reconnectCountRef.current++
75 | toast.error(`Connection lost. Reconnecting... (${reconnectCountRef.current}/${reconnectAttempts})`)
76 |
77 | reconnectTimeoutRef.current = setTimeout(() => {
78 | connect()
79 | }, reconnectInterval)
80 | } else if (reconnectCountRef.current >= reconnectAttempts) {
81 | toast.error('Failed to reconnect. Please refresh the page.')
82 | }
83 | }
84 |
85 | wsRef.current.onerror = (error) => {
86 | console.error('WebSocket error:', error)
87 | onError?.(error)
88 | }
89 | } catch (error) {
90 | console.error('Failed to create WebSocket connection:', error)
91 | toast.error('Failed to connect to real-time updates')
92 | }
93 | }, [url, onMessage, onOpen, onClose, onError, reconnect, reconnectInterval, reconnectAttempts])
94 |
95 | const disconnect = useCallback(() => {
96 | if (reconnectTimeoutRef.current) {
97 | clearTimeout(reconnectTimeoutRef.current)
98 | reconnectTimeoutRef.current = null
99 | }
100 |
101 | if (wsRef.current) {
102 | wsRef.current.close()
103 | wsRef.current = null
104 | }
105 |
106 | setIsConnected(false)
107 | }, [])
108 |
109 | const sendMessage = useCallback((data: any) => {
110 | if (wsRef.current?.readyState === WebSocket.OPEN) {
111 | wsRef.current.send(JSON.stringify(data))
112 | } else {
113 | console.warn('WebSocket is not connected')
114 | toast.error('Not connected. Please wait...')
115 | }
116 | }, [])
117 |
118 | // Connect on mount
119 | useEffect(() => {
120 | connect()
121 |
122 | // Cleanup on unmount
123 | return () => {
124 | disconnect()
125 | }
126 | }, []) // Empty deps intentionally - we only want to connect once on mount
127 |
128 | return {
129 | isConnected,
130 | sendMessage,
131 | connect,
132 | disconnect,
133 | lastMessage
134 | }
135 | }
136 |
137 | // Hook for real-time article updates
138 | interface UseRealtimeArticlesOptions {
139 | onArticleUpdate?: (article: any) => void
140 | onArticleDelete?: (articleId: string) => void
141 | }
142 |
143 | export function useRealtimeArticles(options: UseRealtimeArticlesOptions = {}) {
144 | const { onArticleUpdate, onArticleDelete } = options
145 |
146 | const handleMessage = useCallback((data: any) => {
147 | switch (data.type) {
148 | case 'article:update':
149 | onArticleUpdate?.(data.article)
150 | break
151 | case 'article:delete':
152 | onArticleDelete?.(data.articleId)
153 | break
154 | default:
155 | console.log('Unknown message type:', data.type)
156 | }
157 | }, [onArticleUpdate, onArticleDelete])
158 |
159 | const wsUrl = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:3001'
160 |
161 | return useWebSocket({
162 | url: `${wsUrl}/articles`,
163 | onMessage: handleMessage,
164 | reconnect: true
165 | })
166 | }
--------------------------------------------------------------------------------