From c17a858e639351e0690f2fecac73f209f43c8a73 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Fri, 8 Aug 2025 21:54:22 +0200 Subject: [PATCH 01/43] Remove previous onboarding implementation --- .../Services/OnboardingRedirectService.php | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 backend/app/Services/OnboardingRedirectService.php diff --git a/backend/app/Services/OnboardingRedirectService.php b/backend/app/Services/OnboardingRedirectService.php deleted file mode 100644 index 6382fbc..0000000 --- a/backend/app/Services/OnboardingRedirectService.php +++ /dev/null @@ -1,20 +0,0 @@ -input('redirect_to'); - - if ($redirectTo) { - return redirect($redirectTo)->with('success', $successMessage); - } - - return redirect()->route($defaultRoute)->with('success', $successMessage); - } -} \ No newline at end of file From 17320ad05a441790c819f0ed3518d53dc7aa9774 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 9 Aug 2025 00:03:45 +0200 Subject: [PATCH 02/43] Add onboarding --- .../Api/V1/OnboardingController.php | 222 ++++++++++++++++++ .../app/Services/Auth/LemmyAuthService.php | 33 +++ backend/routes/api.php | 33 +-- frontend/src/App.tsx | 51 +++- frontend/src/contexts/OnboardingContext.tsx | 68 ++++++ frontend/src/lib/api.ts | 83 +++++++ .../src/pages/onboarding/OnboardingLayout.tsx | 17 ++ .../src/pages/onboarding/OnboardingWizard.tsx | 25 ++ .../pages/onboarding/steps/ChannelStep.tsx | 178 ++++++++++++++ .../pages/onboarding/steps/CompleteStep.tsx | 86 +++++++ .../src/pages/onboarding/steps/FeedStep.tsx | 193 +++++++++++++++ .../pages/onboarding/steps/PlatformStep.tsx | 138 +++++++++++ .../pages/onboarding/steps/WelcomeStep.tsx | 43 ++++ 13 files changed, 1145 insertions(+), 25 deletions(-) create mode 100644 backend/app/Http/Controllers/Api/V1/OnboardingController.php create mode 100644 frontend/src/contexts/OnboardingContext.tsx create mode 100644 frontend/src/pages/onboarding/OnboardingLayout.tsx create mode 100644 frontend/src/pages/onboarding/OnboardingWizard.tsx create mode 100644 frontend/src/pages/onboarding/steps/ChannelStep.tsx create mode 100644 frontend/src/pages/onboarding/steps/CompleteStep.tsx create mode 100644 frontend/src/pages/onboarding/steps/FeedStep.tsx create mode 100644 frontend/src/pages/onboarding/steps/PlatformStep.tsx create mode 100644 frontend/src/pages/onboarding/steps/WelcomeStep.tsx diff --git a/backend/app/Http/Controllers/Api/V1/OnboardingController.php b/backend/app/Http/Controllers/Api/V1/OnboardingController.php new file mode 100644 index 0000000..a1c3b1b --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/OnboardingController.php @@ -0,0 +1,222 @@ +exists(); + $hasFeed = Feed::where('is_active', true)->exists(); + $hasChannel = PlatformChannel::where('is_active', true)->exists(); + + $needsOnboarding = !$hasPlatformAccount || !$hasFeed || !$hasChannel; + + // Determine current step + $currentStep = null; + if ($needsOnboarding) { + if (!$hasPlatformAccount) { + $currentStep = 'platform'; + } elseif (!$hasFeed) { + $currentStep = 'feed'; + } elseif (!$hasChannel) { + $currentStep = 'channel'; + } + } + + return $this->sendResponse([ + 'needs_onboarding' => $needsOnboarding, + 'current_step' => $currentStep, + 'has_platform_account' => $hasPlatformAccount, + 'has_feed' => $hasFeed, + 'has_channel' => $hasChannel, + ], 'Onboarding status retrieved successfully.'); + } + + /** + * Get onboarding options (languages, platform instances) + */ + public function options(): JsonResponse + { + $languages = Language::where('is_active', true) + ->orderBy('name') + ->get(['id', 'short_code', 'name', 'native_name', 'is_active']); + + $platformInstances = PlatformInstance::where('is_active', true) + ->orderBy('name') + ->get(['id', 'platform', 'url', 'name', 'description', 'is_active']); + + return $this->sendResponse([ + 'languages' => $languages, + 'platform_instances' => $platformInstances, + ], 'Onboarding options retrieved successfully.'); + } + + /** + * Create platform account for onboarding + */ + public function createPlatform(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'instance_url' => 'required|url|max:255', + 'username' => 'required|string|max:255', + 'password' => 'required|string|min:6', + 'platform' => 'required|in:lemmy', + ]); + + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $validated = $validator->validated(); + + try { + // Create or get platform instance + $platformInstance = PlatformInstance::firstOrCreate([ + 'url' => $validated['instance_url'], + 'platform' => $validated['platform'], + ], [ + 'name' => parse_url($validated['instance_url'], PHP_URL_HOST) ?? 'Lemmy Instance', + 'is_active' => true, + ]); + + // Authenticate with Lemmy API + $authResponse = $this->lemmyAuthService->authenticate( + $validated['instance_url'], + $validated['username'], + $validated['password'] + ); + + // Create platform account with the current schema + $platformAccount = PlatformAccount::create([ + 'platform' => $validated['platform'], + 'instance_url' => $validated['instance_url'], + '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, + ], + 'is_active' => true, + 'status' => 'active', + ]); + + return $this->sendResponse( + new PlatformAccountResource($platformAccount), + 'Platform account created successfully.' + ); + + } catch (\Exception $e) { + return $this->sendError('Failed to create platform account: ' . $e->getMessage(), [], 422); + } + } + + /** + * Create feed for onboarding + */ + public function createFeed(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:255', + 'url' => 'required|url|max:500', + 'type' => 'required|in:website,rss', + 'language_id' => 'required|exists:languages,id', + 'description' => 'nullable|string|max:1000', + ]); + + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $validated = $validator->validated(); + + $feed = Feed::create([ + 'name' => $validated['name'], + 'url' => $validated['url'], + 'type' => $validated['type'], + 'language_id' => $validated['language_id'], + 'description' => $validated['description'] ?? null, + 'is_active' => true, + ]); + + return $this->sendResponse( + new FeedResource($feed->load('language')), + 'Feed created successfully.' + ); + } + + /** + * Create channel for onboarding + */ + public function createChannel(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:255', + 'platform_instance_id' => 'required|exists:platform_instances,id', + 'language_id' => 'required|exists:languages,id', + 'description' => 'nullable|string|max:1000', + ]); + + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $validated = $validator->validated(); + + $channel = PlatformChannel::create([ + 'platform_instance_id' => $validated['platform_instance_id'], + 'channel_id' => $validated['name'], // For Lemmy, this is the community name + 'name' => $validated['name'], + 'display_name' => ucfirst($validated['name']), + 'description' => $validated['description'] ?? null, + 'language_id' => $validated['language_id'], + 'is_active' => true, + ]); + + return $this->sendResponse( + new PlatformChannelResource($channel->load(['platformInstance', 'language'])), + 'Channel created successfully.' + ); + } + + /** + * Mark onboarding as complete + */ + public function complete(): JsonResponse + { + // In a real implementation, you might want to update a user preference + // or create a setting that tracks onboarding completion + // For now, we'll just return success since the onboarding status + // is determined by the existence of platform accounts, feeds, and channels + + return $this->sendResponse( + ['completed' => true], + 'Onboarding completed successfully.' + ); + } +} \ No newline at end of file diff --git a/backend/app/Services/Auth/LemmyAuthService.php b/backend/app/Services/Auth/LemmyAuthService.php index 1ee58b2..6c0afda 100644 --- a/backend/app/Services/Auth/LemmyAuthService.php +++ b/backend/app/Services/Auth/LemmyAuthService.php @@ -6,6 +6,7 @@ use App\Exceptions\PlatformAuthException; use App\Models\PlatformAccount; use App\Modules\Lemmy\Services\LemmyApiService; +use Exception; use Illuminate\Support\Facades\Cache; class LemmyAuthService @@ -38,4 +39,36 @@ public static function getToken(PlatformAccount $account): string return $token; } + + /** + * Authenticate with Lemmy API and return user data with JWT + * @throws PlatformAuthException + */ + public function authenticate(string $instanceUrl, string $username, string $password): array + { + try { + $api = new LemmyApiService($instanceUrl); + $token = $api->login($username, $password); + + if (!$token) { + throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for user: ' . $username); + } + + // Get user info with the token + // For now, we'll return a basic response structure + // In a real implementation, you might want to fetch user details + return [ + 'jwt' => $token, + 'person_view' => [ + 'person' => [ + 'id' => 0, // Would need API call to get actual user info + 'display_name' => null, + 'bio' => null, + ] + ] + ]; + } catch (Exception $e) { + throw new PlatformAuthException(PlatformEnum::LEMMY, 'Authentication failed: ' . $e->getMessage()); + } + } } diff --git a/backend/routes/api.php b/backend/routes/api.php index 4b3b990..f89d445 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -5,11 +5,11 @@ use App\Http\Controllers\Api\V1\DashboardController; use App\Http\Controllers\Api\V1\FeedsController; use App\Http\Controllers\Api\V1\LogsController; +use App\Http\Controllers\Api\V1\OnboardingController; use App\Http\Controllers\Api\V1\PlatformAccountsController; use App\Http\Controllers\Api\V1\PlatformChannelsController; use App\Http\Controllers\Api\V1\RoutingController; use App\Http\Controllers\Api\V1\SettingsController; -use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; /* @@ -27,23 +27,29 @@ // Public authentication routes Route::post('/auth/login', [AuthController::class, 'login'])->name('api.auth.login'); Route::post('/auth/register', [AuthController::class, 'register'])->name('api.auth.register'); - + // Protected authentication routes Route::middleware('auth:sanctum')->group(function () { Route::post('/auth/logout', [AuthController::class, 'logout'])->name('api.auth.logout'); Route::get('/auth/me', [AuthController::class, 'me'])->name('api.auth.me'); }); - // For demo purposes, making most endpoints public. In production, wrap in auth:sanctum middleware - // Route::middleware('auth:sanctum')->group(function () { + // Onboarding + Route::get('/onboarding/status', [OnboardingController::class, 'status'])->name('api.onboarding.status'); + Route::get('/onboarding/options', [OnboardingController::class, 'options'])->name('api.onboarding.options'); + 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/complete', [OnboardingController::class, 'complete'])->name('api.onboarding.complete'); + // Dashboard stats Route::get('/dashboard/stats', [DashboardController::class, 'stats'])->name('api.dashboard.stats'); - + // Articles Route::get('/articles', [ArticlesController::class, 'index'])->name('api.articles.index'); Route::post('/articles/{article}/approve', [ArticlesController::class, 'approve'])->name('api.articles.approve'); Route::post('/articles/{article}/reject', [ArticlesController::class, 'reject'])->name('api.articles.reject'); - + // Platform Accounts Route::apiResource('platform-accounts', PlatformAccountsController::class)->names([ 'index' => 'api.platform-accounts.index', @@ -54,7 +60,7 @@ ]); Route::post('/platform-accounts/{platformAccount}/set-active', [PlatformAccountsController::class, 'setActive']) ->name('api.platform-accounts.set-active'); - + // Platform Channels Route::apiResource('platform-channels', PlatformChannelsController::class)->names([ 'index' => 'api.platform-channels.index', @@ -65,7 +71,7 @@ ]); Route::post('/platform-channels/{channel}/toggle', [PlatformChannelsController::class, 'toggle']) ->name('api.platform-channels.toggle'); - + // Feeds Route::apiResource('feeds', FeedsController::class)->names([ 'index' => 'api.feeds.index', @@ -75,7 +81,7 @@ 'destroy' => 'api.feeds.destroy', ]); Route::post('/feeds/{feed}/toggle', [FeedsController::class, 'toggle'])->name('api.feeds.toggle'); - + // Routing Route::get('/routing', [RoutingController::class, 'index'])->name('api.routing.index'); Route::post('/routing', [RoutingController::class, 'store'])->name('api.routing.store'); @@ -83,14 +89,11 @@ Route::put('/routing/{feed}/{channel}', [RoutingController::class, 'update'])->name('api.routing.update'); Route::delete('/routing/{feed}/{channel}', [RoutingController::class, 'destroy'])->name('api.routing.destroy'); Route::post('/routing/{feed}/{channel}/toggle', [RoutingController::class, 'toggle'])->name('api.routing.toggle'); - + // Settings Route::get('/settings', [SettingsController::class, 'index'])->name('api.settings.index'); Route::put('/settings', [SettingsController::class, 'update'])->name('api.settings.update'); - + // Logs Route::get('/logs', [LogsController::class, 'index'])->name('api.logs.index'); - - // Close the auth:sanctum middleware group when ready - // }); -}); \ No newline at end of file +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d523794..04a0e4b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,19 +5,50 @@ import Dashboard from './pages/Dashboard'; import Articles from './pages/Articles'; import Feeds from './pages/Feeds'; import Settings from './pages/Settings'; +import OnboardingWizard from './pages/onboarding/OnboardingWizard'; +import { OnboardingProvider, useOnboarding } from './contexts/OnboardingContext'; + +const AppContent: React.FC = () => { + const { isLoading } = useOnboarding(); + + if (isLoading) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + return ( + + {/* Onboarding routes - outside of main layout */} + } /> + + {/* Main app routes - with layout */} + + + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + + ); +}; const App: React.FC = () => { return ( - - - } /> - } /> - } /> - } /> - } /> - } /> - - + + + ); }; diff --git a/frontend/src/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx new file mode 100644 index 0000000..47b9287 --- /dev/null +++ b/frontend/src/contexts/OnboardingContext.tsx @@ -0,0 +1,68 @@ +import React, { createContext, useContext, type ReactNode } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { apiClient, type OnboardingStatus } from '../lib/api'; + +interface OnboardingContextValue { + onboardingStatus: OnboardingStatus | undefined; + isLoading: boolean; + needsOnboarding: boolean; +} + +const OnboardingContext = createContext(null); + +interface OnboardingProviderProps { + children: ReactNode; +} + +export const OnboardingProvider: React.FC = ({ children }) => { + const navigate = useNavigate(); + const location = useLocation(); + + const { data: onboardingStatus, isLoading } = useQuery({ + queryKey: ['onboarding-status'], + queryFn: () => apiClient.getOnboardingStatus(), + retry: 1, + }); + + const needsOnboarding = onboardingStatus?.needs_onboarding ?? false; + const isOnOnboardingPage = location.pathname.startsWith('/onboarding'); + + // Redirect logic + React.useEffect(() => { + if (isLoading) return; + + if (needsOnboarding && !isOnOnboardingPage) { + // User needs onboarding but is not on onboarding pages + const targetStep = onboardingStatus?.current_step; + if (targetStep) { + navigate(`/onboarding/${targetStep}`, { replace: true }); + } else { + navigate('/onboarding', { replace: true }); + } + } else if (!needsOnboarding && isOnOnboardingPage) { + // User doesn't need onboarding but is on onboarding pages + navigate('/dashboard', { replace: true }); + } + }, [onboardingStatus, isLoading, needsOnboarding, isOnOnboardingPage, navigate]); + + const value: OnboardingContextValue = { + onboardingStatus, + isLoading, + needsOnboarding, + }; + + return ( + + {children} + + ); +}; + +export const useOnboarding = () => { + const context = useContext(OnboardingContext); + if (!context) { + throw new Error('useOnboarding must be used within an OnboardingProvider'); + } + return context; +}; \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1404909..b2fd143 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -127,6 +127,59 @@ export interface DashboardStats { current_period: string; } +// Onboarding types +export interface Language { + id: number; + short_code: string; + name: string; + native_name: string; + is_active: boolean; +} + +export interface PlatformInstance { + id: number; + platform: 'lemmy'; + url: string; + name: string; + description: string | null; + is_active: boolean; +} + +export interface OnboardingStatus { + needs_onboarding: boolean; + current_step: 'platform' | 'feed' | 'channel' | 'complete' | null; + has_platform_account: boolean; + has_feed: boolean; + has_channel: boolean; +} + +export interface OnboardingOptions { + languages: Language[]; + platform_instances: PlatformInstance[]; +} + +export interface PlatformAccountRequest { + instance_url: string; + username: string; + password: string; + platform: 'lemmy'; +} + +export interface FeedRequest { + name: string; + url: string; + type: 'rss' | 'website'; + language_id: number; + description?: string; +} + +export interface ChannelRequest { + name: string; + platform_instance_id: number; + language_id: number; + description?: string; +} + // API Client class class ApiClient { constructor() { @@ -205,6 +258,36 @@ class ApiClient { const response = await axios.put>('/settings', data); return response.data.data; } + + // Onboarding endpoints + async getOnboardingStatus(): Promise { + const response = await axios.get>('/onboarding/status'); + return response.data.data; + } + + async getOnboardingOptions(): Promise { + const response = await axios.get>('/onboarding/options'); + return response.data.data; + } + + async createPlatformAccount(data: PlatformAccountRequest): Promise { + const response = await axios.post>('/onboarding/platform', data); + return response.data.data; + } + + async createFeedForOnboarding(data: FeedRequest): Promise { + const response = await axios.post>('/onboarding/feed', data); + return response.data.data; + } + + async createChannelForOnboarding(data: ChannelRequest): Promise { + const response = await axios.post>('/onboarding/channel', data); + return response.data.data; + } + + async completeOnboarding(): Promise { + await axios.post('/onboarding/complete'); + } } export const apiClient = new ApiClient(); \ No newline at end of file diff --git a/frontend/src/pages/onboarding/OnboardingLayout.tsx b/frontend/src/pages/onboarding/OnboardingLayout.tsx new file mode 100644 index 0000000..4985823 --- /dev/null +++ b/frontend/src/pages/onboarding/OnboardingLayout.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +interface OnboardingLayoutProps { + children: React.ReactNode; +} + +const OnboardingLayout: React.FC = ({ children }) => { + return ( +
+
+ {children} +
+
+ ); +}; + +export default OnboardingLayout; \ No newline at end of file diff --git a/frontend/src/pages/onboarding/OnboardingWizard.tsx b/frontend/src/pages/onboarding/OnboardingWizard.tsx new file mode 100644 index 0000000..b8379bb --- /dev/null +++ b/frontend/src/pages/onboarding/OnboardingWizard.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import OnboardingLayout from './OnboardingLayout'; +import WelcomeStep from './steps/WelcomeStep'; +import PlatformStep from './steps/PlatformStep'; +import FeedStep from './steps/FeedStep'; +import ChannelStep from './steps/ChannelStep'; +import CompleteStep from './steps/CompleteStep'; + +const OnboardingWizard: React.FC = () => { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +}; + +export default OnboardingWizard; \ No newline at end of file diff --git a/frontend/src/pages/onboarding/steps/ChannelStep.tsx b/frontend/src/pages/onboarding/steps/ChannelStep.tsx new file mode 100644 index 0000000..b15b244 --- /dev/null +++ b/frontend/src/pages/onboarding/steps/ChannelStep.tsx @@ -0,0 +1,178 @@ +import React, { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { apiClient, type ChannelRequest, type Language, type PlatformInstance } from '../../../lib/api'; + +const ChannelStep: React.FC = () => { + const navigate = useNavigate(); + const [formData, setFormData] = useState({ + name: '', + platform_instance_id: 0, + language_id: 0, + description: '' + }); + const [errors, setErrors] = useState>({}); + + // Get onboarding options (languages, platform instances) + const { data: options, isLoading: optionsLoading } = useQuery({ + queryKey: ['onboarding-options'], + queryFn: () => apiClient.getOnboardingOptions() + }); + + const createChannelMutation = useMutation({ + mutationFn: (data: ChannelRequest) => apiClient.createChannelForOnboarding(data), + onSuccess: () => { + 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({}); + createChannelMutation.mutate(formData); + }; + + const handleChange = (field: keyof ChannelRequest, value: string | number) => { + setFormData(prev => ({ ...prev, [field]: value })); + // Clear field error when user starts typing + if (errors[field]) { + setErrors(prev => ({ ...prev, [field]: [] })); + } + }; + + if (optionsLoading) { + return
Loading...
; + } + + return ( +
+

Configure Your Channel

+

+ Set up a Lemmy community where articles will be posted +

+ + {/* Progress indicator */} +
+
+
+
3
+
4
+
+ +
+ {errors.general && ( +
+

{errors.general[0]}

+
+ )} + +
+ + handleChange('name', e.target.value)} + placeholder="technology" + 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 + /> +

Enter the community name (without the @ or instance)

+ {errors.name && ( +

{errors.name[0]}

+ )} +
+ +
+ + + {errors.platform_instance_id && ( +

{errors.platform_instance_id[0]}

+ )} +
+ +
+ + + {errors.language_id && ( +

{errors.language_id[0]}

+ )} +
+ +
+ +