Add onboarding

This commit is contained in:
myrmidex 2025-07-06 11:22:53 +02:00
parent 833cf7a313
commit 8de4368e50
10 changed files with 573 additions and 4 deletions

View file

@ -29,7 +29,7 @@ public function store(Request $request): RedirectResponse
'name' => 'required|string|max:255',
'url' => 'required|url|unique:feeds,url',
'type' => 'required|in:website,rss',
'language' => 'required|string|size:2',
'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string',
'is_active' => 'boolean'
]);
@ -39,6 +39,13 @@ public function store(Request $request): RedirectResponse
Feed::create($validated);
// Check if there's a redirect_to parameter for onboarding flow
$redirectTo = $request->input('redirect_to');
if ($redirectTo) {
return redirect($redirectTo)
->with('success', 'Feed created successfully!');
}
return redirect()->route('feeds.index')
->with('success', 'Feed created successfully!');
}
@ -59,7 +66,7 @@ public function update(Request $request, Feed $feed): RedirectResponse
'name' => 'required|string|max:255',
'url' => 'required|url|unique:feeds,url,' . $feed->id,
'type' => 'required|in:website,rss',
'language' => 'required|string|size:2',
'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string',
'is_active' => 'boolean'
]);

View file

@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers;
use App\Models\Feed;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Illuminate\Http\RedirectResponse;
class OnboardingController extends Controller
{
public function index(): View|RedirectResponse
{
// Check if user needs onboarding
if (!$this->needsOnboarding()) {
return redirect()->route('feeds.index');
}
return view('onboarding.welcome');
}
public function platform(): View|RedirectResponse
{
if (!$this->needsOnboarding()) {
return redirect()->route('feeds.index');
}
return view('onboarding.platform');
}
public function feed(): View|RedirectResponse
{
if (!$this->needsOnboarding()) {
return redirect()->route('feeds.index');
}
if (!$this->hasPlatformAccount()) {
return redirect()->route('onboarding.platform');
}
return view('onboarding.feed');
}
public function channel(): View|RedirectResponse
{
if (!$this->needsOnboarding()) {
return redirect()->route('feeds.index');
}
if (!$this->hasPlatformAccount()) {
return redirect()->route('onboarding.platform');
}
if (!$this->hasFeed()) {
return redirect()->route('onboarding.feed');
}
return view('onboarding.channel');
}
public function complete(): View|RedirectResponse
{
if (!$this->needsOnboarding()) {
return redirect()->route('feeds.index');
}
if (!$this->hasPlatformAccount() || !$this->hasFeed() || !$this->hasChannel()) {
return redirect()->route('onboarding.index');
}
return view('onboarding.complete');
}
private function needsOnboarding(): bool
{
return !$this->hasPlatformAccount() || !$this->hasFeed() || !$this->hasChannel();
}
private function hasPlatformAccount(): bool
{
return PlatformAccount::where('is_active', true)->exists();
}
private function hasFeed(): bool
{
return Feed::where('is_active', true)->exists();
}
private function hasChannel(): bool
{
return PlatformChannel::where('is_active', true)->exists();
}
}

View file

@ -3,6 +3,8 @@
namespace App\Http\Controllers;
use App\Models\PlatformAccount;
use App\Models\PlatformInstance;
use App\Enums\PlatformEnum;
use Illuminate\Http\Request;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
@ -31,6 +33,17 @@ public function store(Request $request): RedirectResponse
'settings' => 'nullable|array',
]);
// Create or find platform instance
$platformEnum = PlatformEnum::from($validated['platform']);
$instance = PlatformInstance::firstOrCreate([
'platform' => $platformEnum,
'url' => $validated['instance_url'],
], [
'name' => parse_url($validated['instance_url'], PHP_URL_HOST),
'description' => ucfirst($validated['platform']) . ' instance',
'is_active' => true,
]);
$account = PlatformAccount::create($validated);
// If this is the first account for this platform, make it active
@ -38,6 +51,13 @@ public function store(Request $request): RedirectResponse
$account->setAsActive();
}
// Check if there's a redirect_to parameter for onboarding flow
$redirectTo = $request->input('redirect_to');
if ($redirectTo) {
return redirect($redirectTo)
->with('success', 'Platform account created successfully!');
}
return redirect()->route('platforms.index')
->with('success', 'Platform account created successfully!');
}

View file

@ -34,13 +34,28 @@ public function store(Request $request): RedirectResponse
$validated = $request->validate([
'platform_instance_id' => 'required|exists:platform_instances,id',
'name' => 'required|string|max:255',
'display_name' => 'required|string|max:255',
'channel_id' => 'required|string|max:255',
'display_name' => 'nullable|string|max:255',
'channel_id' => 'nullable|string|max:255',
'description' => 'nullable|string',
'language_id' => 'required|exists:languages,id',
'is_active' => 'boolean',
]);
// Default is_active to true if not provided
$validated['is_active'] = $validated['is_active'] ?? true;
// Set display_name to name if not provided
$validated['display_name'] = $validated['display_name'] ?? $validated['name'];
PlatformChannel::create($validated);
// Check if there's a redirect_to parameter for onboarding flow
$redirectTo = $request->input('redirect_to');
if ($redirectTo) {
return redirect($redirectTo)
->with('success', 'Channel created successfully!');
}
return redirect()->route('channels.index')
->with('success', 'Channel created successfully!');
}

View file

@ -0,0 +1,110 @@
@extends('layouts.app')
@section('content')
<div class="min-h-screen flex items-center justify-center bg-gray-50">
<div class="max-w-lg w-full bg-white rounded-lg shadow-md p-8">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-900 mb-2">Configure Your Channel</h1>
<p class="text-gray-600">
Set up a Lemmy community where articles will be posted
</p>
<!-- Progress indicator -->
<div class="flex justify-center mt-6 space-x-2">
<div class="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
<div class="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
<div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">3</div>
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">4</div>
</div>
</div>
<form action="{{ route('channels.store') }}" method="POST" class="space-y-6">
@csrf
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
Community Name
</label>
<input type="text"
id="name"
name="name"
value="{{ old('name') }}"
placeholder="technology"
class="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>
<p class="text-sm text-gray-500 mt-1">Enter the community name (without the @ or instance)</p>
@error('name')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="platform_instance_id" class="block text-sm font-medium text-gray-700 mb-2">
Platform Instance
</label>
<select id="platform_instance_id"
name="platform_instance_id"
class="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>
@foreach(\App\Models\PlatformInstance::where('is_active', true)->get() as $instance)
<option value="{{ $instance->id }}" {{ old('platform_instance_id') == $instance->id ? 'selected' : '' }}>
{{ $instance->name }} ({{ $instance->url }})
</option>
@endforeach
</select>
@error('platform_instance_id')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="language_id" class="block text-sm font-medium text-gray-700 mb-2">
Language
</label>
<select id="language_id"
name="language_id"
class="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 language</option>
@foreach(\App\Models\Language::orderBy('name')->get() as $language)
<option value="{{ $language->id }}" {{ old('language_id') == $language->id ? 'selected' : '' }}>
{{ $language->name }}
</option>
@endforeach
</select>
@error('language_id')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">
Description (Optional)
</label>
<textarea id="description"
name="description"
rows="3"
placeholder="Brief description of this channel"
class="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">{{ old('description') }}</textarea>
@error('description')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<input type="hidden" name="is_active" value="1">
<input type="hidden" name="redirect_to" value="{{ route('onboarding.complete') }}">
<div class="flex justify-between">
<a href="{{ route('onboarding.feed') }}"
class="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200">
Back
</a>
<button type="submit"
class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200">
Continue
</button>
</div>
</form>
</div>
</div>
@endsection

View file

@ -0,0 +1,64 @@
@extends('layouts.app')
@section('content')
<div class="min-h-screen flex items-center justify-center bg-gray-50">
<div class="max-w-lg w-full bg-white rounded-lg shadow-md p-8">
<div class="text-center">
<div class="mb-6">
<div class="w-16 h-16 bg-green-500 text-white rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">Setup Complete!</h1>
<p class="text-gray-600 mb-6">
Great! You've successfully configured Lemmy Poster. Your feeds will now be monitored and articles will be automatically posted to your configured channels.
</p>
</div>
<!-- Progress indicator -->
<div class="flex justify-center mb-8 space-x-2">
<div class="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
<div class="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
<div class="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
<div class="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
</div>
<div class="space-y-4 mb-8">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 class="font-semibold text-blue-900 mb-2">What happens next?</h3>
<ul class="text-sm text-blue-800 space-y-1 text-left">
<li> Your feeds will be checked regularly for new articles</li>
<li> New articles will be automatically posted to your channels</li>
<li> You can monitor activity in the Articles and Logs sections</li>
</ul>
</div>
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h3 class="font-semibold text-yellow-900 mb-2">Want more control?</h3>
<p class="text-sm text-yellow-800 text-left mb-2">
Set up <strong>routing rules</strong> to control which articles get posted where based on keywords, titles, or content.
</p>
<a href="{{ route('routing.index') }}"
class="inline-block bg-yellow-200 hover:bg-yellow-300 text-yellow-900 px-3 py-1 rounded-md text-sm transition duration-200">
Configure Routing
</a>
</div>
</div>
<div class="space-y-3">
<a href="{{ route('feeds.index') }}"
class="w-full bg-blue-600 text-white py-3 px-4 rounded-md hover:bg-blue-700 transition duration-200 inline-block">
Go to Dashboard
</a>
<div class="text-sm text-gray-500">
<a href="{{ route('articles') }}" class="hover:text-blue-600">View Articles</a>
<a href="{{ route('logs') }}" class="hover:text-blue-600">Check Logs</a>
</div>
</div>
</div>
</div>
</div>
@endsection

View file

@ -0,0 +1,123 @@
@extends('layouts.app')
@section('content')
<div class="min-h-screen flex items-center justify-center bg-gray-50">
<div class="max-w-lg w-full bg-white rounded-lg shadow-md p-8">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-900 mb-2">Add Your First Feed</h1>
<p class="text-gray-600">
Add a RSS feed or website to monitor for new articles
</p>
<!-- Progress indicator -->
<div class="flex justify-center mt-6 space-x-2">
<div class="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
<div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">2</div>
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">3</div>
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">4</div>
</div>
</div>
<form action="{{ route('feeds.store') }}" method="POST" class="space-y-6">
@csrf
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
Feed Name
</label>
<input type="text"
id="name"
name="name"
value="{{ old('name') }}"
placeholder="My News Feed"
class="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>
@error('name')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="url" class="block text-sm font-medium text-gray-700 mb-2">
Feed URL
</label>
<input type="url"
id="url"
name="url"
value="{{ old('url') }}"
placeholder="https://example.com/rss.xml"
class="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>
@error('url')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="type" class="block text-sm font-medium text-gray-700 mb-2">
Feed Type
</label>
<select id="type"
name="type"
class="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 feed type</option>
<option value="rss" {{ old('type') == 'rss' ? 'selected' : '' }}>RSS Feed</option>
<option value="website" {{ old('type') == 'website' ? 'selected' : '' }}>Website</option>
</select>
@error('type')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="language_id" class="block text-sm font-medium text-gray-700 mb-2">
Language
</label>
<select id="language_id"
name="language_id"
class="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 language</option>
@foreach(\App\Models\Language::orderBy('name')->get() as $language)
<option value="{{ $language->id }}" {{ old('language_id') == $language->id ? 'selected' : '' }}>
{{ $language->name }}
</option>
@endforeach
</select>
@error('language_id')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">
Description (Optional)
</label>
<textarea id="description"
name="description"
rows="3"
placeholder="Brief description of this feed"
class="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">{{ old('description') }}</textarea>
@error('description')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<input type="hidden" name="is_active" value="1">
<input type="hidden" name="redirect_to" value="{{ route('onboarding.channel') }}">
<div class="flex justify-between">
<a href="{{ route('onboarding.platform') }}"
class="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200">
Back
</a>
<button type="submit"
class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200">
Continue
</button>
</div>
</form>
</div>
</div>
@endsection

View file

@ -0,0 +1,86 @@
@extends('layouts.app')
@section('content')
<div class="min-h-screen flex items-center justify-center bg-gray-50">
<div class="max-w-lg w-full bg-white rounded-lg shadow-md p-8">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-900 mb-2">Connect Your Lemmy Account</h1>
<p class="text-gray-600">
Enter your Lemmy instance details and login credentials
</p>
<!-- Progress indicator -->
<div class="flex justify-center mt-6 space-x-2">
<div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">1</div>
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">2</div>
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">3</div>
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">4</div>
</div>
</div>
<form action="{{ route('platforms.store') }}" method="POST" class="space-y-6">
@csrf
<div>
<label for="instance_url" class="block text-sm font-medium text-gray-700 mb-2">
Lemmy Instance URL
</label>
<input type="url"
id="instance_url"
name="instance_url"
value="{{ old('instance_url') }}"
placeholder="https://lemmy.world"
class="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>
@error('instance_url')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-2">
Username
</label>
<input type="text"
id="username"
name="username"
value="{{ old('username') }}"
class="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>
@error('username')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<input type="password"
id="password"
name="password"
class="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>
@error('password')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<input type="hidden" name="platform" value="lemmy">
<input type="hidden" name="is_active" value="1">
<input type="hidden" name="redirect_to" value="{{ route('onboarding.feed') }}">
<div class="flex justify-between">
<a href="{{ route('onboarding.index') }}"
class="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200">
Back
</a>
<button type="submit"
class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200">
Continue
</button>
</div>
</form>
</div>
</div>
@endsection

View file

@ -0,0 +1,40 @@
@extends('layouts.app')
@section('content')
<div class="min-h-screen flex items-center justify-center bg-gray-50">
<div class="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<div class="text-center">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Welcome to Lemmy Poster</h1>
<p class="text-gray-600 mb-8">
Let's get you set up! We'll help you configure your Lemmy account, add your first feed, and create a channel for posting.
</p>
<div class="space-y-4">
<div class="flex items-center text-sm text-gray-600">
<div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center mr-3 text-xs font-semibold">1</div>
<span>Connect your Lemmy account</span>
</div>
<div class="flex items-center text-sm text-gray-600">
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center mr-3 text-xs font-semibold">2</div>
<span>Add your first feed</span>
</div>
<div class="flex items-center text-sm text-gray-600">
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center mr-3 text-xs font-semibold">3</div>
<span>Configure a channel</span>
</div>
<div class="flex items-center text-sm text-gray-600">
<div class="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>You're ready to go!</span>
</div>
</div>
<div class="mt-8">
<a href="{{ route('onboarding.platform') }}"
class="w-full bg-blue-600 text-white py-3 px-4 rounded-md hover:bg-blue-700 transition duration-200 inline-block">
Get Started
</a>
</div>
</div>
</div>
</div>
@endsection

View file

@ -2,8 +2,16 @@
use App\Http\Controllers\ArticlesController;
use App\Http\Controllers\LogsController;
use App\Http\Controllers\OnboardingController;
use Illuminate\Support\Facades\Route;
// Onboarding routes
Route::get('/', [OnboardingController::class, 'index'])->name('onboarding.index');
Route::get('/onboarding/platform', [OnboardingController::class, 'platform'])->name('onboarding.platform');
Route::get('/onboarding/feed', [OnboardingController::class, 'feed'])->name('onboarding.feed');
Route::get('/onboarding/channel', [OnboardingController::class, 'channel'])->name('onboarding.channel');
Route::get('/onboarding/complete', [OnboardingController::class, 'complete'])->name('onboarding.complete');
Route::get('/articles', ArticlesController::class)->name('articles');
Route::get('/logs', LogsController::class)->name('logs');