Minor fixes

This commit is contained in:
myrmidex 2025-08-09 13:48:25 +02:00
parent 387920e82b
commit 2a68895ba9
21 changed files with 650 additions and 123 deletions

62
backend/.env.broken Normal file
View file

@ -0,0 +1,62 @@
APP_NAME="FFR Development"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost:8000
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
APP_MAINTENANCE_STORE=database
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=ffr_dev
DB_USERNAME=ffr_user
DB_PASSWORD=ffr_password
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
CACHE_STORE=redis
CACHE_PREFIX=
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

View file

@ -6,11 +6,13 @@
use App\Http\Resources\FeedResource;
use App\Http\Resources\PlatformAccountResource;
use App\Http\Resources\PlatformChannelResource;
use App\Http\Resources\RouteResource;
use App\Models\Feed;
use App\Models\Language;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
use App\Models\Route;
use App\Models\Setting;
use App\Services\Auth\LemmyAuthService;
use Illuminate\Http\JsonResponse;
@ -32,12 +34,13 @@ public function status(): JsonResponse
$hasPlatformAccount = PlatformAccount::where('is_active', true)->exists();
$hasFeed = Feed::where('is_active', true)->exists();
$hasChannel = PlatformChannel::where('is_active', true)->exists();
$hasRoute = Route::where('is_active', true)->exists();
// Check if onboarding was explicitly skipped
$onboardingSkipped = Setting::where('key', 'onboarding_skipped')->value('value') === 'true';
// User needs onboarding if they don't have the required components AND haven't skipped it
$needsOnboarding = (!$hasPlatformAccount || !$hasFeed || !$hasChannel) && !$onboardingSkipped;
$needsOnboarding = (!$hasPlatformAccount || !$hasFeed || !$hasChannel || !$hasRoute) && !$onboardingSkipped;
// Determine current step
$currentStep = null;
@ -48,6 +51,8 @@ public function status(): JsonResponse
$currentStep = 'feed';
} elseif (!$hasChannel) {
$currentStep = 'channel';
} elseif (!$hasRoute) {
$currentStep = 'route';
}
}
@ -57,6 +62,7 @@ public function status(): JsonResponse
'has_platform_account' => $hasPlatformAccount,
'has_feed' => $hasFeed,
'has_channel' => $hasChannel,
'has_route' => $hasRoute,
'onboarding_skipped' => $onboardingSkipped,
], 'Onboarding status retrieved successfully.');
}
@ -74,9 +80,21 @@ public function options(): JsonResponse
->orderBy('name')
->get(['id', 'platform', 'url', 'name', 'description', 'is_active']);
// Get existing feeds and channels for route creation
$feeds = Feed::where('is_active', true)
->orderBy('name')
->get(['id', 'name', 'url', 'type']);
$platformChannels = PlatformChannel::where('is_active', true)
->with(['platformInstance:id,name,url'])
->orderBy('name')
->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']);
return $this->sendResponse([
'languages' => $languages,
'platform_instances' => $platformInstances,
'feeds' => $feeds,
'platform_channels' => $platformChannels,
], 'Onboarding options retrieved successfully.');
}
@ -127,12 +145,12 @@ public function createPlatform(Request $request): JsonResponse
'instance_url' => $fullInstanceUrl,
'username' => $validated['username'],
'password' => $validated['password'],
'api_token' => $authResponse['jwt'] ?? null,
'settings' => [
'display_name' => $authResponse['person_view']['person']['display_name'] ?? null,
'description' => $authResponse['person_view']['person']['bio'] ?? null,
'person_id' => $authResponse['person_view']['person']['id'] ?? null,
'platform_instance_id' => $platformInstance->id,
'api_token' => $authResponse['jwt'] ?? null, // Store JWT in settings for now
],
'is_active' => true,
'status' => 'active',
@ -144,10 +162,14 @@ public function createPlatform(Request $request): JsonResponse
);
} catch (\App\Exceptions\PlatformAuthException $e) {
// Handle authentication-specific errors with cleaner messages
// Check if it's a rate limit error
if (str_contains($e->getMessage(), 'Rate limited by')) {
return $this->sendError($e->getMessage(), [], 429);
}
return $this->sendError('Invalid username or password. Please check your credentials and try again.', [], 422);
} catch (\Exception $e) {
// Handle other errors (network, instance not found, etc.)
$message = 'Unable to connect to the Lemmy instance. Please check the URL and try again.';
// If it's a network/connection issue, provide a more specific message
@ -229,6 +251,38 @@ public function createChannel(Request $request): JsonResponse
);
}
/**
* Create route for onboarding
*/
public function createRoute(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'feed_id' => 'required|exists:feeds,id',
'platform_channel_id' => 'required|exists:platform_channels,id',
'priority' => 'nullable|integer|min:1|max:100',
'filters' => 'nullable|array',
]);
if ($validator->fails()) {
throw new ValidationException($validator);
}
$validated = $validator->validated();
$route = Route::create([
'feed_id' => $validated['feed_id'],
'platform_channel_id' => $validated['platform_channel_id'],
'priority' => $validated['priority'] ?? 50,
'filters' => $validated['filters'] ?? [],
'is_active' => true,
]);
return $this->sendResponse(
new RouteResource($route->load(['feed', 'platformChannel'])),
'Route created successfully.'
);
}
/**
* Mark onboarding as complete
*/

View file

@ -7,28 +7,37 @@
class LemmyRequest
{
private string $instance;
private string $host;
private string $scheme;
private ?string $token;
public function __construct(string $instance, ?string $token = null)
{
// Handle both full URLs and just domain names
$this->instance = $this->normalizeInstance($instance);
[$this->scheme, $this->host] = $this->parseInstance($instance);
$this->token = $token;
}
/**
* Normalize instance URL to just the domain name
* Parse instance into scheme and host. Defaults to https when scheme missing.
*
* @return array{0:string,1:string} [scheme, host]
*/
private function normalizeInstance(string $instance): string
private function parseInstance(string $instance): array
{
$scheme = 'https';
// If instance includes a scheme, honor it
if (preg_match('/^(https?):\/\//i', $instance, $m)) {
$scheme = strtolower($m[1]);
}
// Remove protocol if present
$instance = preg_replace('/^https?:\/\//', '', $instance);
$host = preg_replace('/^https?:\/\//i', '', $instance);
// Remove trailing slash if present
$instance = rtrim($instance, '/');
$host = rtrim($host ?? '', '/');
return $instance;
return [$scheme, $host];
}
/**
@ -36,7 +45,7 @@ private function normalizeInstance(string $instance): string
*/
public function get(string $endpoint, array $params = []): Response
{
$url = "https://{$this->instance}/api/v3/{$endpoint}";
$url = sprintf('%s://%s/api/v3/%s', $this->scheme, $this->host, ltrim($endpoint, '/'));
$request = Http::timeout(30);
@ -52,7 +61,7 @@ public function get(string $endpoint, array $params = []): Response
*/
public function post(string $endpoint, array $data = []): Response
{
$url = "https://{$this->instance}/api/v3/{$endpoint}";
$url = sprintf('%s://%s/api/v3/%s', $this->scheme, $this->host, ltrim($endpoint, '/'));
$request = Http::timeout(30);
@ -68,4 +77,14 @@ public function withToken(string $token): self
$this->token = $token;
return $this;
}
/**
* Return a cloned request with a different scheme (http or https)
*/
public function withScheme(string $scheme): self
{
$clone = clone $this;
$clone->scheme = strtolower($scheme) === 'http' ? 'http' : 'https';
return $clone;
}
}

View file

@ -18,27 +18,61 @@ public function __construct(string $instance)
public function login(string $username, string $password): ?string
{
try {
$request = new LemmyRequest($this->instance);
$response = $request->post('user/login', [
'username_or_email' => $username,
'password' => $password,
]);
// Try HTTPS first; on failure, optionally retry with HTTP to support dev instances
$schemesToTry = [];
if (preg_match('/^https?:\/\//i', $this->instance)) {
// Preserve user-provided scheme as first try
$schemesToTry[] = strtolower(str_starts_with($this->instance, 'http://') ? 'http' : 'https');
} else {
// Default order: https then http
$schemesToTry = ['https', 'http'];
}
if (!$response->successful()) {
logger()->error('Lemmy login failed', [
'status' => $response->status(),
'body' => $response->body()
foreach ($schemesToTry as $idx => $scheme) {
try {
$request = new LemmyRequest($this->instance);
// ensure scheme used matches current attempt
$request = $request->withScheme($scheme);
$response = $request->post('user/login', [
'username_or_email' => $username,
'password' => $password,
]);
if (!$response->successful()) {
$responseBody = $response->body();
logger()->error('Lemmy login failed', [
'status' => $response->status(),
'body' => $responseBody,
'scheme' => $scheme,
]);
// Check if it's a rate limit error
if (str_contains($responseBody, 'rate_limit_error')) {
throw new Exception('Rate limited by Lemmy instance. Please wait a moment and try again.');
}
// If first attempt failed and there is another scheme to try, continue loop
if ($idx === 0 && count($schemesToTry) > 1) {
continue;
}
return null;
}
$data = $response->json();
return $data['jwt'] ?? null;
} catch (Exception $e) {
logger()->error('Lemmy login exception', ['error' => $e->getMessage(), 'scheme' => $scheme]);
// If this was the first attempt and HTTPS, try HTTP next
if ($idx === 0 && in_array('http', $schemesToTry, true)) {
continue;
}
return null;
}
$data = $response->json();
return $data['jwt'] ?? null;
} catch (Exception $e) {
logger()->error('Lemmy login exception', ['error' => $e->getMessage()]);
return null;
}
return null;
}
public function getCommunityId(string $communityName, string $token): int

View file

@ -72,6 +72,10 @@ public function authenticate(string $instanceUrl, string $username, string $pass
// Re-throw PlatformAuthExceptions as-is to avoid nesting
throw $e;
} catch (Exception $e) {
// Check if it's a rate limit error
if (str_contains($e->getMessage(), 'Rate limited by')) {
throw new PlatformAuthException(PlatformEnum::LEMMY, $e->getMessage());
}
// For other exceptions, throw a clean PlatformAuthException
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed');
}

View file

@ -85,10 +85,17 @@ public function getSystemStats(): array
')
->first();
$accountStats = DB::table('platform_accounts')
->selectRaw('
COUNT(*) as total_platform_accounts,
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_platform_accounts
')
->first();
$channelStats = DB::table('platform_channels')
->selectRaw('
COUNT(*) as total_channels,
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_channels
COUNT(*) as total_platform_channels,
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_platform_channels
')
->first();
@ -102,8 +109,10 @@ public function getSystemStats(): array
return [
'total_feeds' => $feedStats->total_feeds,
'active_feeds' => $feedStats->active_feeds,
'total_channels' => $channelStats->total_channels,
'active_channels' => $channelStats->active_channels,
'total_platform_accounts' => $accountStats->total_platform_accounts,
'active_platform_accounts' => $accountStats->active_platform_accounts,
'total_platform_channels' => $channelStats->total_platform_channels,
'active_platform_channels' => $channelStats->active_platform_channels,
'total_routes' => $routeStats->total_routes,
'active_routes' => $routeStats->active_routes,
];

View file

@ -8,6 +8,9 @@ class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call(SettingsSeeder::class);
$this->call([
SettingsSeeder::class,
LanguageSeeder::class,
]);
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace Database\Seeders;
use App\Models\Language;
use Illuminate\Database\Seeder;
class LanguageSeeder extends Seeder
{
public function run(): void
{
$languages = [
// id is auto-increment; we set codes and names
['short_code' => 'en', 'name' => 'English', 'native_name' => 'English', 'is_active' => true],
['short_code' => 'nl', 'name' => 'Dutch', 'native_name' => 'Nederlands', 'is_active' => true],
['short_code' => 'fr', 'name' => 'French', 'native_name' => 'Français', 'is_active' => true],
['short_code' => 'de', 'name' => 'German', 'native_name' => 'Deutsch', 'is_active' => true],
['short_code' => 'es', 'name' => 'Spanish', 'native_name' => 'Español', 'is_active' => true],
['short_code' => 'it', 'name' => 'Italian', 'native_name' => 'Italiano', 'is_active' => true],
];
foreach ($languages as $lang) {
Language::updateOrCreate(
['short_code' => $lang['short_code']],
$lang
);
}
}
}

View file

@ -40,6 +40,7 @@
Route::post('/onboarding/platform', [OnboardingController::class, 'createPlatform'])->name('api.onboarding.platform');
Route::post('/onboarding/feed', [OnboardingController::class, 'createFeed'])->name('api.onboarding.feed');
Route::post('/onboarding/channel', [OnboardingController::class, 'createChannel'])->name('api.onboarding.channel');
Route::post('/onboarding/route', [OnboardingController::class, 'createRoute'])->name('api.onboarding.route');
Route::post('/onboarding/complete', [OnboardingController::class, 'complete'])->name('api.onboarding.complete');
Route::post('/onboarding/skip', [OnboardingController::class, 'skip'])->name('api.onboarding.skip');
Route::post('/onboarding/reset-skip', [OnboardingController::class, 'resetSkip'])->name('api.onboarding.reset-skip');

View file

@ -7,6 +7,7 @@
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
use App\Models\Route;
use App\Models\Setting;
use App\Services\Auth\LemmyAuthService;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -43,6 +44,7 @@ public function test_status_shows_needs_onboarding_when_no_components_exist()
'has_platform_account' => false,
'has_feed' => false,
'has_channel' => false,
'has_route' => false,
'onboarding_skipped' => false,
],
]);
@ -63,6 +65,7 @@ public function test_status_shows_feed_step_when_platform_account_exists()
'has_platform_account' => true,
'has_feed' => false,
'has_channel' => false,
'has_route' => false,
],
]);
}
@ -83,6 +86,29 @@ public function test_status_shows_channel_step_when_platform_account_and_feed_ex
'has_platform_account' => true,
'has_feed' => true,
'has_channel' => false,
'has_route' => false,
],
]);
}
public function test_status_shows_route_step_when_platform_account_feed_and_channel_exist()
{
PlatformAccount::factory()->create(['is_active' => true]);
Feed::factory()->create(['is_active' => true]);
PlatformChannel::factory()->create(['is_active' => true]);
$response = $this->getJson('/api/v1/onboarding/status');
$response->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'needs_onboarding' => true,
'current_step' => 'route',
'has_platform_account' => true,
'has_feed' => true,
'has_channel' => true,
'has_route' => false,
],
]);
}
@ -92,6 +118,7 @@ public function test_status_shows_no_onboarding_needed_when_all_components_exist
PlatformAccount::factory()->create(['is_active' => true]);
Feed::factory()->create(['is_active' => true]);
PlatformChannel::factory()->create(['is_active' => true]);
Route::factory()->create(['is_active' => true]);
$response = $this->getJson('/api/v1/onboarding/status');
@ -104,6 +131,7 @@ public function test_status_shows_no_onboarding_needed_when_all_components_exist
'has_platform_account' => true,
'has_feed' => true,
'has_channel' => true,
'has_route' => true,
],
]);
}
@ -127,6 +155,7 @@ public function test_status_shows_no_onboarding_needed_when_skipped()
'has_platform_account' => false,
'has_feed' => false,
'has_channel' => false,
'has_route' => false,
'onboarding_skipped' => true,
],
]);
@ -238,6 +267,47 @@ public function test_create_channel_creates_channel_successfully()
]);
}
public function test_create_route_validates_required_fields()
{
$response = $this->postJson('/api/v1/onboarding/route', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['feed_id', 'platform_channel_id']);
}
public function test_create_route_creates_route_successfully()
{
$feed = Feed::factory()->create();
$platformChannel = PlatformChannel::factory()->create();
$routeData = [
'feed_id' => $feed->id,
'platform_channel_id' => $platformChannel->id,
'priority' => 75,
'filters' => ['keyword' => 'test'],
];
$response = $this->postJson('/api/v1/onboarding/route', $routeData);
$response->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'feed_id' => $feed->id,
'platform_channel_id' => $platformChannel->id,
'priority' => 75,
'is_active' => true,
]
]);
$this->assertDatabaseHas('routes', [
'feed_id' => $feed->id,
'platform_channel_id' => $platformChannel->id,
'priority' => 75,
'is_active' => true,
]);
}
public function test_complete_onboarding_returns_success()
{
$response = $this->postJson('/api/v1/onboarding/complete');

View file

@ -11,9 +11,27 @@ echo "Installing PHP dependencies..."
cd /var/www/html/backend
composer install --no-interaction
# Generate application key
echo "Generating application key..."
php artisan key:generate --force
# Ensure APP_KEY is set in backend/.env
ENV_APP_KEY="${APP_KEY}"
if [ -n "$ENV_APP_KEY" ]; then
echo "Using APP_KEY from environment"
sed -i "s|^APP_KEY=.*|APP_KEY=${ENV_APP_KEY}|" /var/www/html/backend/.env || true
fi
# Generate application key if still missing
CURRENT_APP_KEY=$(grep "^APP_KEY=" /var/www/html/backend/.env | cut -d'=' -f2)
if [ -z "$CURRENT_APP_KEY" ]; then
echo "Generating application key..."
php artisan key:generate --force
fi
# Verify APP_KEY
APP_KEY=$(grep "^APP_KEY=" /var/www/html/backend/.env | cut -d'=' -f2)
if [ -n "$APP_KEY" ]; then
echo "✅ APP_KEY successfully set."
else
echo "❌ ERROR: APP_KEY not set!"
fi
# Wait for database to be ready
echo "Waiting for database..."
@ -45,6 +63,9 @@ npm run dev -- --host 0.0.0.0 --port 5173 &
cd /var/www/html/backend
php artisan serve --host=127.0.0.1 --port=8000 &
# Start Horizon (manages queue workers in dev)
php artisan horizon &
# Start nginx
nginx -g "daemon off;" &

View file

@ -28,17 +28,19 @@ export const OnboardingProvider: React.FC<OnboardingProviderProps> = ({ children
const needsOnboarding = onboardingStatus?.needs_onboarding ?? false;
const isOnOnboardingPage = location.pathname.startsWith('/onboarding');
// Redirect logic - only redirect if user explicitly navigates to a protected route
// Redirect logic
React.useEffect(() => {
if (isLoading) return;
// Only redirect if user doesn't need onboarding but is on onboarding pages
// If user doesn't need onboarding but is on onboarding pages, redirect to dashboard
if (!needsOnboarding && isOnOnboardingPage) {
navigate('/dashboard', { replace: true });
}
// Don't auto-redirect to onboarding - let user navigate manually or via links
// This prevents the app from being "stuck" in onboarding mode
// If user needs onboarding but is not on onboarding pages, redirect to onboarding
if (needsOnboarding && !isOnOnboardingPage) {
navigate('/onboarding', { replace: true });
}
}, [onboardingStatus, isLoading, needsOnboarding, isOnOnboardingPage, navigate]);
const value: OnboardingContextValue = {

View file

@ -147,16 +147,19 @@ export interface PlatformInstance {
export interface OnboardingStatus {
needs_onboarding: boolean;
current_step: 'platform' | 'feed' | 'channel' | 'complete' | null;
current_step: 'platform' | 'feed' | 'channel' | 'route' | 'complete' | null;
has_platform_account: boolean;
has_feed: boolean;
has_channel: boolean;
has_route: boolean;
onboarding_skipped: boolean;
}
export interface OnboardingOptions {
languages: Language[];
platform_instances: PlatformInstance[];
feeds: Feed[];
platform_channels: PlatformChannel[];
}
export interface PlatformAccountRequest {
@ -181,6 +184,25 @@ export interface ChannelRequest {
description?: string;
}
export interface Route {
feed_id: number;
platform_channel_id: number;
is_active: boolean;
priority: number;
filters: Record<string, any>;
created_at: string;
updated_at: string;
feed?: Feed;
platform_channel?: PlatformChannel;
}
export interface RouteRequest {
feed_id: number;
platform_channel_id: number;
priority?: number;
filters?: Record<string, any>;
}
// API Client class
class ApiClient {
constructor() {
@ -226,8 +248,8 @@ class ApiClient {
// Feeds endpoints
async getFeeds(): Promise<Feed[]> {
const response = await axios.get<ApiResponse<Feed[]>>('/feeds');
return response.data.data;
const response = await axios.get<ApiResponse<{feeds: Feed[], pagination: any}>>('/feeds');
return response.data.data.feeds;
}
async createFeed(data: Partial<Feed>): Promise<Feed> {
@ -286,6 +308,11 @@ class ApiClient {
return response.data.data;
}
async createRouteForOnboarding(data: RouteRequest): Promise<Route> {
const response = await axios.post<ApiResponse<Route>>('/onboarding/route', data);
return response.data.data;
}
async completeOnboarding(): Promise<void> {
await axios.post('/onboarding/complete');
}

View file

@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import './index.css';
import App from './App';
import { OnboardingProvider } from './contexts/OnboardingContext';
// Create React Query client
const queryClient = new QueryClient({
@ -19,7 +20,9 @@ createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<OnboardingProvider>
<App />
</OnboardingProvider>
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,

View file

@ -49,70 +49,8 @@ const Dashboard: React.FC = () => {
</p>
</div>
{/* Article Statistics */}
<div className="mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Article Statistics</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="flex-shrink-0">
<FileText className="h-8 w-8 text-blue-500" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Articles Today</p>
<p className="text-2xl font-semibold text-gray-900">
{articleStats?.total_today || 0}
</p>
</div>
</div>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="flex-shrink-0">
<Clock className="h-8 w-8 text-yellow-500" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Articles This Week</p>
<p className="text-2xl font-semibold text-gray-900">
{articleStats?.total_week || 0}
</p>
</div>
</div>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="flex-shrink-0">
<CheckCircle className="h-8 w-8 text-green-500" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Approved Today</p>
<p className="text-2xl font-semibold text-gray-900">
{articleStats?.approved_today || 0}
</p>
</div>
</div>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="flex-shrink-0">
<TrendingUp className="h-8 w-8 text-purple-500" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Approval Rate</p>
<p className="text-2xl font-semibold text-gray-900">
{articleStats?.approval_percentage_today?.toFixed(1) || 0}%
</p>
</div>
</div>
</div>
</div>
</div>
{/* System Statistics */}
<div>
<div className="mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">System Overview</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white p-6 rounded-lg shadow">
@ -184,6 +122,68 @@ const Dashboard: React.FC = () => {
</div>
</div>
</div>
{/* Article Statistics */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Article Statistics</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="flex-shrink-0">
<FileText className="h-8 w-8 text-blue-500" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Articles Today</p>
<p className="text-2xl font-semibold text-gray-900">
{articleStats?.total_today || 0}
</p>
</div>
</div>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="flex-shrink-0">
<Clock className="h-8 w-8 text-yellow-500" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Articles This Week</p>
<p className="text-2xl font-semibold text-gray-900">
{articleStats?.total_week || 0}
</p>
</div>
</div>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="flex-shrink-0">
<CheckCircle className="h-8 w-8 text-green-500" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Approved Today</p>
<p className="text-2xl font-semibold text-gray-900">
{articleStats?.approved_today || 0}
</p>
</div>
</div>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="flex-shrink-0">
<TrendingUp className="h-8 w-8 text-purple-500" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Approval Rate</p>
<p className="text-2xl font-semibold text-gray-900">
{articleStats?.approval_percentage_today?.toFixed(1) || 0}%
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View file

@ -5,6 +5,7 @@ import WelcomeStep from './steps/WelcomeStep';
import PlatformStep from './steps/PlatformStep';
import FeedStep from './steps/FeedStep';
import ChannelStep from './steps/ChannelStep';
import RouteStep from './steps/RouteStep';
import CompleteStep from './steps/CompleteStep';
const OnboardingWizard: React.FC = () => {
@ -15,6 +16,7 @@ const OnboardingWizard: React.FC = () => {
<Route path="platform" element={<PlatformStep />} />
<Route path="feed" element={<FeedStep />} />
<Route path="channel" element={<ChannelStep />} />
<Route path="route" element={<RouteStep />} />
<Route path="complete" element={<CompleteStep />} />
<Route path="*" element={<Navigate to="/onboarding" replace />} />
</Routes>

View file

@ -1,10 +1,11 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient, type ChannelRequest, type Language, type PlatformInstance } from '../../../lib/api';
const ChannelStep: React.FC = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [formData, setFormData] = useState<ChannelRequest>({
name: '',
platform_instance_id: 0,
@ -22,7 +23,9 @@ const ChannelStep: React.FC = () => {
const createChannelMutation = useMutation({
mutationFn: (data: ChannelRequest) => apiClient.createChannelForOnboarding(data),
onSuccess: () => {
navigate('/onboarding/complete');
// Invalidate onboarding status cache
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
navigate('/onboarding/route');
},
onError: (error: any) => {
if (error.response?.data?.errors) {

View file

@ -1,19 +1,25 @@
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../../../lib/api';
const CompleteStep: React.FC = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const completeOnboardingMutation = useMutation({
mutationFn: () => apiClient.completeOnboarding(),
onSuccess: () => {
// Invalidate onboarding status cache to ensure proper redirect logic
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
navigate('/dashboard');
},
onError: (error) => {
console.error('Failed to complete onboarding:', error);
// Still navigate to dashboard even if completion fails
// Still invalidate cache and navigate to dashboard even if completion fails
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
navigate('/dashboard');
}
});

View file

@ -136,8 +136,8 @@ const FeedStep: React.FC = () => {
</label>
<select
id="language_id"
value={formData.language_id}
onChange={(e) => handleChange('language_id', parseInt(e.target.value))}
value={formData.language_id || ''}
onChange={(e) => handleChange('language_id', e.target.value ? parseInt(e.target.value) : 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
>

View file

@ -0,0 +1,174 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient, type RouteRequest, type Feed, type PlatformChannel } from '../../../lib/api';
const RouteStep: React.FC = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [formData, setFormData] = useState<RouteRequest>({
feed_id: 0,
platform_channel_id: 0,
priority: 50,
filters: {}
});
const [errors, setErrors] = useState<Record<string, string[]>>({});
// Get onboarding options (feeds and channels)
const { data: options, isLoading: optionsLoading } = useQuery({
queryKey: ['onboarding-options'],
queryFn: () => apiClient.getOnboardingOptions()
});
const createRouteMutation = useMutation({
mutationFn: (data: RouteRequest) => apiClient.createRouteForOnboarding(data),
onSuccess: () => {
// Invalidate onboarding status cache to refresh the status
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
navigate('/onboarding/complete');
},
onError: (error: any) => {
if (error.response?.data?.errors) {
setErrors(error.response.data.errors);
} else {
setErrors({ general: [error.response?.data?.message || 'An error occurred'] });
}
}
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
createRouteMutation.mutate(formData);
};
const handleChange = (field: keyof RouteRequest, value: string | number | Record<string, any>) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear field error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: [] }));
}
};
if (optionsLoading) {
return <div className="text-center">Loading...</div>;
}
return (
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Create Your First Route</h1>
<p className="text-gray-600">
Connect your feed to a channel by creating a route. This tells FFR which articles to post where.
</p>
{/* Progress indicator */}
<div className="flex justify-center mt-6 space-x-2">
<div className="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
<div className="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
<div className="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
<div className="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">4</div>
<div className="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">5</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6 mt-8 text-left">
{errors.general && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-red-600 text-sm">{errors.general[0]}</p>
</div>
)}
<div>
<label htmlFor="feed_id" className="block text-sm font-medium text-gray-700 mb-2">
Select Feed
</label>
<select
id="feed_id"
value={formData.feed_id || ''}
onChange={(e) => handleChange('feed_id', e.target.value ? parseInt(e.target.value) : 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
>
<option value="">Select a feed</option>
{options?.feeds?.map((feed: Feed) => (
<option key={feed.id} value={feed.id}>
{feed.name}
</option>
))}
</select>
{errors.feed_id && (
<p className="text-red-600 text-sm mt-1">{errors.feed_id[0]}</p>
)}
</div>
<div>
<label htmlFor="platform_channel_id" className="block text-sm font-medium text-gray-700 mb-2">
Select Channel
</label>
<select
id="platform_channel_id"
value={formData.platform_channel_id || ''}
onChange={(e) => handleChange('platform_channel_id', e.target.value ? parseInt(e.target.value) : 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
>
<option value="">Select a channel</option>
{options?.platform_channels?.map((channel: PlatformChannel) => (
<option key={channel.id} value={channel.id}>
{channel.display_name || channel.name}
</option>
))}
</select>
{(!options?.platform_channels || options.platform_channels.length === 0) && (
<p className="text-sm text-gray-500 mt-1">
No channels available. Please create a channel first.
</p>
)}
{errors.platform_channel_id && (
<p className="text-red-600 text-sm mt-1">{errors.platform_channel_id[0]}</p>
)}
</div>
<div>
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 mb-2">
Priority (1-100)
</label>
<input
type="number"
id="priority"
min="1"
max="100"
value={formData.priority || 50}
onChange={(e) => handleChange('priority', parseInt(e.target.value))}
placeholder="50"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<p className="text-sm text-gray-500 mt-1">
Higher priority routes are processed first (default: 50)
</p>
{errors.priority && (
<p className="text-red-600 text-sm mt-1">{errors.priority[0]}</p>
)}
</div>
<div className="flex justify-between">
<Link
to="/onboarding/channel"
className="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200"
>
Back
</Link>
<button
type="submit"
disabled={createRouteMutation.isPending || (!options?.platform_channels || options.platform_channels.length === 0)}
className="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50"
>
{createRouteMutation.isPending ? 'Creating...' : 'Continue'}
</button>
</div>
</form>
</div>
);
};
export default RouteStep;

View file

@ -41,6 +41,10 @@ const WelcomeStep: React.FC = () => {
</div>
<div className="flex items-center text-sm text-gray-600">
<div className="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center mr-3 text-xs font-semibold">4</div>
<span>Create a route</span>
</div>
<div className="flex items-center text-sm text-gray-600">
<div className="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center mr-3 text-xs font-semibold">5</div>
<span>You're ready to go!</span>
</div>
</div>