73 - Port react frontend to blade+livewire
This commit is contained in:
parent
0823cb796c
commit
638983d42a
61 changed files with 2641 additions and 8298 deletions
29
app/Http/Middleware/EnsureOnboardingComplete.php
Normal file
29
app/Http/Middleware/EnsureOnboardingComplete.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\OnboardingService;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureOnboardingComplete
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private OnboardingService $onboardingService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* Redirect to onboarding if the user hasn't completed setup.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if ($this->onboardingService->needsOnboarding()) {
|
||||||
|
return redirect()->route('onboarding');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Http/Middleware/RedirectIfOnboardingComplete.php
Normal file
29
app/Http/Middleware/RedirectIfOnboardingComplete.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\OnboardingService;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class RedirectIfOnboardingComplete
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private OnboardingService $onboardingService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* Redirect to dashboard if onboarding is already complete.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (!$this->onboardingService->needsOnboarding()) {
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Livewire/Articles.php
Normal file
61
app/Livewire/Articles.php
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Jobs\ArticleDiscoveryJob;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
|
class Articles extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
public bool $isRefreshing = false;
|
||||||
|
|
||||||
|
public function approve(int $articleId): void
|
||||||
|
{
|
||||||
|
$article = Article::findOrFail($articleId);
|
||||||
|
$article->approve();
|
||||||
|
|
||||||
|
$this->dispatch('article-updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reject(int $articleId): void
|
||||||
|
{
|
||||||
|
$article = Article::findOrFail($articleId);
|
||||||
|
$article->reject();
|
||||||
|
|
||||||
|
$this->dispatch('article-updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refresh(): void
|
||||||
|
{
|
||||||
|
$this->isRefreshing = true;
|
||||||
|
|
||||||
|
ArticleDiscoveryJob::dispatch();
|
||||||
|
|
||||||
|
// Reset after 10 seconds
|
||||||
|
$this->dispatch('refresh-complete')->self();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshComplete(): void
|
||||||
|
{
|
||||||
|
$this->isRefreshing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$articles = Article::with(['feed', 'articlePublication'])
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate(15);
|
||||||
|
|
||||||
|
$approvalsEnabled = Setting::isPublishingApprovalsEnabled();
|
||||||
|
|
||||||
|
return view('livewire.articles', [
|
||||||
|
'articles' => $articles,
|
||||||
|
'approvalsEnabled' => $approvalsEnabled,
|
||||||
|
])->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/Livewire/Channels.php
Normal file
73
app/Livewire/Channels.php
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\PlatformAccount;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Channels extends Component
|
||||||
|
{
|
||||||
|
public ?int $managingChannelId = null;
|
||||||
|
|
||||||
|
public function toggle(int $channelId): void
|
||||||
|
{
|
||||||
|
$channel = PlatformChannel::findOrFail($channelId);
|
||||||
|
$channel->is_active = !$channel->is_active;
|
||||||
|
$channel->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openAccountModal(int $channelId): void
|
||||||
|
{
|
||||||
|
$this->managingChannelId = $channelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeAccountModal(): void
|
||||||
|
{
|
||||||
|
$this->managingChannelId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachAccount(int $accountId): void
|
||||||
|
{
|
||||||
|
if (!$this->managingChannelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$channel = PlatformChannel::findOrFail($this->managingChannelId);
|
||||||
|
|
||||||
|
if (!$channel->platformAccounts()->where('platform_account_id', $accountId)->exists()) {
|
||||||
|
$channel->platformAccounts()->attach($accountId, [
|
||||||
|
'is_active' => true,
|
||||||
|
'priority' => 1,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detachAccount(int $channelId, int $accountId): void
|
||||||
|
{
|
||||||
|
$channel = PlatformChannel::findOrFail($channelId);
|
||||||
|
$channel->platformAccounts()->detach($accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$channels = PlatformChannel::with(['platformInstance', 'platformAccounts'])->orderBy('name')->get();
|
||||||
|
$allAccounts = PlatformAccount::where('is_active', true)->get();
|
||||||
|
|
||||||
|
$managingChannel = $this->managingChannelId
|
||||||
|
? PlatformChannel::with('platformAccounts')->find($this->managingChannelId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$availableAccounts = $managingChannel
|
||||||
|
? $allAccounts->filter(fn($account) => !$managingChannel->platformAccounts->contains('id', $account->id))
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
return view('livewire.channels', [
|
||||||
|
'channels' => $channels,
|
||||||
|
'managingChannel' => $managingChannel,
|
||||||
|
'availableAccounts' => $availableAccounts,
|
||||||
|
])->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Livewire/Dashboard.php
Normal file
36
app/Livewire/Dashboard.php
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Services\DashboardStatsService;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Dashboard extends Component
|
||||||
|
{
|
||||||
|
public string $period = 'today';
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
// Default period
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPeriod(string $period): void
|
||||||
|
{
|
||||||
|
$this->period = $period;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$service = app(DashboardStatsService::class);
|
||||||
|
|
||||||
|
$articleStats = $service->getStats($this->period);
|
||||||
|
$systemStats = $service->getSystemStats();
|
||||||
|
$availablePeriods = $service->getAvailablePeriods();
|
||||||
|
|
||||||
|
return view('livewire.dashboard', [
|
||||||
|
'articleStats' => $articleStats,
|
||||||
|
'systemStats' => $systemStats,
|
||||||
|
'availablePeriods' => $availablePeriods,
|
||||||
|
])->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Livewire/Feeds.php
Normal file
25
app/Livewire/Feeds.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Feed;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Feeds extends Component
|
||||||
|
{
|
||||||
|
public function toggle(int $feedId): void
|
||||||
|
{
|
||||||
|
$feed = Feed::findOrFail($feedId);
|
||||||
|
$feed->is_active = !$feed->is_active;
|
||||||
|
$feed->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$feeds = Feed::orderBy('name')->get();
|
||||||
|
|
||||||
|
return view('livewire.feeds', [
|
||||||
|
'feeds' => $feeds,
|
||||||
|
])->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
349
app/Livewire/Onboarding.php
Normal file
349
app/Livewire/Onboarding.php
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Jobs\ArticleDiscoveryJob;
|
||||||
|
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 App\Services\OnboardingService;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Onboarding extends Component
|
||||||
|
{
|
||||||
|
// Step tracking (1-6: welcome, platform, feed, channel, route, complete)
|
||||||
|
public int $step = 1;
|
||||||
|
|
||||||
|
// Platform form
|
||||||
|
public string $instanceUrl = '';
|
||||||
|
public string $username = '';
|
||||||
|
public string $password = '';
|
||||||
|
public ?array $existingAccount = null;
|
||||||
|
|
||||||
|
// Feed form
|
||||||
|
public string $feedName = '';
|
||||||
|
public string $feedProvider = 'vrt';
|
||||||
|
public ?int $feedLanguageId = null;
|
||||||
|
public string $feedDescription = '';
|
||||||
|
|
||||||
|
// Channel form
|
||||||
|
public string $channelName = '';
|
||||||
|
public ?int $platformInstanceId = null;
|
||||||
|
public ?int $channelLanguageId = null;
|
||||||
|
public string $channelDescription = '';
|
||||||
|
|
||||||
|
// Route form
|
||||||
|
public ?int $routeFeedId = null;
|
||||||
|
public ?int $routeChannelId = null;
|
||||||
|
public int $routePriority = 50;
|
||||||
|
|
||||||
|
// State
|
||||||
|
public array $errors = [];
|
||||||
|
public bool $isLoading = false;
|
||||||
|
|
||||||
|
protected LemmyAuthService $lemmyAuthService;
|
||||||
|
|
||||||
|
public function boot(LemmyAuthService $lemmyAuthService): void
|
||||||
|
{
|
||||||
|
$this->lemmyAuthService = $lemmyAuthService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
// Check for existing platform account
|
||||||
|
$account = PlatformAccount::where('is_active', true)->first();
|
||||||
|
if ($account) {
|
||||||
|
$this->existingAccount = [
|
||||||
|
'id' => $account->id,
|
||||||
|
'username' => $account->username,
|
||||||
|
'instance_url' => $account->instance_url,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-fill feed form if exists
|
||||||
|
$feed = Feed::where('is_active', true)->first();
|
||||||
|
if ($feed) {
|
||||||
|
$this->feedName = $feed->name;
|
||||||
|
$this->feedProvider = $feed->provider ?? 'vrt';
|
||||||
|
$this->feedLanguageId = $feed->language_id;
|
||||||
|
$this->feedDescription = $feed->description ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-fill channel form if exists
|
||||||
|
$channel = PlatformChannel::where('is_active', true)->first();
|
||||||
|
if ($channel) {
|
||||||
|
$this->channelName = $channel->name;
|
||||||
|
$this->platformInstanceId = $channel->platform_instance_id;
|
||||||
|
$this->channelLanguageId = $channel->language_id;
|
||||||
|
$this->channelDescription = $channel->description ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-fill route form if exists
|
||||||
|
$route = Route::where('is_active', true)->first();
|
||||||
|
if ($route) {
|
||||||
|
$this->routeFeedId = $route->feed_id;
|
||||||
|
$this->routeChannelId = $route->platform_channel_id;
|
||||||
|
$this->routePriority = $route->priority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function goToStep(int $step): void
|
||||||
|
{
|
||||||
|
$this->step = $step;
|
||||||
|
$this->errors = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextStep(): void
|
||||||
|
{
|
||||||
|
$this->step++;
|
||||||
|
$this->errors = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function previousStep(): void
|
||||||
|
{
|
||||||
|
if ($this->step > 1) {
|
||||||
|
$this->step--;
|
||||||
|
$this->errors = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function continueWithExistingAccount(): void
|
||||||
|
{
|
||||||
|
$this->nextStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteAccount(): void
|
||||||
|
{
|
||||||
|
if ($this->existingAccount) {
|
||||||
|
PlatformAccount::destroy($this->existingAccount['id']);
|
||||||
|
$this->existingAccount = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createPlatformAccount(): void
|
||||||
|
{
|
||||||
|
$this->errors = [];
|
||||||
|
$this->isLoading = true;
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'instanceUrl' => 'required|string|max:255|regex:/^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$/',
|
||||||
|
'username' => 'required|string|max:255',
|
||||||
|
'password' => 'required|string|min:6',
|
||||||
|
], [
|
||||||
|
'instanceUrl.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fullInstanceUrl = 'https://' . $this->instanceUrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create or get platform instance
|
||||||
|
$platformInstance = PlatformInstance::firstOrCreate([
|
||||||
|
'url' => $fullInstanceUrl,
|
||||||
|
'platform' => 'lemmy',
|
||||||
|
], [
|
||||||
|
'name' => ucfirst($this->instanceUrl),
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Authenticate with Lemmy API
|
||||||
|
$authResponse = $this->lemmyAuthService->authenticate(
|
||||||
|
$fullInstanceUrl,
|
||||||
|
$this->username,
|
||||||
|
$this->password
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create platform account
|
||||||
|
$platformAccount = PlatformAccount::create([
|
||||||
|
'platform' => 'lemmy',
|
||||||
|
'instance_url' => $fullInstanceUrl,
|
||||||
|
'username' => $this->username,
|
||||||
|
'password' => Crypt::encryptString($this->password),
|
||||||
|
'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,
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->existingAccount = [
|
||||||
|
'id' => $platformAccount->id,
|
||||||
|
'username' => $platformAccount->username,
|
||||||
|
'instance_url' => $platformAccount->instance_url,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->nextStep();
|
||||||
|
} catch (\App\Exceptions\PlatformAuthException $e) {
|
||||||
|
if (str_contains($e->getMessage(), 'Rate limited by')) {
|
||||||
|
$this->errors['general'] = $e->getMessage();
|
||||||
|
} else {
|
||||||
|
$this->errors['general'] = 'Invalid username or password. Please check your credentials and try again.';
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->errors['general'] = 'Unable to connect to the Lemmy instance. Please check the URL and try again.';
|
||||||
|
} finally {
|
||||||
|
$this->isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createFeed(): void
|
||||||
|
{
|
||||||
|
$this->errors = [];
|
||||||
|
$this->isLoading = true;
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'feedName' => 'required|string|max:255',
|
||||||
|
'feedProvider' => 'required|in:belga,vrt',
|
||||||
|
'feedLanguageId' => 'required|exists:languages,id',
|
||||||
|
'feedDescription' => 'nullable|string|max:1000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Map provider to URL
|
||||||
|
$url = $this->feedProvider === 'vrt'
|
||||||
|
? 'https://www.vrt.be/vrtnws/en/'
|
||||||
|
: 'https://www.belganewsagency.eu/';
|
||||||
|
|
||||||
|
Feed::firstOrCreate(
|
||||||
|
['url' => $url],
|
||||||
|
[
|
||||||
|
'name' => $this->feedName,
|
||||||
|
'type' => 'website',
|
||||||
|
'provider' => $this->feedProvider,
|
||||||
|
'language_id' => $this->feedLanguageId,
|
||||||
|
'description' => $this->feedDescription ?: null,
|
||||||
|
'is_active' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->nextStep();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->errors['general'] = 'Failed to create feed. Please try again.';
|
||||||
|
} finally {
|
||||||
|
$this->isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createChannel(): void
|
||||||
|
{
|
||||||
|
$this->errors = [];
|
||||||
|
$this->isLoading = true;
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'channelName' => 'required|string|max:255',
|
||||||
|
'platformInstanceId' => 'required|exists:platform_instances,id',
|
||||||
|
'channelLanguageId' => 'required|exists:languages,id',
|
||||||
|
'channelDescription' => 'nullable|string|max:1000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$platformInstance = PlatformInstance::findOrFail($this->platformInstanceId);
|
||||||
|
|
||||||
|
// Check for active platform accounts
|
||||||
|
$activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url)
|
||||||
|
->where('is_active', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($activeAccounts->isEmpty()) {
|
||||||
|
$this->errors['general'] = 'No active platform accounts found for this instance. Please create a platform account first.';
|
||||||
|
$this->isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$channel = PlatformChannel::create([
|
||||||
|
'platform_instance_id' => $this->platformInstanceId,
|
||||||
|
'channel_id' => $this->channelName,
|
||||||
|
'name' => $this->channelName,
|
||||||
|
'display_name' => ucfirst($this->channelName),
|
||||||
|
'description' => $this->channelDescription ?: null,
|
||||||
|
'language_id' => $this->channelLanguageId,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Attach first active account
|
||||||
|
$channel->platformAccounts()->attach($activeAccounts->first()->id, [
|
||||||
|
'is_active' => true,
|
||||||
|
'priority' => 1,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->nextStep();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->errors['general'] = 'Failed to create channel. Please try again.';
|
||||||
|
} finally {
|
||||||
|
$this->isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createRoute(): void
|
||||||
|
{
|
||||||
|
$this->errors = [];
|
||||||
|
$this->isLoading = true;
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'routeFeedId' => 'required|exists:feeds,id',
|
||||||
|
'routeChannelId' => 'required|exists:platform_channels,id',
|
||||||
|
'routePriority' => 'nullable|integer|min:1|max:100',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Route::create([
|
||||||
|
'feed_id' => $this->routeFeedId,
|
||||||
|
'platform_channel_id' => $this->routeChannelId,
|
||||||
|
'priority' => $this->routePriority,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Trigger article discovery
|
||||||
|
ArticleDiscoveryJob::dispatch();
|
||||||
|
|
||||||
|
$this->nextStep();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->errors['general'] = 'Failed to create route. Please try again.';
|
||||||
|
} finally {
|
||||||
|
$this->isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function completeOnboarding(): void
|
||||||
|
{
|
||||||
|
Setting::updateOrCreate(
|
||||||
|
['key' => 'onboarding_completed'],
|
||||||
|
['value' => now()->toIso8601String()]
|
||||||
|
);
|
||||||
|
|
||||||
|
app(OnboardingService::class)->clearCache();
|
||||||
|
|
||||||
|
$this->redirect(route('dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$languages = Language::where('is_active', true)->orderBy('name')->get();
|
||||||
|
$platformInstances = PlatformInstance::where('is_active', true)->orderBy('name')->get();
|
||||||
|
$feeds = Feed::where('is_active', true)->orderBy('name')->get();
|
||||||
|
$channels = PlatformChannel::where('is_active', true)->orderBy('name')->get();
|
||||||
|
|
||||||
|
$feedProviders = collect(config('feed.providers', []))
|
||||||
|
->filter(fn($provider) => $provider['is_active'] ?? false)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return view('livewire.onboarding', [
|
||||||
|
'languages' => $languages,
|
||||||
|
'platformInstances' => $platformInstances,
|
||||||
|
'feeds' => $feeds,
|
||||||
|
'channels' => $channels,
|
||||||
|
'feedProviders' => $feedProviders,
|
||||||
|
])->layout('layouts.guest');
|
||||||
|
}
|
||||||
|
}
|
||||||
200
app/Livewire/Routes.php
Normal file
200
app/Livewire/Routes.php
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Models\Keyword;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use App\Models\Route;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Routes extends Component
|
||||||
|
{
|
||||||
|
public bool $showCreateModal = false;
|
||||||
|
public ?int $editingFeedId = null;
|
||||||
|
public ?int $editingChannelId = null;
|
||||||
|
|
||||||
|
// Create form
|
||||||
|
public ?int $newFeedId = null;
|
||||||
|
public ?int $newChannelId = null;
|
||||||
|
public int $newPriority = 50;
|
||||||
|
|
||||||
|
// Edit form
|
||||||
|
public int $editPriority = 50;
|
||||||
|
|
||||||
|
// Keyword management
|
||||||
|
public string $newKeyword = '';
|
||||||
|
public bool $showKeywordInput = false;
|
||||||
|
|
||||||
|
public function openCreateModal(): void
|
||||||
|
{
|
||||||
|
$this->showCreateModal = true;
|
||||||
|
$this->newFeedId = null;
|
||||||
|
$this->newChannelId = null;
|
||||||
|
$this->newPriority = 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeCreateModal(): void
|
||||||
|
{
|
||||||
|
$this->showCreateModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createRoute(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'newFeedId' => 'required|exists:feeds,id',
|
||||||
|
'newChannelId' => 'required|exists:platform_channels,id',
|
||||||
|
'newPriority' => 'required|integer|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$exists = Route::where('feed_id', $this->newFeedId)
|
||||||
|
->where('platform_channel_id', $this->newChannelId)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
$this->addError('newFeedId', 'This route already exists.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Route::create([
|
||||||
|
'feed_id' => $this->newFeedId,
|
||||||
|
'platform_channel_id' => $this->newChannelId,
|
||||||
|
'priority' => $this->newPriority,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->closeCreateModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openEditModal(int $feedId, int $channelId): void
|
||||||
|
{
|
||||||
|
$route = Route::where('feed_id', $feedId)
|
||||||
|
->where('platform_channel_id', $channelId)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$this->editingFeedId = $feedId;
|
||||||
|
$this->editingChannelId = $channelId;
|
||||||
|
$this->editPriority = $route->priority;
|
||||||
|
$this->newKeyword = '';
|
||||||
|
$this->showKeywordInput = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeEditModal(): void
|
||||||
|
{
|
||||||
|
$this->editingFeedId = null;
|
||||||
|
$this->editingChannelId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateRoute(): void
|
||||||
|
{
|
||||||
|
if (!$this->editingFeedId || !$this->editingChannelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'editPriority' => 'required|integer|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Route::where('feed_id', $this->editingFeedId)
|
||||||
|
->where('platform_channel_id', $this->editingChannelId)
|
||||||
|
->update(['priority' => $this->editPriority]);
|
||||||
|
|
||||||
|
$this->closeEditModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggle(int $feedId, int $channelId): void
|
||||||
|
{
|
||||||
|
$route = Route::where('feed_id', $feedId)
|
||||||
|
->where('platform_channel_id', $channelId)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$route->is_active = !$route->is_active;
|
||||||
|
$route->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $feedId, int $channelId): void
|
||||||
|
{
|
||||||
|
// Delete associated keywords first
|
||||||
|
Keyword::where('feed_id', $feedId)
|
||||||
|
->where('platform_channel_id', $channelId)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
Route::where('feed_id', $feedId)
|
||||||
|
->where('platform_channel_id', $channelId)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addKeyword(): void
|
||||||
|
{
|
||||||
|
if (!$this->editingFeedId || !$this->editingChannelId || empty(trim($this->newKeyword))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Keyword::create([
|
||||||
|
'feed_id' => $this->editingFeedId,
|
||||||
|
'platform_channel_id' => $this->editingChannelId,
|
||||||
|
'keyword' => trim($this->newKeyword),
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->newKeyword = '';
|
||||||
|
$this->showKeywordInput = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleKeyword(int $keywordId): void
|
||||||
|
{
|
||||||
|
$keyword = Keyword::findOrFail($keywordId);
|
||||||
|
$keyword->is_active = !$keyword->is_active;
|
||||||
|
$keyword->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteKeyword(int $keywordId): void
|
||||||
|
{
|
||||||
|
Keyword::destroy($keywordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$routes = Route::with(['feed', 'platformChannel'])
|
||||||
|
->orderBy('priority', 'desc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Batch load keywords for all routes to avoid N+1 queries
|
||||||
|
$routeKeys = $routes->map(fn($r) => $r->feed_id . '-' . $r->platform_channel_id);
|
||||||
|
$allKeywords = Keyword::whereIn('feed_id', $routes->pluck('feed_id'))
|
||||||
|
->whereIn('platform_channel_id', $routes->pluck('platform_channel_id'))
|
||||||
|
->get()
|
||||||
|
->groupBy(fn($k) => $k->feed_id . '-' . $k->platform_channel_id);
|
||||||
|
|
||||||
|
$routes = $routes->map(function ($route) use ($allKeywords) {
|
||||||
|
$key = $route->feed_id . '-' . $route->platform_channel_id;
|
||||||
|
$route->keywords = $allKeywords->get($key, collect());
|
||||||
|
return $route;
|
||||||
|
});
|
||||||
|
|
||||||
|
$feeds = Feed::where('is_active', true)->orderBy('name')->get();
|
||||||
|
$channels = PlatformChannel::where('is_active', true)->orderBy('name')->get();
|
||||||
|
|
||||||
|
$editingRoute = null;
|
||||||
|
$editingKeywords = collect();
|
||||||
|
|
||||||
|
if ($this->editingFeedId && $this->editingChannelId) {
|
||||||
|
$editingRoute = Route::with(['feed', 'platformChannel'])
|
||||||
|
->where('feed_id', $this->editingFeedId)
|
||||||
|
->where('platform_channel_id', $this->editingChannelId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$editingKeywords = Keyword::where('feed_id', $this->editingFeedId)
|
||||||
|
->where('platform_channel_id', $this->editingChannelId)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('livewire.routes', [
|
||||||
|
'routes' => $routes,
|
||||||
|
'feeds' => $feeds,
|
||||||
|
'channels' => $channels,
|
||||||
|
'editingRoute' => $editingRoute,
|
||||||
|
'editingKeywords' => $editingKeywords,
|
||||||
|
])->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Livewire/Settings.php
Normal file
55
app/Livewire/Settings.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Settings extends Component
|
||||||
|
{
|
||||||
|
public bool $articleProcessingEnabled = true;
|
||||||
|
public bool $publishingApprovalsEnabled = false;
|
||||||
|
|
||||||
|
public ?string $successMessage = null;
|
||||||
|
public ?string $errorMessage = null;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->articleProcessingEnabled = Setting::isArticleProcessingEnabled();
|
||||||
|
$this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleArticleProcessing(): void
|
||||||
|
{
|
||||||
|
$this->articleProcessingEnabled = !$this->articleProcessingEnabled;
|
||||||
|
Setting::setArticleProcessingEnabled($this->articleProcessingEnabled);
|
||||||
|
$this->showSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function togglePublishingApprovals(): void
|
||||||
|
{
|
||||||
|
$this->publishingApprovalsEnabled = !$this->publishingApprovalsEnabled;
|
||||||
|
Setting::setPublishingApprovalsEnabled($this->publishingApprovalsEnabled);
|
||||||
|
$this->showSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function showSuccess(): void
|
||||||
|
{
|
||||||
|
$this->successMessage = 'Settings updated successfully!';
|
||||||
|
$this->errorMessage = null;
|
||||||
|
|
||||||
|
// Clear success message after 3 seconds
|
||||||
|
$this->dispatch('clear-message');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearMessages(): void
|
||||||
|
{
|
||||||
|
$this->successMessage = null;
|
||||||
|
$this->errorMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.settings')->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -109,6 +109,11 @@ public function canBePublished(): bool
|
||||||
return $this->isApproved();
|
return $this->isApproved();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getIsPublishedAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->articlePublication()->exists();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return HasOne<ArticlePublication, $this>
|
* @return HasOne<ArticlePublication, $this>
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
46
app/Services/OnboardingService.php
Normal file
46
app/Services/OnboardingService.php
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Models\PlatformAccount;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use App\Models\Route;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class OnboardingService
|
||||||
|
{
|
||||||
|
public function needsOnboarding(): bool
|
||||||
|
{
|
||||||
|
return Cache::remember('onboarding_needed', 300, function () {
|
||||||
|
return $this->checkOnboardingStatus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearCache(): void
|
||||||
|
{
|
||||||
|
Cache::forget('onboarding_needed');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkOnboardingStatus(): bool
|
||||||
|
{
|
||||||
|
$onboardingSkipped = Setting::where('key', 'onboarding_skipped')->value('value') === 'true';
|
||||||
|
$onboardingCompleted = Setting::where('key', 'onboarding_completed')->exists();
|
||||||
|
|
||||||
|
// If skipped or completed, no onboarding needed
|
||||||
|
if ($onboardingCompleted || $onboardingSkipped) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all components exist
|
||||||
|
$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();
|
||||||
|
|
||||||
|
$hasAllComponents = $hasPlatformAccount && $hasFeed && $hasChannel && $hasRoute;
|
||||||
|
|
||||||
|
return !$hasAllComponents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Middleware\HandleAppearance;
|
use App\Http\Middleware\EnsureOnboardingComplete;
|
||||||
use App\Http\Middleware\HandleInertiaRequests;
|
use App\Http\Middleware\RedirectIfOnboardingComplete;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
|
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
|
|
@ -15,12 +14,9 @@
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
$middleware->alias([
|
||||||
|
'onboarding.complete' => EnsureOnboardingComplete::class,
|
||||||
$middleware->web(append: [
|
'onboarding.incomplete' => RedirectIfOnboardingComplete::class,
|
||||||
HandleAppearance::class,
|
|
||||||
HandleInertiaRequests::class,
|
|
||||||
AddLinkHeadersForPreloadedAssets::class,
|
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
|
|
|
||||||
24
frontend/.gitignore
vendored
24
frontend/.gitignore
vendored
|
|
@ -1,24 +0,0 @@
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
resources/js/components/ui/*
|
|
||||||
resources/js/ziggy.js
|
|
||||||
resources/views/mail/*
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": true,
|
|
||||||
"singleAttributePerLine": false,
|
|
||||||
"htmlWhitespaceSensitivity": "css",
|
|
||||||
"printWidth": 150,
|
|
||||||
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"],
|
|
||||||
"tailwindFunctions": ["clsx", "cn"],
|
|
||||||
"tailwindStylesheet": "resources/css/app.css",
|
|
||||||
"tabWidth": 4,
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": "**/*.yml",
|
|
||||||
"options": {
|
|
||||||
"tabWidth": 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
# React + TypeScript + Vite
|
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default tseslint.config([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
...tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
...tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default tseslint.config([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import js from '@eslint/js'
|
|
||||||
import globals from 'globals'
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import tseslint from 'typescript-eslint'
|
|
||||||
import { globalIgnores } from 'eslint/config'
|
|
||||||
|
|
||||||
export default tseslint.config([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
js.configs.recommended,
|
|
||||||
tseslint.configs.recommended,
|
|
||||||
reactHooks.configs['recommended-latest'],
|
|
||||||
reactRefresh.configs.vite,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
globals: globals.browser,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>FFR - Fedi Feed Router</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
4504
frontend/package-lock.json
generated
4504
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,36 +0,0 @@
|
||||||
{
|
|
||||||
"name": "frontend",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@tanstack/react-query": "^5.84.1",
|
|
||||||
"axios": "^1.11.0",
|
|
||||||
"lucide-react": "^0.536.0",
|
|
||||||
"react": "^19.1.0",
|
|
||||||
"react-dom": "^19.1.0",
|
|
||||||
"react-router-dom": "^7.7.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.30.1",
|
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
|
||||||
"@types/react": "^19.1.8",
|
|
||||||
"@types/react-dom": "^19.1.6",
|
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
|
||||||
"autoprefixer": "^10.4.21",
|
|
||||||
"eslint": "^9.30.1",
|
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
|
||||||
"globals": "^16.3.0",
|
|
||||||
"postcss": "^8.5.6",
|
|
||||||
"typescript": "~5.8.3",
|
|
||||||
"typescript-eslint": "^8.35.1",
|
|
||||||
"vite": "^7.0.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
'@tailwindcss/postcss': {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
|
@ -1,42 +0,0 @@
|
||||||
#root {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 6em;
|
|
||||||
padding: 1.5em;
|
|
||||||
will-change: filter;
|
|
||||||
transition: filter 300ms;
|
|
||||||
}
|
|
||||||
.logo:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #646cffaa);
|
|
||||||
}
|
|
||||||
.logo.react:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logo-spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
a:nth-of-type(2) .logo {
|
|
||||||
animation: logo-spin infinite 20s linear;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.read-the-docs {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
|
||||||
import Layout from './components/Layout';
|
|
||||||
import Dashboard from './pages/Dashboard';
|
|
||||||
import Articles from './pages/Articles';
|
|
||||||
import Feeds from './pages/Feeds';
|
|
||||||
import Channels from './pages/Channels';
|
|
||||||
import RoutesPage from './pages/Routes';
|
|
||||||
import Settings from './pages/Settings';
|
|
||||||
import OnboardingWizard from './pages/onboarding/OnboardingWizard';
|
|
||||||
|
|
||||||
const App: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<Routes>
|
|
||||||
{/* Onboarding routes - outside of main layout */}
|
|
||||||
<Route path="/onboarding/*" element={<OnboardingWizard />} />
|
|
||||||
|
|
||||||
{/* Main app routes - with layout */}
|
|
||||||
<Route path="/*" element={
|
|
||||||
<Layout>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
|
||||||
<Route path="/articles" element={<Articles />} />
|
|
||||||
<Route path="/feeds" element={<Feeds />} />
|
|
||||||
<Route path="/channels" element={<Channels />} />
|
|
||||||
<Route path="/routes" element={<RoutesPage />} />
|
|
||||||
<Route path="/settings" element={<Settings />} />
|
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
|
||||||
</Routes>
|
|
||||||
</Layout>
|
|
||||||
} />
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4 KiB |
|
|
@ -1,170 +0,0 @@
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { Plus, X, Tag } from 'lucide-react';
|
|
||||||
import { apiClient, type Keyword, type KeywordRequest } from '../lib/api';
|
|
||||||
|
|
||||||
interface KeywordManagerProps {
|
|
||||||
feedId: number;
|
|
||||||
channelId: number;
|
|
||||||
keywords: Keyword[];
|
|
||||||
onKeywordChange?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const KeywordManager: React.FC<KeywordManagerProps> = ({
|
|
||||||
feedId,
|
|
||||||
channelId,
|
|
||||||
keywords = [],
|
|
||||||
onKeywordChange
|
|
||||||
}) => {
|
|
||||||
const [newKeyword, setNewKeyword] = useState('');
|
|
||||||
const [isAddingKeyword, setIsAddingKeyword] = useState(false);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const createKeywordMutation = useMutation({
|
|
||||||
mutationFn: (data: KeywordRequest) => apiClient.createKeyword(feedId, channelId, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['routes'] });
|
|
||||||
setNewKeyword('');
|
|
||||||
setIsAddingKeyword(false);
|
|
||||||
onKeywordChange?.();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteKeywordMutation = useMutation({
|
|
||||||
mutationFn: (keywordId: number) => apiClient.deleteKeyword(feedId, channelId, keywordId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['routes'] });
|
|
||||||
onKeywordChange?.();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleKeywordMutation = useMutation({
|
|
||||||
mutationFn: (keywordId: number) => apiClient.toggleKeyword(feedId, channelId, keywordId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['routes'] });
|
|
||||||
onKeywordChange?.();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleAddKeyword = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (newKeyword.trim()) {
|
|
||||||
createKeywordMutation.mutate({ keyword: newKeyword.trim() });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteKeyword = (keywordId: number) => {
|
|
||||||
if (confirm('Are you sure you want to delete this keyword?')) {
|
|
||||||
deleteKeywordMutation.mutate(keywordId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleKeyword = (keywordId: number) => {
|
|
||||||
toggleKeywordMutation.mutate(keywordId);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Tag className="h-4 w-4 text-gray-500" />
|
|
||||||
<span className="text-sm font-medium text-gray-700">Keywords</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsAddingKeyword(true)}
|
|
||||||
className="inline-flex items-center p-1 text-gray-400 hover:text-gray-600 rounded"
|
|
||||||
title="Add keyword"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isAddingKeyword && (
|
|
||||||
<form onSubmit={handleAddKeyword} className="flex space-x-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newKeyword}
|
|
||||||
onChange={(e) => setNewKeyword(e.target.value)}
|
|
||||||
placeholder="Enter keyword..."
|
|
||||||
className="flex-1 px-2 py-1 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={createKeywordMutation.isPending || !newKeyword.trim()}
|
|
||||||
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setIsAddingKeyword(false);
|
|
||||||
setNewKeyword('');
|
|
||||||
}}
|
|
||||||
className="px-3 py-1 bg-gray-200 text-gray-700 text-sm rounded hover:bg-gray-300"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{keywords.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{keywords.map((keyword) => (
|
|
||||||
<div
|
|
||||||
key={keyword.id}
|
|
||||||
className={`flex items-center justify-between px-3 py-2 rounded border ${
|
|
||||||
keyword.is_active
|
|
||||||
? 'border-blue-200 bg-blue-50'
|
|
||||||
: 'border-gray-200 bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
|
||||||
keyword.is_active
|
|
||||||
? 'bg-blue-100 text-blue-800'
|
|
||||||
: 'bg-gray-100 text-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{keyword.keyword}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
{keyword.is_active ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleToggleKeyword(keyword.id)}
|
|
||||||
disabled={toggleKeywordMutation.isPending}
|
|
||||||
className="text-sm text-blue-600 hover:text-blue-800 disabled:opacity-50"
|
|
||||||
title={keyword.is_active ? 'Deactivate keyword' : 'Activate keyword'}
|
|
||||||
>
|
|
||||||
{keyword.is_active ? 'Deactivate' : 'Activate'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDeleteKeyword(keyword.id)}
|
|
||||||
disabled={deleteKeywordMutation.isPending}
|
|
||||||
className="text-red-600 hover:text-red-800 disabled:opacity-50"
|
|
||||||
title="Delete keyword"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
!isAddingKeyword && (
|
|
||||||
<div className="text-sm text-gray-500 italic p-2 border border-gray-200 rounded">
|
|
||||||
No keywords defined. This route will match all articles.
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default KeywordManager;
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
Home,
|
|
||||||
FileText,
|
|
||||||
Rss,
|
|
||||||
Hash,
|
|
||||||
Settings as SettingsIcon,
|
|
||||||
Route,
|
|
||||||
Menu,
|
|
||||||
X
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface LayoutProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|
||||||
const [sidebarOpen, setSidebarOpen] = React.useState(false);
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const navigation = [
|
|
||||||
{ name: 'Dashboard', href: '/dashboard', icon: Home },
|
|
||||||
{ name: 'Articles', href: '/articles', icon: FileText },
|
|
||||||
{ name: 'Feeds', href: '/feeds', icon: Rss },
|
|
||||||
{ name: 'Channels', href: '/channels', icon: Hash },
|
|
||||||
{ name: 'Routes', href: '/routes', icon: Route },
|
|
||||||
{ name: 'Settings', href: '/settings', icon: SettingsIcon },
|
|
||||||
];
|
|
||||||
|
|
||||||
const renderMobileOverlay = () => {
|
|
||||||
if (!sidebarOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50">
|
|
||||||
{renderMobileOverlay()}
|
|
||||||
|
|
||||||
{/* Mobile sidebar */}
|
|
||||||
<div className={`fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out lg:hidden ${
|
|
||||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
|
||||||
}`}>
|
|
||||||
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
|
|
||||||
<h1 className="text-xl font-bold text-gray-900">FFR</h1>
|
|
||||||
<button
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
className="p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<X className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<nav className="mt-5 px-2">
|
|
||||||
{navigation.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const isActive = location.pathname === item.href;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.name}
|
|
||||||
to={item.href}
|
|
||||||
className={`group flex items-center px-2 py-2 text-base font-medium rounded-md mb-1 ${
|
|
||||||
isActive
|
|
||||||
? 'bg-blue-100 text-blue-700'
|
|
||||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
|
||||||
}`}
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
>
|
|
||||||
<Icon className="mr-4 h-6 w-6" />
|
|
||||||
{item.name}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop sidebar */}
|
|
||||||
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
|
|
||||||
<div className="flex flex-col flex-grow bg-white pt-5 pb-4 overflow-y-auto border-r border-gray-200">
|
|
||||||
<div className="flex items-center flex-shrink-0 px-4">
|
|
||||||
<h1 className="text-xl font-bold text-gray-900">FFR</h1>
|
|
||||||
</div>
|
|
||||||
<nav className="mt-5 flex-1 px-2 bg-white">
|
|
||||||
{navigation.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const isActive = location.pathname === item.href;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.name}
|
|
||||||
to={item.href}
|
|
||||||
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md mb-1 ${
|
|
||||||
isActive
|
|
||||||
? 'bg-blue-100 text-blue-700'
|
|
||||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="mr-3 h-6 w-6" />
|
|
||||||
{item.name}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
<div className="flex-shrink-0 p-4 border-t border-gray-200">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-gray-900 truncate">
|
|
||||||
Feed Feed Reader
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500 truncate">
|
|
||||||
Admin Dashboard
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div className="lg:pl-64 flex flex-col flex-1">
|
|
||||||
<div className="sticky top-0 z-10 flex-shrink-0 flex h-16 bg-white shadow lg:hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => setSidebarOpen(true)}
|
|
||||||
className="px-4 border-r border-gray-200 text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 lg:hidden"
|
|
||||||
>
|
|
||||||
<Menu className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
<div className="flex-1 px-4 flex justify-between items-center">
|
|
||||||
<h1 className="text-lg font-medium text-gray-900">FFR</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<main className="flex-1">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Layout;
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
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<OnboardingContextValue | null>(null);
|
|
||||||
|
|
||||||
interface OnboardingProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OnboardingProvider: React.FC<OnboardingProviderProps> = ({ 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 user doesn't need onboarding but is on onboarding pages, redirect to dashboard
|
|
||||||
if (!needsOnboarding && isOnOnboardingPage) {
|
|
||||||
navigate('/dashboard', { replace: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 = {
|
|
||||||
onboardingStatus,
|
|
||||||
isLoading,
|
|
||||||
needsOnboarding,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OnboardingContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</OnboardingContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useOnboarding = () => {
|
|
||||||
const context = useContext(OnboardingContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useOnboarding must be used within an OnboardingProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
@source "./src/**/*.{js,ts,jsx,tsx}";
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
|
||||||
sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
@ -1,457 +0,0 @@
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
// Configure axios base URL for API calls
|
|
||||||
axios.defaults.baseURL = '/api/v1';
|
|
||||||
|
|
||||||
// Types for API responses
|
|
||||||
export interface ApiResponse<T = any> {
|
|
||||||
success: boolean;
|
|
||||||
data: T;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiError {
|
|
||||||
success: false;
|
|
||||||
message: string;
|
|
||||||
errors?: Record<string, string[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
|
||||||
data: T[];
|
|
||||||
pagination: {
|
|
||||||
current_page: number;
|
|
||||||
last_page: number;
|
|
||||||
per_page: number;
|
|
||||||
total: number;
|
|
||||||
from: number | null;
|
|
||||||
to: number | null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Article types
|
|
||||||
export interface Article {
|
|
||||||
id: number;
|
|
||||||
feed_id: number;
|
|
||||||
url: string | null;
|
|
||||||
title: string;
|
|
||||||
description: string | null;
|
|
||||||
content: string | null;
|
|
||||||
image_url: string | null;
|
|
||||||
published_at: string | null;
|
|
||||||
author: string | null;
|
|
||||||
approval_status: 'pending' | 'approved' | 'rejected';
|
|
||||||
is_published: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
feed?: Feed;
|
|
||||||
article_publication?: ArticlePublication;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Feed types
|
|
||||||
export interface Feed {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
type: 'website' | 'rss';
|
|
||||||
is_active: boolean;
|
|
||||||
description: string | null;
|
|
||||||
language_id?: number;
|
|
||||||
provider?: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
articles_count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other types
|
|
||||||
export interface ArticlePublication {
|
|
||||||
id: number;
|
|
||||||
article_id: number;
|
|
||||||
status: string;
|
|
||||||
published_at: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlatformAccount {
|
|
||||||
id: number;
|
|
||||||
platform_instance_id: number;
|
|
||||||
account_id: string;
|
|
||||||
username: string;
|
|
||||||
display_name: string | null;
|
|
||||||
description: string | null;
|
|
||||||
is_active: boolean;
|
|
||||||
instance_url?: string;
|
|
||||||
password?: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlatformChannel {
|
|
||||||
id: number;
|
|
||||||
platform_instance_id: number;
|
|
||||||
channel_id: string;
|
|
||||||
name: string;
|
|
||||||
display_name: string | null;
|
|
||||||
description: string | null;
|
|
||||||
is_active: boolean;
|
|
||||||
language_id?: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
platform_instance?: PlatformInstance;
|
|
||||||
platform_accounts?: PlatformAccount[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Settings {
|
|
||||||
article_processing_enabled: boolean;
|
|
||||||
publishing_approvals_enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardStats {
|
|
||||||
article_stats: {
|
|
||||||
total_today: number;
|
|
||||||
total_week: number;
|
|
||||||
total_month: number;
|
|
||||||
approved_today: number;
|
|
||||||
approved_week: number;
|
|
||||||
approved_month: number;
|
|
||||||
approval_percentage_today: number;
|
|
||||||
approval_percentage_week: number;
|
|
||||||
approval_percentage_month: number;
|
|
||||||
};
|
|
||||||
system_stats: {
|
|
||||||
total_feeds: number;
|
|
||||||
active_feeds: number;
|
|
||||||
total_platform_accounts: number;
|
|
||||||
active_platform_accounts: number;
|
|
||||||
total_platform_channels: number;
|
|
||||||
active_platform_channels: number;
|
|
||||||
total_routes: number;
|
|
||||||
active_routes: number;
|
|
||||||
};
|
|
||||||
available_periods: Array<{ value: string; label: string }>;
|
|
||||||
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' | 'route' | 'complete' | null;
|
|
||||||
has_platform_account: boolean;
|
|
||||||
has_feed: boolean;
|
|
||||||
has_channel: boolean;
|
|
||||||
has_route: boolean;
|
|
||||||
onboarding_skipped: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FeedProvider {
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OnboardingOptions {
|
|
||||||
languages: Language[];
|
|
||||||
platform_instances: PlatformInstance[];
|
|
||||||
feeds: Feed[];
|
|
||||||
platform_channels: PlatformChannel[];
|
|
||||||
feed_providers: FeedProvider[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlatformAccountRequest {
|
|
||||||
instance_url: string;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
platform: 'lemmy';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FeedRequest {
|
|
||||||
name: string;
|
|
||||||
provider: 'vrt' | 'belga';
|
|
||||||
language_id: number;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChannelRequest {
|
|
||||||
name: string;
|
|
||||||
platform_instance_id: number;
|
|
||||||
language_id: number;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Keyword {
|
|
||||||
id: number;
|
|
||||||
keyword: string;
|
|
||||||
is_active: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Route {
|
|
||||||
id?: number;
|
|
||||||
feed_id: number;
|
|
||||||
platform_channel_id: number;
|
|
||||||
is_active: boolean;
|
|
||||||
priority: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
feed?: Feed;
|
|
||||||
platform_channel?: PlatformChannel;
|
|
||||||
keywords?: Keyword[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RouteRequest {
|
|
||||||
feed_id: number;
|
|
||||||
platform_channel_id: number;
|
|
||||||
priority?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KeywordRequest {
|
|
||||||
keyword: string;
|
|
||||||
is_active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// API Client class
|
|
||||||
class ApiClient {
|
|
||||||
constructor() {
|
|
||||||
this.setupInterceptors();
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupInterceptors() {
|
|
||||||
// Response interceptor to handle errors
|
|
||||||
axios.interceptors.response.use(
|
|
||||||
(response) => response,
|
|
||||||
(error) => {
|
|
||||||
console.error('API Error:', error);
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dashboard endpoints
|
|
||||||
async getDashboardStats(period = 'today'): Promise<DashboardStats> {
|
|
||||||
const response = await axios.get<ApiResponse<DashboardStats>>('/dashboard/stats', {
|
|
||||||
params: { period }
|
|
||||||
});
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Articles endpoints
|
|
||||||
async getArticles(page = 1, perPage = 15): Promise<{ articles: Article[]; pagination: any; settings: any }> {
|
|
||||||
const response = await axios.get<ApiResponse<{ articles: Article[]; pagination: any; settings: any }>>('/articles', {
|
|
||||||
params: { page, per_page: perPage }
|
|
||||||
});
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async approveArticle(articleId: number): Promise<Article> {
|
|
||||||
const response = await axios.post<ApiResponse<Article>>(`/articles/${articleId}/approve`);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async rejectArticle(articleId: number): Promise<Article> {
|
|
||||||
const response = await axios.post<ApiResponse<Article>>(`/articles/${articleId}/reject`);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Feeds endpoints
|
|
||||||
async getFeeds(): Promise<Feed[]> {
|
|
||||||
const response = await axios.get<ApiResponse<{feeds: Feed[], pagination: any}>>('/feeds');
|
|
||||||
return response.data.data.feeds;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createFeed(data: Partial<Feed>): Promise<Feed> {
|
|
||||||
const response = await axios.post<ApiResponse<Feed>>('/feeds', data);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateFeed(id: number, data: Partial<Feed>): Promise<Feed> {
|
|
||||||
const response = await axios.put<ApiResponse<Feed>>(`/feeds/${id}`, data);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteFeed(id: number): Promise<void> {
|
|
||||||
await axios.delete(`/feeds/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggleFeed(id: number): Promise<Feed> {
|
|
||||||
const response = await axios.post<ApiResponse<Feed>>(`/feeds/${id}/toggle`);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings endpoints
|
|
||||||
async getSettings(): Promise<Settings> {
|
|
||||||
const response = await axios.get<ApiResponse<Settings>>('/settings');
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateSettings(data: Partial<Settings>): Promise<Settings> {
|
|
||||||
const response = await axios.put<ApiResponse<Settings>>('/settings', data);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Onboarding endpoints
|
|
||||||
async getOnboardingStatus(): Promise<OnboardingStatus> {
|
|
||||||
const response = await axios.get<ApiResponse<OnboardingStatus>>('/onboarding/status');
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getOnboardingOptions(): Promise<OnboardingOptions> {
|
|
||||||
const response = await axios.get<ApiResponse<OnboardingOptions>>('/onboarding/options');
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPlatformAccount(data: PlatformAccountRequest): Promise<PlatformAccount> {
|
|
||||||
const response = await axios.post<ApiResponse<PlatformAccount>>('/onboarding/platform', data);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createFeedForOnboarding(data: FeedRequest): Promise<Feed> {
|
|
||||||
const response = await axios.post<ApiResponse<Feed>>('/onboarding/feed', data);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createChannelForOnboarding(data: ChannelRequest): Promise<PlatformChannel> {
|
|
||||||
const response = await axios.post<ApiResponse<PlatformChannel>>('/onboarding/channel', data);
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
async skipOnboarding(): Promise<void> {
|
|
||||||
await axios.post('/onboarding/skip');
|
|
||||||
}
|
|
||||||
|
|
||||||
async resetOnboardingSkip(): Promise<void> {
|
|
||||||
await axios.post('/onboarding/reset-skip');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Articles management endpoints
|
|
||||||
async refreshArticles(): Promise<void> {
|
|
||||||
await axios.post('/articles/refresh');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Routes endpoints
|
|
||||||
async getRoutes(): Promise<Route[]> {
|
|
||||||
const response = await axios.get<ApiResponse<Route[]>>('/routing');
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createRoute(data: RouteRequest): Promise<Route> {
|
|
||||||
const response = await axios.post<ApiResponse<Route>>('/routing', data);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateRoute(feedId: number, channelId: number, data: Partial<RouteRequest>): Promise<Route> {
|
|
||||||
const response = await axios.put<ApiResponse<Route>>(`/routing/${feedId}/${channelId}`, data);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteRoute(feedId: number, channelId: number): Promise<void> {
|
|
||||||
await axios.delete(`/routing/${feedId}/${channelId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggleRoute(feedId: number, channelId: number): Promise<Route> {
|
|
||||||
const response = await axios.post<ApiResponse<Route>>(`/routing/${feedId}/${channelId}/toggle`);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keywords endpoints
|
|
||||||
async getKeywords(feedId: number, channelId: number): Promise<Keyword[]> {
|
|
||||||
const response = await axios.get<ApiResponse<Keyword[]>>(`/routing/${feedId}/${channelId}/keywords`);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createKeyword(feedId: number, channelId: number, data: KeywordRequest): Promise<Keyword> {
|
|
||||||
const response = await axios.post<ApiResponse<Keyword>>(`/routing/${feedId}/${channelId}/keywords`, data);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateKeyword(feedId: number, channelId: number, keywordId: number, data: Partial<KeywordRequest>): Promise<Keyword> {
|
|
||||||
const response = await axios.put<ApiResponse<Keyword>>(`/routing/${feedId}/${channelId}/keywords/${keywordId}`, data);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteKeyword(feedId: number, channelId: number, keywordId: number): Promise<void> {
|
|
||||||
await axios.delete(`/routing/${feedId}/${channelId}/keywords/${keywordId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggleKeyword(feedId: number, channelId: number, keywordId: number): Promise<Keyword> {
|
|
||||||
const response = await axios.post<ApiResponse<Keyword>>(`/routing/${feedId}/${channelId}/keywords/${keywordId}/toggle`);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Platform Channels endpoints
|
|
||||||
async getPlatformChannels(): Promise<PlatformChannel[]> {
|
|
||||||
const response = await axios.get<ApiResponse<PlatformChannel[]>>('/platform-channels');
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPlatformChannel(data: Partial<PlatformChannel>): Promise<PlatformChannel> {
|
|
||||||
const response = await axios.post<ApiResponse<PlatformChannel>>('/platform-channels', data);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updatePlatformChannel(id: number, data: Partial<PlatformChannel>): Promise<PlatformChannel> {
|
|
||||||
const response = await axios.put<ApiResponse<PlatformChannel>>(`/platform-channels/${id}`, data);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deletePlatformChannel(id: number): Promise<void> {
|
|
||||||
await axios.delete(`/platform-channels/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async togglePlatformChannel(id: number): Promise<PlatformChannel> {
|
|
||||||
const response = await axios.post<ApiResponse<PlatformChannel>>(`/platform-channels/${id}/toggle`);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Platform Channel-Account management
|
|
||||||
async attachAccountToChannel(channelId: number, data: { platform_account_id: number; is_active?: boolean; priority?: number }): Promise<PlatformChannel> {
|
|
||||||
const response = await axios.post<ApiResponse<PlatformChannel>>(`/platform-channels/${channelId}/accounts`, data);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async detachAccountFromChannel(channelId: number, accountId: number): Promise<PlatformChannel> {
|
|
||||||
const response = await axios.delete<ApiResponse<PlatformChannel>>(`/platform-channels/${channelId}/accounts/${accountId}`);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateChannelAccountRelation(channelId: number, accountId: number, data: { is_active?: boolean; priority?: number }): Promise<PlatformChannel> {
|
|
||||||
const response = await axios.put<ApiResponse<PlatformChannel>>(`/platform-channels/${channelId}/accounts/${accountId}`, data);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Platform Accounts endpoints
|
|
||||||
async getPlatformAccounts(): Promise<PlatformAccount[]> {
|
|
||||||
const response = await axios.get<ApiResponse<PlatformAccount[]>>('/platform-accounts');
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deletePlatformAccount(id: number): Promise<void> {
|
|
||||||
await axios.delete(`/platform-accounts/${id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const apiClient = new ApiClient();
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
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({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
retry: 1,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<BrowserRouter>
|
|
||||||
<OnboardingProvider>
|
|
||||||
<App />
|
|
||||||
</OnboardingProvider>
|
|
||||||
</BrowserRouter>
|
|
||||||
</QueryClientProvider>
|
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
|
||||||
|
|
@ -1,281 +0,0 @@
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { CheckCircle, XCircle, ExternalLink, Calendar, Tag, FileText, RefreshCw } from 'lucide-react';
|
|
||||||
import { apiClient, type Article } from '../lib/api';
|
|
||||||
|
|
||||||
const Articles: React.FC = () => {
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
|
||||||
queryKey: ['articles', page],
|
|
||||||
queryFn: () => apiClient.getArticles(page),
|
|
||||||
});
|
|
||||||
|
|
||||||
const approveMutation = useMutation({
|
|
||||||
mutationFn: (articleId: number) => apiClient.approveArticle(articleId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const rejectMutation = useMutation({
|
|
||||||
mutationFn: (articleId: number) => apiClient.rejectArticle(articleId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const refreshMutation = useMutation({
|
|
||||||
mutationFn: () => apiClient.refreshArticles(),
|
|
||||||
onSuccess: () => {
|
|
||||||
// Keep the button in "refreshing" state for 10 seconds
|
|
||||||
setIsRefreshing(true);
|
|
||||||
|
|
||||||
// Refresh the articles list after 10 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}, 10000);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
// Reset the refreshing state on error
|
|
||||||
setIsRefreshing(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleApprove = (articleId: number) => {
|
|
||||||
approveMutation.mutate(articleId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReject = (articleId: number) => {
|
|
||||||
rejectMutation.mutate(articleId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
|
||||||
refreshMutation.mutate();
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadge = (article: Article) => {
|
|
||||||
// Show "Published" status if the article has been published
|
|
||||||
if (article.is_published) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
|
||||||
<ExternalLink className="h-3 w-3 mr-1" />
|
|
||||||
Published
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise show the approval status
|
|
||||||
switch (article.approval_status) {
|
|
||||||
case 'approved':
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
|
||||||
<CheckCircle className="h-3 w-3 mr-1" />
|
|
||||||
Approved
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
case 'rejected':
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
|
||||||
<XCircle className="h-3 w-3 mr-1" />
|
|
||||||
Rejected
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
|
||||||
<Calendar className="h-3 w-3 mr-1" />
|
|
||||||
Pending
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="animate-pulse">
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-1/4 mb-6"></div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[...Array(5)].map((_, i) => (
|
|
||||||
<div key={i} className="bg-white p-6 rounded-lg shadow">
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
|
|
||||||
<div className="h-3 bg-gray-200 rounded w-1/2 mb-4"></div>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-20"></div>
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-20"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
|
||||||
<p className="text-red-600">Failed to load articles</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const articles = data?.articles || [];
|
|
||||||
const pagination = data?.pagination;
|
|
||||||
const settings = data?.settings;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="mb-8 flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Articles</h1>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
Manage and review articles from your feeds
|
|
||||||
</p>
|
|
||||||
{settings?.publishing_approvals_enabled && (
|
|
||||||
<div className="mt-2 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
|
||||||
<Tag className="h-3 w-3 mr-1" />
|
|
||||||
Approval system enabled
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={refreshMutation.isPending || isRefreshing}
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${(refreshMutation.isPending || isRefreshing) ? 'animate-spin' : ''}`} />
|
|
||||||
{(refreshMutation.isPending || isRefreshing) ? 'Refreshing...' : 'Refresh'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{articles.map((article: Article) => (
|
|
||||||
<div key={article.id} className="bg-white rounded-lg shadow p-6">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
|
||||||
{article.title || 'Untitled Article'}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
|
|
||||||
{article.description || 'No description available'}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
|
||||||
<span>Feed: {article.feed?.name || 'Unknown'}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{new Date(article.created_at).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-3 ml-4">
|
|
||||||
{getStatusBadge(article)}
|
|
||||||
{article.url && (
|
|
||||||
<a
|
|
||||||
href={article.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-md"
|
|
||||||
title="View original article"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{article.approval_status === 'pending' && settings?.publishing_approvals_enabled && (
|
|
||||||
<div className="mt-4 flex space-x-3">
|
|
||||||
<button
|
|
||||||
onClick={() => handleApprove(article.id)}
|
|
||||||
disabled={approveMutation.isPending}
|
|
||||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<CheckCircle className="h-4 w-4 mr-1" />
|
|
||||||
Approve
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleReject(article.id)}
|
|
||||||
disabled={rejectMutation.isPending}
|
|
||||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<XCircle className="h-4 w-4 mr-1" />
|
|
||||||
Reject
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{articles.length === 0 && (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<FileText className="mx-auto h-12 w-12 text-gray-400" />
|
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No articles</h3>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
No articles have been fetched yet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{pagination && pagination.last_page > 1 && (
|
|
||||||
<div className="flex items-center justify-between bg-white px-4 py-3 border-t border-gray-200 sm:px-6 rounded-lg shadow">
|
|
||||||
<div className="flex-1 flex justify-between sm:hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(page - 1)}
|
|
||||||
disabled={page <= 1}
|
|
||||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(page + 1)}
|
|
||||||
disabled={page >= pagination.last_page}
|
|
||||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-700">
|
|
||||||
Showing{' '}
|
|
||||||
<span className="font-medium">{pagination.from}</span> to{' '}
|
|
||||||
<span className="font-medium">{pagination.to}</span> of{' '}
|
|
||||||
<span className="font-medium">{pagination.total}</span> results
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(page - 1)}
|
|
||||||
disabled={page <= 1}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
|
|
||||||
{page} of {pagination.last_page}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(page + 1)}
|
|
||||||
disabled={page >= pagination.last_page}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Articles;
|
|
||||||
|
|
@ -1,311 +0,0 @@
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { Hash, Globe, ToggleLeft, ToggleRight, Users, Settings, ExternalLink, Link2, X } from 'lucide-react';
|
|
||||||
import { apiClient } from '../lib/api';
|
|
||||||
|
|
||||||
const Channels: React.FC = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [showAccountModal, setShowAccountModal] = useState<{ channelId: number; channelName: string } | null>(null);
|
|
||||||
|
|
||||||
const { data: channels, isLoading, error } = useQuery({
|
|
||||||
queryKey: ['platformChannels'],
|
|
||||||
queryFn: () => apiClient.getPlatformChannels(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: accounts } = useQuery({
|
|
||||||
queryKey: ['platformAccounts'],
|
|
||||||
queryFn: () => apiClient.getPlatformAccounts(),
|
|
||||||
enabled: !!showAccountModal,
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleMutation = useMutation({
|
|
||||||
mutationFn: (channelId: number) => apiClient.togglePlatformChannel(channelId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['platformChannels'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const attachAccountMutation = useMutation({
|
|
||||||
mutationFn: ({ channelId, accountId }: { channelId: number; accountId: number }) =>
|
|
||||||
apiClient.attachAccountToChannel(channelId, { platform_account_id: accountId }),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['platformChannels'] });
|
|
||||||
setShowAccountModal(null);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const detachAccountMutation = useMutation({
|
|
||||||
mutationFn: ({ channelId, accountId }: { channelId: number; accountId: number }) =>
|
|
||||||
apiClient.detachAccountFromChannel(channelId, accountId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['platformChannels'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleToggle = (channelId: number) => {
|
|
||||||
toggleMutation.mutate(channelId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAttachAccount = (channelId: number, accountId: number) => {
|
|
||||||
attachAccountMutation.mutate({ channelId, accountId });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDetachAccount = (channelId: number, accountId: number) => {
|
|
||||||
detachAccountMutation.mutate({ channelId, accountId });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="animate-pulse">
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-1/4 mb-6"></div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{[...Array(3)].map((_, i) => (
|
|
||||||
<div key={i} className="bg-white p-6 rounded-lg shadow">
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
|
|
||||||
<div className="h-3 bg-gray-200 rounded w-1/2 mb-4"></div>
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-20"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-red-800">
|
|
||||||
Error loading channels
|
|
||||||
</h3>
|
|
||||||
<div className="mt-2 text-sm text-red-700">
|
|
||||||
<p>There was an error loading the platform channels. Please try again.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="sm:flex sm:items-center sm:justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Platform Channels</h1>
|
|
||||||
<p className="mt-2 text-sm text-gray-700">
|
|
||||||
Manage your publishing channels and their associated accounts.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!channels || channels.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<Hash className="mx-auto h-12 w-12 text-gray-400" />
|
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No channels</h3>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
Get started by creating a new platform channel.
|
|
||||||
</p>
|
|
||||||
<div className="mt-6">
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Channels are created during onboarding. If you need to create more channels, please go through the onboarding process again.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{channels.map((channel) => (
|
|
||||||
<div key={channel.id} className="bg-white overflow-hidden shadow rounded-lg">
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Hash className="h-8 w-8 text-blue-500" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 flex-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 truncate">
|
|
||||||
{channel.display_name || channel.name}
|
|
||||||
</h3>
|
|
||||||
<a
|
|
||||||
href={`https://${channel.platform_instance?.url?.replace(/^https?:\/\//, '')}/c/${channel.name}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-gray-400 hover:text-gray-600 flex-shrink-0"
|
|
||||||
title="View community on Lemmy"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggle(channel.id)}
|
|
||||||
disabled={toggleMutation.isPending}
|
|
||||||
className="ml-2 flex-shrink-0"
|
|
||||||
title={channel.is_active ? 'Deactivate channel' : 'Activate channel'}
|
|
||||||
>
|
|
||||||
{channel.is_active ? (
|
|
||||||
<ToggleRight className="h-6 w-6 text-green-500 hover:text-green-600" />
|
|
||||||
) : (
|
|
||||||
<ToggleLeft className="h-6 w-6 text-gray-400 hover:text-gray-500" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
!{channel.name}@{channel.platform_instance?.url?.replace(/^https?:\/\//, '')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<div className="flex items-center text-sm text-gray-500">
|
|
||||||
<Globe className="flex-shrink-0 mr-1.5 h-4 w-4" />
|
|
||||||
Channel ID: {channel.channel_id}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{channel.description && (
|
|
||||||
<p className="mt-2 text-sm text-gray-600">{channel.description}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center text-sm text-gray-500">
|
|
||||||
<Users className="flex-shrink-0 mr-1.5 h-4 w-4" />
|
|
||||||
<span>
|
|
||||||
{channel.platform_accounts?.length || 0} account{(channel.platform_accounts?.length || 0) !== 1 ? 's' : ''} linked
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAccountModal({ channelId: channel.id, channelName: channel.display_name || channel.name })}
|
|
||||||
className="text-blue-500 hover:text-blue-600"
|
|
||||||
title="Manage accounts"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{channel.platform_accounts && channel.platform_accounts.length > 0 && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{channel.platform_accounts.map((account) => (
|
|
||||||
<div key={account.id} className="flex items-center justify-between text-xs bg-gray-50 rounded px-2 py-1">
|
|
||||||
<span className="text-gray-700">@{account.username}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDetachAccount(channel.id, account.id)}
|
|
||||||
disabled={detachAccountMutation.isPending}
|
|
||||||
className="text-red-400 hover:text-red-600"
|
|
||||||
title="Remove account"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
channel.is_active
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-gray-100 text-gray-800'
|
|
||||||
}`}>
|
|
||||||
{channel.is_active ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
Created {new Date(channel.created_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Account Management Modal */}
|
|
||||||
{showAccountModal && (
|
|
||||||
<div className="fixed inset-0" style={{ zIndex: 9999 }} onClick={() => setShowAccountModal(null)}>
|
|
||||||
<div
|
|
||||||
className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg shadow-xl p-6"
|
|
||||||
style={{ width: '500px', maxHeight: '80vh', overflowY: 'auto' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center mb-4">
|
|
||||||
<div className="flex-shrink-0 flex items-center justify-center h-10 w-10 rounded-full bg-blue-100">
|
|
||||||
<Link2 className="h-6 w-6 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
|
||||||
Manage Accounts for {showAccountModal.channelName}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-sm text-gray-500 mb-4">
|
|
||||||
Select a platform account to link to this channel:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{accounts && accounts.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{accounts
|
|
||||||
.filter(account => !channels?.find(c => c.id === showAccountModal.channelId)?.platform_accounts?.some(pa => pa.id === account.id))
|
|
||||||
.map((account) => (
|
|
||||||
<button
|
|
||||||
key={account.id}
|
|
||||||
onClick={() => handleAttachAccount(showAccountModal.channelId, account.id)}
|
|
||||||
disabled={attachAccountMutation.isPending}
|
|
||||||
className="w-full text-left px-3 py-2 border border-gray-200 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-900">@{account.username}</p>
|
|
||||||
{account.display_name && (
|
|
||||||
<p className="text-xs text-gray-500">{account.display_name}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
account.is_active
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-gray-100 text-gray-800'
|
|
||||||
}`}>
|
|
||||||
{account.is_active ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{accounts.filter(account => !channels?.find(c => c.id === showAccountModal.channelId)?.platform_accounts?.some(pa => pa.id === account.id)).length === 0 && (
|
|
||||||
<p className="text-sm text-gray-500 text-center py-4">
|
|
||||||
All available accounts are already linked to this channel.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-500 text-center py-4">
|
|
||||||
No platform accounts available. Create a platform account first.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end pt-4 border-t">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAccountModal(null)}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Channels;
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { FileText, Rss, Users, Route, TrendingUp, Clock, CheckCircle } from 'lucide-react';
|
|
||||||
import { apiClient } from '../lib/api';
|
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
|
||||||
const { data: stats, isLoading, error } = useQuery({
|
|
||||||
queryKey: ['dashboard-stats'],
|
|
||||||
queryFn: () => apiClient.getDashboardStats(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="animate-pulse">
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-1/4 mb-6"></div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
||||||
{[...Array(4)].map((_, i) => (
|
|
||||||
<div key={i} className="bg-white p-6 rounded-lg shadow">
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-1/2"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
|
||||||
<p className="text-red-600">Failed to load dashboard data</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const articleStats = stats?.article_stats;
|
|
||||||
const systemStats = stats?.system_stats;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
Overview of your feed management system
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* System Statistics */}
|
|
||||||
<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">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Rss className="h-8 w-8 text-orange-500" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<p className="text-sm font-medium text-gray-500">Active Feeds</p>
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
|
||||||
{systemStats?.active_feeds || 0}
|
|
||||||
<span className="text-sm font-normal text-gray-500">
|
|
||||||
/{systemStats?.total_feeds || 0}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Users className="h-8 w-8 text-indigo-500" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<p className="text-sm font-medium text-gray-500">Platform Accounts</p>
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
|
||||||
{systemStats?.active_platform_accounts || 0}
|
|
||||||
<span className="text-sm font-normal text-gray-500">
|
|
||||||
/{systemStats?.total_platform_accounts || 0}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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-cyan-500" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<p className="text-sm font-medium text-gray-500">Platform Channels</p>
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
|
||||||
{systemStats?.active_platform_channels || 0}
|
|
||||||
<span className="text-sm font-normal text-gray-500">
|
|
||||||
/{systemStats?.total_platform_channels || 0}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Route className="h-8 w-8 text-pink-500" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<p className="text-sm font-medium text-gray-500">Active Routes</p>
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
|
||||||
{systemStats?.active_routes || 0}
|
|
||||||
<span className="text-sm font-normal text-gray-500">
|
|
||||||
/{systemStats?.total_routes || 0}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Dashboard;
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { Rss, Globe, ToggleLeft, ToggleRight, ExternalLink } from 'lucide-react';
|
|
||||||
import { apiClient, type Feed } from '../lib/api';
|
|
||||||
|
|
||||||
const Feeds: React.FC = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data: feeds, isLoading, error } = useQuery({
|
|
||||||
queryKey: ['feeds'],
|
|
||||||
queryFn: () => apiClient.getFeeds(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleMutation = useMutation({
|
|
||||||
mutationFn: (feedId: number) => apiClient.toggleFeed(feedId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['feeds'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleToggle = (feedId: number) => {
|
|
||||||
toggleMutation.mutate(feedId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTypeIcon = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'rss':
|
|
||||||
return <Rss className="h-5 w-5 text-orange-500" />;
|
|
||||||
case 'website':
|
|
||||||
return <Globe className="h-5 w-5 text-blue-500" />;
|
|
||||||
default:
|
|
||||||
return <Rss className="h-5 w-5 text-gray-500" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="animate-pulse">
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-1/4 mb-6"></div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{[...Array(6)].map((_, i) => (
|
|
||||||
<div key={i} className="bg-white p-6 rounded-lg shadow">
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
|
|
||||||
<div className="h-3 bg-gray-200 rounded w-1/2 mb-4"></div>
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-20"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
|
||||||
<p className="text-red-600">Failed to load feeds</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Feeds</h1>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
Manage your RSS feeds and website sources
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{feeds?.map((feed: Feed) => (
|
|
||||||
<div key={feed.id} className="bg-white rounded-lg shadow p-6">
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
{getTypeIcon(feed.type)}
|
|
||||||
<h3 className="ml-2 text-lg font-medium text-gray-900 truncate">
|
|
||||||
{feed.name}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggle(feed.id)}
|
|
||||||
disabled={toggleMutation.isPending}
|
|
||||||
className="flex-shrink-0"
|
|
||||||
>
|
|
||||||
{feed.is_active ? (
|
|
||||||
<ToggleRight className="h-6 w-6 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<ToggleLeft className="h-6 w-6 text-gray-300" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-gray-600 mb-4 line-clamp-2">
|
|
||||||
{feed.description || 'No description provided'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
feed.is_active
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-gray-100 text-gray-800'
|
|
||||||
}`}>
|
|
||||||
{feed.is_active ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
|
||||||
{feed.type.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={feed.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="p-1 text-gray-400 hover:text-gray-600 rounded"
|
|
||||||
title="Visit feed URL"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 text-xs text-gray-500">
|
|
||||||
Added {new Date(feed.created_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{feeds?.length === 0 && (
|
|
||||||
<div className="col-span-full text-center py-12">
|
|
||||||
<Rss className="mx-auto h-12 w-12 text-gray-400" />
|
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No feeds</h3>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
Get started by adding your first feed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Feeds;
|
|
||||||
|
|
@ -1,452 +0,0 @@
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { Plus, Edit2, Trash2, ToggleLeft, ToggleRight, ExternalLink, CheckCircle, XCircle, Tag } from 'lucide-react';
|
|
||||||
import { apiClient, type Route, type RouteRequest, type Feed, type PlatformChannel } from '../lib/api';
|
|
||||||
import KeywordManager from '../components/KeywordManager';
|
|
||||||
|
|
||||||
const Routes: React.FC = () => {
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
||||||
const [editingRoute, setEditingRoute] = useState<Route | null>(null);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data: routes, isLoading, error } = useQuery({
|
|
||||||
queryKey: ['routes'],
|
|
||||||
queryFn: () => apiClient.getRoutes(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: feeds } = useQuery({
|
|
||||||
queryKey: ['feeds'],
|
|
||||||
queryFn: () => apiClient.getFeeds(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: onboardingOptions } = useQuery({
|
|
||||||
queryKey: ['onboarding-options'],
|
|
||||||
queryFn: () => apiClient.getOnboardingOptions(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleMutation = useMutation({
|
|
||||||
mutationFn: ({ feedId, channelId }: { feedId: number; channelId: number }) =>
|
|
||||||
apiClient.toggleRoute(feedId, channelId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['routes'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: ({ feedId, channelId }: { feedId: number; channelId: number }) =>
|
|
||||||
apiClient.deleteRoute(feedId, channelId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['routes'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: (data: RouteRequest) => apiClient.createRoute(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['routes'] });
|
|
||||||
setShowCreateModal(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
|
||||||
mutationFn: ({ feedId, channelId, data }: { feedId: number; channelId: number; data: Partial<RouteRequest> }) =>
|
|
||||||
apiClient.updateRoute(feedId, channelId, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['routes'] });
|
|
||||||
setEditingRoute(null);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleToggle = (route: Route) => {
|
|
||||||
toggleMutation.mutate({ feedId: route.feed_id, channelId: route.platform_channel_id });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (route: Route) => {
|
|
||||||
if (confirm('Are you sure you want to delete this route?')) {
|
|
||||||
deleteMutation.mutate({ feedId: route.feed_id, channelId: route.platform_channel_id });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="animate-pulse">
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-1/4 mb-6"></div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[...Array(3)].map((_, i) => (
|
|
||||||
<div key={i} className="bg-white p-6 rounded-lg shadow">
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
|
|
||||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
|
||||||
<p className="text-red-600">Failed to load routes</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="mb-8 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Routes</h1>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
Manage connections between your feeds and channels
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Create Route
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{routes && routes.length > 0 ? (
|
|
||||||
routes.map((route: Route) => (
|
|
||||||
<div key={`${route.feed_id}-${route.platform_channel_id}`} className="bg-white rounded-lg shadow p-6">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center space-x-3 mb-2">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
|
||||||
{route.feed?.name} → {route.platform_channel?.display_name || route.platform_channel?.name}
|
|
||||||
</h3>
|
|
||||||
{route.is_active ? (
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
|
||||||
<CheckCircle className="h-3 w-3 mr-1" />
|
|
||||||
Active
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
|
||||||
<XCircle className="h-3 w-3 mr-1" />
|
|
||||||
Inactive
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-4 text-sm text-gray-600">
|
|
||||||
<span>Priority: {route.priority}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>Feed: {route.feed?.name}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>Channel: {route.platform_channel?.display_name || route.platform_channel?.name}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>Created: {new Date(route.created_at).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
{route.platform_channel?.description && (
|
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
|
||||||
{route.platform_channel.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{route.keywords && route.keywords.length > 0 && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<div className="flex items-center space-x-2 mb-2">
|
|
||||||
<Tag className="h-4 w-4 text-gray-500" />
|
|
||||||
<span className="text-sm font-medium text-gray-700">Keywords</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{route.keywords.map((keyword) => (
|
|
||||||
<span
|
|
||||||
key={keyword.id}
|
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
keyword.is_active
|
|
||||||
? 'bg-blue-100 text-blue-800'
|
|
||||||
: 'bg-gray-100 text-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{keyword.keyword}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(!route.keywords || route.keywords.length === 0) && (
|
|
||||||
<div className="mt-3 text-sm text-gray-400 italic">
|
|
||||||
No keyword filters - matches all articles
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 ml-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingRoute(route)}
|
|
||||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-md"
|
|
||||||
title="Edit route"
|
|
||||||
>
|
|
||||||
<Edit2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggle(route)}
|
|
||||||
disabled={toggleMutation.isPending}
|
|
||||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-md"
|
|
||||||
title={route.is_active ? 'Deactivate route' : 'Activate route'}
|
|
||||||
>
|
|
||||||
{route.is_active ? (
|
|
||||||
<ToggleRight className="h-4 w-4 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<ToggleLeft className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(route)}
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
className="p-2 text-gray-400 hover:text-red-600 rounded-md"
|
|
||||||
title="Delete route"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="mx-auto h-12 w-12 text-gray-400">
|
|
||||||
<ExternalLink className="h-12 w-12" />
|
|
||||||
</div>
|
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No routes</h3>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
Get started by creating a new route to connect feeds with channels.
|
|
||||||
</p>
|
|
||||||
<div className="mt-6">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Create Route
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create Route Modal */}
|
|
||||||
{showCreateModal && (
|
|
||||||
<CreateRouteModal
|
|
||||||
feeds={feeds || []}
|
|
||||||
channels={onboardingOptions?.platform_channels || []}
|
|
||||||
onClose={() => setShowCreateModal(false)}
|
|
||||||
onSubmit={(data) => createMutation.mutate(data)}
|
|
||||||
isLoading={createMutation.isPending}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Edit Route Modal */}
|
|
||||||
{editingRoute && (
|
|
||||||
<EditRouteModal
|
|
||||||
route={editingRoute}
|
|
||||||
onClose={() => setEditingRoute(null)}
|
|
||||||
onSubmit={(data) => updateMutation.mutate({
|
|
||||||
feedId: editingRoute.feed_id,
|
|
||||||
channelId: editingRoute.platform_channel_id,
|
|
||||||
data
|
|
||||||
})}
|
|
||||||
isLoading={updateMutation.isPending}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CreateRouteModalProps {
|
|
||||||
feeds: Feed[];
|
|
||||||
channels: PlatformChannel[];
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (data: RouteRequest) => void;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CreateRouteModal: React.FC<CreateRouteModalProps> = ({ feeds, channels, onClose, onSubmit, isLoading }) => {
|
|
||||||
const [formData, setFormData] = useState<RouteRequest>({
|
|
||||||
feed_id: 0,
|
|
||||||
platform_channel_id: 0,
|
|
||||||
priority: 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 overflow-y-auto" style={{ zIndex: 9999 }}>
|
|
||||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
||||||
<div className="fixed inset-0 transition-opacity" onClick={onClose}></div>
|
|
||||||
|
|
||||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen">​</span>
|
|
||||||
|
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Create New Route</h3>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="feed_id" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Feed
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="feed_id"
|
|
||||||
value={formData.feed_id || ''}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, feed_id: parseInt(e.target.value) }))}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Select a feed</option>
|
|
||||||
{feeds.map((feed) => (
|
|
||||||
<option key={feed.id} value={feed.id}>
|
|
||||||
{feed.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="platform_channel_id" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Channel
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="platform_channel_id"
|
|
||||||
value={formData.platform_channel_id || ''}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, platform_channel_id: parseInt(e.target.value) }))}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Select a channel</option>
|
|
||||||
{channels.map((channel) => (
|
|
||||||
<option key={channel.id} value={channel.id}>
|
|
||||||
{channel.display_name || channel.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Priority
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="priority"
|
|
||||||
min="0"
|
|
||||||
value={formData.priority || 50}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, priority: parseInt(e.target.value) }))}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">Higher priority routes are processed first</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isLoading ? 'Creating...' : 'Create Route'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface EditRouteModalProps {
|
|
||||||
route: Route;
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (data: Partial<RouteRequest>) => void;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EditRouteModal: React.FC<EditRouteModalProps> = ({ route, onClose, onSubmit, isLoading }) => {
|
|
||||||
const [priority, setPriority] = useState(route.priority || 50);
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit({ priority });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0" style={{ zIndex: 9999 }} onClick={onClose}>
|
|
||||||
<div
|
|
||||||
className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg shadow-xl p-6"
|
|
||||||
style={{ width: '500px', maxHeight: '80vh', overflowY: 'auto' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Edit Route</h3>
|
|
||||||
<div className="mb-4 p-3 bg-gray-50 rounded-md">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
<strong>Feed:</strong> {route.feed?.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
<strong>Channel:</strong> {route.platform_channel?.display_name || route.platform_channel?.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Priority
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="priority"
|
|
||||||
min="0"
|
|
||||||
value={priority}
|
|
||||||
onChange={(e) => setPriority(parseInt(e.target.value))}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">Higher priority routes are processed first</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<KeywordManager
|
|
||||||
feedId={route.feed_id}
|
|
||||||
channelId={route.platform_channel_id}
|
|
||||||
keywords={route.keywords || []}
|
|
||||||
onKeywordChange={() => {
|
|
||||||
// Keywords will be refreshed via React Query invalidation
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 pt-4 border-t">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isLoading ? 'Updating...' : 'Update Route'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Routes;
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { Settings as SettingsIcon, ToggleLeft, ToggleRight } from 'lucide-react';
|
|
||||||
import { apiClient } from '../lib/api';
|
|
||||||
|
|
||||||
const Settings: React.FC = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data: settings, isLoading, error } = useQuery({
|
|
||||||
queryKey: ['settings'],
|
|
||||||
queryFn: () => apiClient.getSettings(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
|
||||||
mutationFn: (data: any) => apiClient.updateSettings(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleToggle = (key: string, value: boolean) => {
|
|
||||||
updateMutation.mutate({ [key]: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="animate-pulse">
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-1/4 mb-6"></div>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow">
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-1/3 mb-4"></div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="h-12 bg-gray-200 rounded"></div>
|
|
||||||
<div className="h-12 bg-gray-200 rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
|
||||||
<p className="text-red-600">Failed to load settings</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
Configure your system preferences
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Article Processing Settings */}
|
|
||||||
<div className="bg-white rounded-lg shadow">
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
|
||||||
<h2 className="text-lg font-medium text-gray-900 flex items-center">
|
|
||||||
<SettingsIcon className="h-5 w-5 mr-2" />
|
|
||||||
Article Processing
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
Control how articles are processed and handled
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-4 space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-gray-900">
|
|
||||||
Article Processing Enabled
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Enable automatic fetching and processing of articles from feeds
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggle('article_processing_enabled', !settings?.article_processing_enabled)}
|
|
||||||
disabled={updateMutation.isPending}
|
|
||||||
className="flex-shrink-0"
|
|
||||||
>
|
|
||||||
{settings?.article_processing_enabled ? (
|
|
||||||
<ToggleRight className="h-6 w-6 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<ToggleLeft className="h-6 w-6 text-gray-300" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-gray-900">
|
|
||||||
Publishing Approvals Required
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Require manual approval before articles are published to platforms
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggle('enable_publishing_approvals', !settings?.publishing_approvals_enabled)}
|
|
||||||
disabled={updateMutation.isPending}
|
|
||||||
className="flex-shrink-0"
|
|
||||||
>
|
|
||||||
{settings?.publishing_approvals_enabled ? (
|
|
||||||
<ToggleRight className="h-6 w-6 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<ToggleLeft className="h-6 w-6 text-gray-300" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status indicator */}
|
|
||||||
{updateMutation.isPending && (
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600 mr-2"></div>
|
|
||||||
<p className="text-blue-600 text-sm">Updating settings...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{updateMutation.isError && (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
|
||||||
<p className="text-red-600 text-sm">Failed to update settings. Please try again.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{updateMutation.isSuccess && (
|
|
||||||
<div className="bg-green-50 border border-green-200 rounded-md p-4">
|
|
||||||
<p className="text-green-600 text-sm">Settings updated successfully!</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Settings;
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface OnboardingLayoutProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const OnboardingLayout: React.FC<OnboardingLayoutProps> = ({ children }) => {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
|
||||||
<div className="max-w-lg w-full bg-white rounded-lg shadow-md p-8">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OnboardingLayout;
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
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 RouteStep from './steps/RouteStep';
|
|
||||||
import CompleteStep from './steps/CompleteStep';
|
|
||||||
|
|
||||||
const OnboardingWizard: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<OnboardingLayout>
|
|
||||||
<Routes>
|
|
||||||
<Route index element={<WelcomeStep />} />
|
|
||||||
<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>
|
|
||||||
</OnboardingLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OnboardingWizard;
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
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,
|
|
||||||
language_id: 0,
|
|
||||||
description: ''
|
|
||||||
});
|
|
||||||
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
|
||||||
|
|
||||||
// Get onboarding options (languages, platform instances)
|
|
||||||
const { data: options, isLoading: optionsLoading } = useQuery({
|
|
||||||
queryKey: ['onboarding-options'],
|
|
||||||
queryFn: () => apiClient.getOnboardingOptions()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch existing channels to pre-fill form when going back
|
|
||||||
const { data: channels } = useQuery({
|
|
||||||
queryKey: ['platform-channels'],
|
|
||||||
queryFn: () => apiClient.getPlatformChannels(),
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pre-fill form with existing data
|
|
||||||
useEffect(() => {
|
|
||||||
if (channels && channels.length > 0) {
|
|
||||||
const firstChannel = channels[0];
|
|
||||||
setFormData({
|
|
||||||
name: firstChannel.name || '',
|
|
||||||
platform_instance_id: firstChannel.platform_instance_id || 0,
|
|
||||||
language_id: firstChannel.language_id || 0,
|
|
||||||
description: firstChannel.description || ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [channels]);
|
|
||||||
|
|
||||||
const createChannelMutation = useMutation({
|
|
||||||
mutationFn: (data: ChannelRequest) => apiClient.createChannelForOnboarding(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
// Invalidate onboarding status cache
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
|
|
||||||
navigate('/onboarding/route');
|
|
||||||
},
|
|
||||||
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 <div className="text-center">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Configure Your Channel</h1>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Set up a Lemmy community where articles will be posted
|
|
||||||
</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-blue-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">3</div>
|
|
||||||
<div className="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">4</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="name" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Community Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="name"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => 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
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">Enter the community name (without the @ or instance)</p>
|
|
||||||
{errors.name && (
|
|
||||||
<p className="text-red-600 text-sm mt-1">{errors.name[0]}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="platform_instance_id" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Platform Instance
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="platform_instance_id"
|
|
||||||
value={formData.platform_instance_id}
|
|
||||||
onChange={(e) => handleChange('platform_instance_id', parseInt(e.target.value))}
|
|
||||||
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 platform instance</option>
|
|
||||||
{options?.platform_instances.filter(instance => instance.is_active).map((instance: PlatformInstance) => (
|
|
||||||
<option key={instance.id} value={instance.id}>
|
|
||||||
{instance.name} ({instance.url})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{errors.platform_instance_id && (
|
|
||||||
<p className="text-red-600 text-sm mt-1">{errors.platform_instance_id[0]}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="language_id" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Language
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="language_id"
|
|
||||||
value={formData.language_id}
|
|
||||||
onChange={(e) => handleChange('language_id', parseInt(e.target.value))}
|
|
||||||
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 language</option>
|
|
||||||
{options?.languages.map((language: Language) => (
|
|
||||||
<option key={language.id} value={language.id}>
|
|
||||||
{language.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{errors.language_id && (
|
|
||||||
<p className="text-red-600 text-sm mt-1">{errors.language_id[0]}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Description (Optional)
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="description"
|
|
||||||
rows={3}
|
|
||||||
value={formData.description || ''}
|
|
||||||
onChange={(e) => handleChange('description', e.target.value)}
|
|
||||||
placeholder="Brief description of this channel"
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
{errors.description && (
|
|
||||||
<p className="text-red-600 text-sm mt-1">{errors.description[0]}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<Link
|
|
||||||
to="/onboarding/feed"
|
|
||||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200"
|
|
||||||
>
|
|
||||||
← Back
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={createChannelMutation.isPending}
|
|
||||||
className="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{createChannelMutation.isPending ? 'Creating...' : 'Continue'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChannelStep;
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
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 invalidate cache and navigate to dashboard even if completion fails
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
|
|
||||||
navigate('/dashboard');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleFinish = () => {
|
|
||||||
completeOnboardingMutation.mutate();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="w-16 h-16 bg-green-500 text-white rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Setup Complete!</h1>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
Great! You've successfully configured FFR. Your feeds will now be monitored and articles will be automatically posted to your configured channels.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress indicator */}
|
|
||||||
<div className="flex justify-center mb-8 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-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">✓</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 mb-8">
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
||||||
<h3 className="font-semibold text-blue-900 mb-2">What happens next?</h3>
|
|
||||||
<ul className="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 other sections</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
|
||||||
<h3 className="font-semibold text-yellow-900 mb-2">Want more control?</h3>
|
|
||||||
<p className="text-sm text-yellow-800 text-left mb-2">
|
|
||||||
You can add more feeds, channels, and configure settings from the dashboard.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<button
|
|
||||||
onClick={handleFinish}
|
|
||||||
disabled={completeOnboardingMutation.isPending}
|
|
||||||
className="w-full bg-blue-600 text-white py-3 px-4 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{completeOnboardingMutation.isPending ? 'Finishing...' : 'Go to Dashboard'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
<Link to="/articles" className="hover:text-blue-600">View Articles</Link>
|
|
||||||
{' • '}
|
|
||||||
<Link to="/feeds" className="hover:text-blue-600">Manage Feeds</Link>
|
|
||||||
{' • '}
|
|
||||||
<Link to="/settings" className="hover:text-blue-600">Settings</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CompleteStep;
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
||||||
import { apiClient, type FeedRequest, type Language, type FeedProvider } from '../../../lib/api';
|
|
||||||
|
|
||||||
const FeedStep: React.FC = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [formData, setFormData] = useState<FeedRequest>({
|
|
||||||
name: '',
|
|
||||||
provider: 'vrt',
|
|
||||||
language_id: 0,
|
|
||||||
description: ''
|
|
||||||
});
|
|
||||||
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
|
||||||
|
|
||||||
// Get onboarding options (languages)
|
|
||||||
const { data: options, isLoading: optionsLoading } = useQuery({
|
|
||||||
queryKey: ['onboarding-options'],
|
|
||||||
queryFn: () => apiClient.getOnboardingOptions()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch existing feeds to pre-fill form when going back
|
|
||||||
const { data: feeds } = useQuery({
|
|
||||||
queryKey: ['feeds'],
|
|
||||||
queryFn: () => apiClient.getFeeds(),
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pre-fill form with existing data
|
|
||||||
useEffect(() => {
|
|
||||||
if (feeds && feeds.length > 0) {
|
|
||||||
const firstFeed = feeds[0];
|
|
||||||
setFormData({
|
|
||||||
name: firstFeed.name || '',
|
|
||||||
provider: (firstFeed.provider === 'vrt' || firstFeed.provider === 'belga') ? firstFeed.provider : 'vrt',
|
|
||||||
language_id: firstFeed.language_id ?? 0,
|
|
||||||
description: firstFeed.description || ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [feeds]);
|
|
||||||
|
|
||||||
const createFeedMutation = useMutation({
|
|
||||||
mutationFn: (data: FeedRequest) => apiClient.createFeedForOnboarding(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
navigate('/onboarding/channel');
|
|
||||||
},
|
|
||||||
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({});
|
|
||||||
createFeedMutation.mutate(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (field: keyof FeedRequest, 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 <div className="text-center">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Add Your First Feed</h1>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Choose from our supported news providers to monitor for new articles
|
|
||||||
</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-blue-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">2</div>
|
|
||||||
<div className="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">3</div>
|
|
||||||
<div className="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">4</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="name" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Feed Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="name"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => handleChange('name', e.target.value)}
|
|
||||||
placeholder="My News Feed"
|
|
||||||
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
|
|
||||||
/>
|
|
||||||
{errors.name && (
|
|
||||||
<p className="text-red-600 text-sm mt-1">{errors.name[0]}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="provider" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
News Provider
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="provider"
|
|
||||||
value={formData.provider}
|
|
||||||
onChange={(e) => handleChange('provider', e.target.value)}
|
|
||||||
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 news provider</option>
|
|
||||||
{options?.feed_providers?.map((provider: FeedProvider) => (
|
|
||||||
<option key={provider.code} value={provider.code}>
|
|
||||||
{provider.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{errors.provider && (
|
|
||||||
<p className="text-red-600 text-sm mt-1">{errors.provider[0]}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="language_id" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Language
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="language_id"
|
|
||||||
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
|
|
||||||
>
|
|
||||||
<option value="">Select language</option>
|
|
||||||
{options?.languages.map((language: Language) => (
|
|
||||||
<option key={language.id} value={language.id}>
|
|
||||||
{language.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{errors.language_id && (
|
|
||||||
<p className="text-red-600 text-sm mt-1">{errors.language_id[0]}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Description (Optional)
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="description"
|
|
||||||
rows={3}
|
|
||||||
value={formData.description || ''}
|
|
||||||
onChange={(e) => handleChange('description', e.target.value)}
|
|
||||||
placeholder="Brief description of this feed"
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
{errors.description && (
|
|
||||||
<p className="text-red-600 text-sm mt-1">{errors.description[0]}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<Link
|
|
||||||
to="/onboarding/platform"
|
|
||||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200"
|
|
||||||
>
|
|
||||||
← Back
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={createFeedMutation.isPending}
|
|
||||||
className="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{createFeedMutation.isPending ? 'Creating...' : 'Continue'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FeedStep;
|
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { apiClient, type PlatformAccountRequest } from '../../../lib/api';
|
|
||||||
|
|
||||||
const PlatformStep: React.FC = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [formData, setFormData] = useState<PlatformAccountRequest>({
|
|
||||||
instance_url: '',
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
platform: 'lemmy'
|
|
||||||
});
|
|
||||||
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
|
||||||
|
|
||||||
// Fetch existing platform accounts
|
|
||||||
const { data: platformAccounts, isLoading } = useQuery({
|
|
||||||
queryKey: ['platform-accounts'],
|
|
||||||
queryFn: () => apiClient.getPlatformAccounts(),
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createPlatformMutation = useMutation({
|
|
||||||
mutationFn: (data: PlatformAccountRequest) => apiClient.createPlatformAccount(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['platform-accounts'] });
|
|
||||||
navigate('/onboarding/feed');
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
if (error.response?.data?.errors) {
|
|
||||||
setErrors(error.response.data.errors);
|
|
||||||
} else {
|
|
||||||
setErrors({ general: [error.response?.data?.message || 'An error occurred'] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const deletePlatformMutation = useMutation({
|
|
||||||
mutationFn: (accountId: number) => apiClient.deletePlatformAccount(accountId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['platform-accounts'] });
|
|
||||||
setErrors({});
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
setErrors({ general: [error.response?.data?.message || 'Failed to delete account'] });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setErrors({});
|
|
||||||
createPlatformMutation.mutate(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (field: keyof PlatformAccountRequest, value: string) => {
|
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
|
||||||
// Clear field error when user starts typing
|
|
||||||
if (errors[field]) {
|
|
||||||
setErrors(prev => ({ ...prev, [field]: [] }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteAccount = (accountId: number) => {
|
|
||||||
if (confirm('Are you sure you want to remove this account? You will need to re-enter your credentials.')) {
|
|
||||||
deletePlatformMutation.mutate(accountId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleContinueWithExisting = () => {
|
|
||||||
navigate('/onboarding/feed');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show loading state
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="text-center">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingAccount = platformAccounts && platformAccounts.length > 0 ? platformAccounts[0] : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Connect Your Lemmy Account</h1>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
{existingAccount ? 'Your connected Lemmy account' : 'Enter your Lemmy instance details and login credentials'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Progress indicator */}
|
|
||||||
<div className="flex justify-center mt-6 space-x-2">
|
|
||||||
<div className="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">1</div>
|
|
||||||
<div className="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">2</div>
|
|
||||||
<div className="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">3</div>
|
|
||||||
<div className="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">4</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Show errors */}
|
|
||||||
{errors.general && (
|
|
||||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md mt-6">
|
|
||||||
<p className="text-red-600 text-sm">{errors.general[0]}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{existingAccount ? (
|
|
||||||
/* Account Card */
|
|
||||||
<div className="mt-8">
|
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6 text-left">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="w-12 h-12 bg-green-500 text-white rounded-full flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Account Connected</h3>
|
|
||||||
<div className="text-sm text-gray-600 mt-1">
|
|
||||||
<p><strong>Username:</strong> {existingAccount.username}</p>
|
|
||||||
<p><strong>Instance:</strong> {existingAccount.instance_url?.replace('https://', '')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteAccount(existingAccount.id)}
|
|
||||||
disabled={deletePlatformMutation.isPending}
|
|
||||||
className="text-red-600 hover:text-red-800 p-2 disabled:opacity-50"
|
|
||||||
title="Remove account"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between mt-6">
|
|
||||||
<Link
|
|
||||||
to="/onboarding"
|
|
||||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200"
|
|
||||||
>
|
|
||||||
← Back
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={handleContinueWithExisting}
|
|
||||||
className="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200"
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Login Form */
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6 mt-8 text-left">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="instance_url" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Lemmy Instance Domain
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="instance_url"
|
|
||||||
value={formData.instance_url}
|
|
||||||
onChange={(e) => handleChange('instance_url', e.target.value)}
|
|
||||||
placeholder="lemmy.world"
|
|
||||||
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
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">Enter just the domain name (e.g., lemmy.world, belgae.social)</p>
|
|
||||||
{errors.instance_url && (
|
|
||||||
<p className="text-red-600 text-sm mt-1">{errors.instance_url[0]}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Username
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="username"
|
|
||||||
value={formData.username}
|
|
||||||
onChange={(e) => handleChange('username', e.target.value)}
|
|
||||||
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
|
|
||||||
/>
|
|
||||||
{errors.username && (
|
|
||||||
<p className="text-red-600 text-sm mt-1">{errors.username[0]}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="password"
|
|
||||||
value={formData.password}
|
|
||||||
onChange={(e) => handleChange('password', e.target.value)}
|
|
||||||
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
|
|
||||||
/>
|
|
||||||
{errors.password && (
|
|
||||||
<p className="text-red-600 text-sm mt-1">{errors.password[0]}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<Link
|
|
||||||
to="/onboarding"
|
|
||||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200"
|
|
||||||
>
|
|
||||||
← Back
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={createPlatformMutation.isPending}
|
|
||||||
className="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{createPlatformMutation.isPending ? 'Connecting...' : 'Continue'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PlatformStep;
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||
import React, { useState, useEffect } 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
|
|
||||||
});
|
|
||||||
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()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch existing routes to pre-fill form when going back
|
|
||||||
const { data: routes } = useQuery({
|
|
||||||
queryKey: ['routes'],
|
|
||||||
queryFn: () => apiClient.getRoutes(),
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pre-fill form with existing data
|
|
||||||
useEffect(() => {
|
|
||||||
if (routes && routes.length > 0) {
|
|
||||||
const firstRoute = routes[0];
|
|
||||||
setFormData({
|
|
||||||
feed_id: firstRoute.feed_id || 0,
|
|
||||||
platform_channel_id: firstRoute.platform_channel_id || 0,
|
|
||||||
priority: firstRoute.priority || 50
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [routes]);
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
const WelcomeStep: React.FC = () => {
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="text-center">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Welcome to FFR</h1>
|
|
||||||
<p className="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 className="space-y-4">
|
|
||||||
<div className="flex items-center text-sm text-gray-600">
|
|
||||||
<div className="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 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">2</div>
|
|
||||||
<span>Add your first feed</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">3</div>
|
|
||||||
<span>Configure a channel</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">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>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
|
||||||
<Link
|
|
||||||
to="/onboarding/platform"
|
|
||||||
className="w-full bg-blue-600 text-white py-3 px-4 rounded-md hover:bg-blue-700 transition duration-200 inline-block"
|
|
||||||
>
|
|
||||||
Get Started
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WelcomeStep;
|
|
||||||
1
frontend/src/vite-env.d.ts
vendored
1
frontend/src/vite-env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
||||||
"target": "ES2022",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
||||||
"target": "ES2023",
|
|
||||||
"lib": ["ES2023"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
server: {
|
|
||||||
host: true,
|
|
||||||
port: 5173,
|
|
||||||
watch: {
|
|
||||||
usePolling: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
|
||||||
<title>{{ config('app.name', 'Laravel') }}</title>
|
<title>{{ config('app.name', 'FFR') }}</title>
|
||||||
|
|
||||||
<!-- Fonts -->
|
<!-- Fonts -->
|
||||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
|
|
@ -13,24 +13,100 @@
|
||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
@livewireStyles
|
||||||
</head>
|
</head>
|
||||||
<body class="font-sans antialiased">
|
<body class="font-sans antialiased">
|
||||||
<div class="min-h-screen bg-gray-100">
|
<div class="min-h-screen bg-gray-50" x-data="{ sidebarOpen: false }">
|
||||||
@include('layouts.navigation')
|
<!-- Mobile overlay -->
|
||||||
|
<div
|
||||||
|
x-show="sidebarOpen"
|
||||||
|
x-transition:enter="transition-opacity ease-linear duration-300"
|
||||||
|
x-transition:enter-start="opacity-0"
|
||||||
|
x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="transition-opacity ease-linear duration-300"
|
||||||
|
x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
class="fixed inset-0 z-40 bg-gray-600/75 lg:hidden"
|
||||||
|
@click="sidebarOpen = false"
|
||||||
|
></div>
|
||||||
|
|
||||||
<!-- Page Heading -->
|
<!-- Mobile sidebar -->
|
||||||
@isset($header)
|
<div
|
||||||
<header class="bg-white shadow">
|
x-show="sidebarOpen"
|
||||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
x-transition:enter="transition ease-in-out duration-300 transform"
|
||||||
{{ $header }}
|
x-transition:enter-start="-translate-x-full"
|
||||||
|
x-transition:enter-end="translate-x-0"
|
||||||
|
x-transition:leave="transition ease-in-out duration-300 transform"
|
||||||
|
x-transition:leave-start="translate-x-0"
|
||||||
|
x-transition:leave-end="-translate-x-full"
|
||||||
|
class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg lg:hidden"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between h-16 px-4 border-b border-gray-200">
|
||||||
|
<h1 class="text-xl font-bold text-gray-900">FFR</h1>
|
||||||
|
<button
|
||||||
|
@click="sidebarOpen = false"
|
||||||
|
class="p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav class="mt-5 px-2">
|
||||||
|
@include('layouts.navigation-items')
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
@endisset
|
|
||||||
|
|
||||||
<!-- Page Content -->
|
<!-- Desktop sidebar -->
|
||||||
<main>
|
<div class="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
|
||||||
|
<div class="flex flex-col flex-grow bg-white pt-5 pb-4 overflow-y-auto border-r border-gray-200">
|
||||||
|
<div class="flex items-center flex-shrink-0 px-4">
|
||||||
|
<h1 class="text-xl font-bold text-gray-900">FFR</h1>
|
||||||
|
</div>
|
||||||
|
<nav class="mt-5 flex-1 px-2 bg-white">
|
||||||
|
@include('layouts.navigation-items')
|
||||||
|
</nav>
|
||||||
|
<div class="flex-shrink-0 p-4 border-t border-gray-200">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{{ Auth::user()->name }}
|
||||||
|
</p>
|
||||||
|
<form method="POST" action="{{ route('logout') }}" class="inline">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="text-sm text-gray-500 hover:text-gray-700 truncate">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="lg:pl-64 flex flex-col flex-1">
|
||||||
|
<!-- Mobile header -->
|
||||||
|
<div class="sticky top-0 z-10 shrink-0 flex h-16 bg-white shadow lg:hidden">
|
||||||
|
<button
|
||||||
|
@click="sidebarOpen = true"
|
||||||
|
class="px-4 border-r border-gray-200 text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="flex-1 px-4 flex justify-between items-center">
|
||||||
|
<h1 class="text-lg font-medium text-gray-900">FFR</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="flex-1">
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@livewireScripts
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -13,18 +13,10 @@
|
||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
@livewireStyles
|
||||||
</head>
|
</head>
|
||||||
<body class="font-sans text-gray-900 antialiased">
|
<body class="font-sans text-gray-900 antialiased">
|
||||||
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100">
|
|
||||||
<div>
|
|
||||||
<a href="/">
|
|
||||||
<x-application-logo class="w-20 h-20 fill-current text-gray-500" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white shadow-md overflow-hidden sm:rounded-lg">
|
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</div>
|
@livewireScripts
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
53
resources/views/layouts/navigation-items.blade.php
Normal file
53
resources/views/layouts/navigation-items.blade.php
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
@php
|
||||||
|
$navigation = [
|
||||||
|
['name' => 'Dashboard', 'route' => 'dashboard', 'icon' => 'home'],
|
||||||
|
['name' => 'Articles', 'route' => 'articles', 'icon' => 'document-text'],
|
||||||
|
['name' => 'Feeds', 'route' => 'feeds', 'icon' => 'rss'],
|
||||||
|
['name' => 'Channels', 'route' => 'channels', 'icon' => 'hashtag'],
|
||||||
|
['name' => 'Routes', 'route' => 'routes', 'icon' => 'arrow-path'],
|
||||||
|
['name' => 'Settings', 'route' => 'settings', 'icon' => 'cog-6-tooth'],
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@foreach ($navigation as $item)
|
||||||
|
<a
|
||||||
|
href="{{ route($item['route']) }}"
|
||||||
|
@click="sidebarOpen = false"
|
||||||
|
class="group flex items-center px-2 py-2 text-sm font-medium rounded-md mb-1 {{ request()->routeIs($item['route']) ? 'bg-blue-100 text-blue-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}"
|
||||||
|
>
|
||||||
|
@switch($item['icon'])
|
||||||
|
@case('home')
|
||||||
|
<svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||||
|
</svg>
|
||||||
|
@break
|
||||||
|
@case('document-text')
|
||||||
|
<svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
||||||
|
</svg>
|
||||||
|
@break
|
||||||
|
@case('rss')
|
||||||
|
<svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-.75a7.5 7.5 0 0 0-7.5-7.5H4.5m0-6.75h.75c7.87 0 14.25 6.38 14.25 14.25v.75M6 18.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
|
||||||
|
</svg>
|
||||||
|
@break
|
||||||
|
@case('hashtag')
|
||||||
|
<svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5" />
|
||||||
|
</svg>
|
||||||
|
@break
|
||||||
|
@case('arrow-path')
|
||||||
|
<svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||||
|
</svg>
|
||||||
|
@break
|
||||||
|
@case('cog-6-tooth')
|
||||||
|
<svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
|
</svg>
|
||||||
|
@break
|
||||||
|
@endswitch
|
||||||
|
{{ $item['name'] }}
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
139
resources/views/livewire/articles.blade.php
Normal file
139
resources/views/livewire/articles.blade.php
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="mb-8 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Articles</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Manage and review articles from your feeds
|
||||||
|
</p>
|
||||||
|
@if ($approvalsEnabled)
|
||||||
|
<div class="mt-2 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z" />
|
||||||
|
</svg>
|
||||||
|
Approval system enabled
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
wire:click="refresh"
|
||||||
|
wire:loading.attr="disabled"
|
||||||
|
@disabled($isRefreshing)
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4 mr-2 {{ $isRefreshing ? 'animate-spin' : '' }}" wire:loading.class="animate-spin" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||||
|
</svg>
|
||||||
|
<span wire:loading.remove wire:target="refresh">{{ $isRefreshing ? 'Refreshing...' : 'Refresh' }}</span>
|
||||||
|
<span wire:loading wire:target="refresh">Refreshing...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
@forelse ($articles as $article)
|
||||||
|
<div class="bg-white rounded-lg shadow p-6" wire:key="article-{{ $article->id }}">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
{{ $article->title ?? 'Untitled Article' }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-600 mb-3 line-clamp-2">
|
||||||
|
{{ $article->description ?? 'No description available' }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center space-x-4 text-xs text-gray-500">
|
||||||
|
<span>Feed: {{ $article->feed?->name ?? 'Unknown' }}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{{ $article->created_at->format('M d, Y') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3 ml-4">
|
||||||
|
@if ($article->is_published)
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||||
|
</svg>
|
||||||
|
Published
|
||||||
|
</span>
|
||||||
|
@elseif ($article->approval_status === 'approved')
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||||
|
</svg>
|
||||||
|
Approved
|
||||||
|
</span>
|
||||||
|
@elseif ($article->approval_status === 'rejected')
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||||
|
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||||
|
</svg>
|
||||||
|
Rejected
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||||
|
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||||
|
</svg>
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($article->url)
|
||||||
|
<a
|
||||||
|
href="{{ $article->url }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="p-2 text-gray-400 hover:text-gray-600 rounded-md"
|
||||||
|
title="View original article"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($article->approval_status === 'pending' && $approvalsEnabled)
|
||||||
|
<div class="mt-4 flex space-x-3">
|
||||||
|
<button
|
||||||
|
wire:click="approve({{ $article->id }})"
|
||||||
|
wire:loading.attr="disabled"
|
||||||
|
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||||
|
</svg>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
wire:click="reject({{ $article->id }})"
|
||||||
|
wire:loading.attr="disabled"
|
||||||
|
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||||
|
</svg>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No articles</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
No articles have been fetched yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
|
||||||
|
@if ($articles->hasPages())
|
||||||
|
<div class="mt-6">
|
||||||
|
{{ $articles->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
163
resources/views/livewire/channels.blade.php
Normal file
163
resources/views/livewire/channels.blade.php
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Channels</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Manage your platform channels and linked accounts
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
@forelse ($channels as $channel)
|
||||||
|
<div class="bg-white rounded-lg shadow p-6" wire:key="channel-{{ $channel->id }}">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="shrink-0">
|
||||||
|
<svg class="h-8 w-8 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 flex-1 min-w-0">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 truncate">
|
||||||
|
{{ $channel->display_name ?? $channel->name }}
|
||||||
|
</h3>
|
||||||
|
@if ($channel->platformInstance)
|
||||||
|
<a href="{{ $channel->platformInstance->url }}/c/{{ $channel->name }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-sm text-blue-500 hover:underline">
|
||||||
|
{{ $channel->platformInstance->name }}
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
wire:click="toggle({{ $channel->id }})"
|
||||||
|
class="p-1 rounded-full {{ $channel->is_active ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400' }}"
|
||||||
|
title="{{ $channel->is_active ? 'Active - Click to disable' : 'Inactive - Click to enable' }}"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($channel->description)
|
||||||
|
<p class="mt-3 text-sm text-gray-500 line-clamp-2">
|
||||||
|
{{ $channel->description }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-sm font-medium text-gray-700">
|
||||||
|
Linked Accounts ({{ $channel->platformAccounts->count() }})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
wire:click="openAccountModal({{ $channel->id }})"
|
||||||
|
class="text-sm text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($channel->platformAccounts->isNotEmpty())
|
||||||
|
<div class="space-y-1">
|
||||||
|
@foreach ($channel->platformAccounts->take(3) as $account)
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-gray-600">{{ $account->username }}</span>
|
||||||
|
<button
|
||||||
|
wire:click="detachAccount({{ $channel->id }}, {{ $account->id }})"
|
||||||
|
class="text-red-500 hover:text-red-700"
|
||||||
|
title="Remove account"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
@if ($channel->platformAccounts->count() > 3)
|
||||||
|
<span class="text-xs text-gray-400">+{{ $channel->platformAccounts->count() - 3 }} more</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-sm text-gray-400">No accounts linked</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center justify-between">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $channel->is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800' }}">
|
||||||
|
{{ $channel->is_active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
{{ $channel->created_at->format('M d, Y') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="col-span-full text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No channels</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
No platform channels have been configured yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Management Modal -->
|
||||||
|
@if ($managingChannel)
|
||||||
|
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||||
|
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" wire:click="closeAccountModal"></div>
|
||||||
|
|
||||||
|
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||||
|
|
||||||
|
<div class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">
|
||||||
|
Manage Accounts for {{ $managingChannel->display_name ?? $managingChannel->name }}
|
||||||
|
</h3>
|
||||||
|
<button wire:click="closeAccountModal" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($availableAccounts->isNotEmpty())
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">Available Accounts</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($availableAccounts as $account)
|
||||||
|
<div class="flex items-center justify-between p-2 border rounded">
|
||||||
|
<span class="text-sm text-gray-900">{{ $account->username }}</span>
|
||||||
|
<button
|
||||||
|
wire:click="attachAccount({{ $account->id }})"
|
||||||
|
class="text-sm text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-sm text-gray-500 mb-4">No available accounts to link.</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<button
|
||||||
|
wire:click="closeAccountModal"
|
||||||
|
class="w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
153
resources/views/livewire/dashboard.blade.php
Normal file
153
resources/views/livewire/dashboard.blade.php
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Overview of your feed management system
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Statistics -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">System Overview</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<!-- Active Feeds -->
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="shrink-0">
|
||||||
|
<svg class="h-8 w-8 text-orange-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-.75a7.5 7.5 0 0 0-7.5-7.5H4.5m0-6.75h.75c7.87 0 14.25 6.38 14.25 14.25v.75M6 18.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-500">Active Feeds</p>
|
||||||
|
<p class="text-2xl font-semibold text-gray-900">
|
||||||
|
{{ $systemStats['active_feeds'] }}
|
||||||
|
<span class="text-sm font-normal text-gray-500">/{{ $systemStats['total_feeds'] }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Platform Accounts -->
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="shrink-0">
|
||||||
|
<svg class="h-8 w-8 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-500">Platform Accounts</p>
|
||||||
|
<p class="text-2xl font-semibold text-gray-900">
|
||||||
|
{{ $systemStats['active_platform_accounts'] }}
|
||||||
|
<span class="text-sm font-normal text-gray-500">/{{ $systemStats['total_platform_accounts'] }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Platform Channels -->
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="shrink-0">
|
||||||
|
<svg class="h-8 w-8 text-cyan-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-500">Platform Channels</p>
|
||||||
|
<p class="text-2xl font-semibold text-gray-900">
|
||||||
|
{{ $systemStats['active_platform_channels'] }}
|
||||||
|
<span class="text-sm font-normal text-gray-500">/{{ $systemStats['total_platform_channels'] }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Routes -->
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="shrink-0">
|
||||||
|
<svg class="h-8 w-8 text-pink-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-500">Active Routes</p>
|
||||||
|
<p class="text-2xl font-semibold text-gray-900">
|
||||||
|
{{ $systemStats['active_routes'] }}
|
||||||
|
<span class="text-sm font-normal text-gray-500">/{{ $systemStats['total_routes'] }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Article Statistics -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Article Statistics</h2>
|
||||||
|
<select
|
||||||
|
wire:model.live="period"
|
||||||
|
class="rounded-md border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
@foreach ($availablePeriods as $value => $label)
|
||||||
|
<option value="{{ $value }}">{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<!-- Articles Fetched -->
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="shrink-0">
|
||||||
|
<svg class="h-8 w-8 text-blue-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-500">Articles Fetched</p>
|
||||||
|
<p class="text-2xl font-semibold text-gray-900">
|
||||||
|
{{ $articleStats['articles_fetched'] }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Articles Published -->
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="shrink-0">
|
||||||
|
<svg class="h-8 w-8 text-green-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-500">Articles Published</p>
|
||||||
|
<p class="text-2xl font-semibold text-gray-900">
|
||||||
|
{{ $articleStats['articles_published'] }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Published Rate -->
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="shrink-0">
|
||||||
|
<svg class="h-8 w-8 text-purple-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-500">Published Rate</p>
|
||||||
|
<p class="text-2xl font-semibold text-gray-900">
|
||||||
|
{{ $articleStats['published_percentage'] }}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
87
resources/views/livewire/feeds.blade.php
Normal file
87
resources/views/livewire/feeds.blade.php
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Feeds</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Manage your news feed sources
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
@forelse ($feeds as $feed)
|
||||||
|
<div class="bg-white rounded-lg shadow p-6" wire:key="feed-{{ $feed->id }}">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="shrink-0">
|
||||||
|
@if ($feed->type === 'rss')
|
||||||
|
<svg class="h-8 w-8 text-orange-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-.75a7.5 7.5 0 0 0-7.5-7.5H4.5m0-6.75h.75c7.87 0 14.25 6.38 14.25 14.25v.75M6 18.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
|
||||||
|
</svg>
|
||||||
|
@else
|
||||||
|
<svg class="h-8 w-8 text-blue-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />
|
||||||
|
</svg>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 flex-1 min-w-0">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 truncate">
|
||||||
|
{{ $feed->name }}
|
||||||
|
</h3>
|
||||||
|
@if ($feed->description)
|
||||||
|
<p class="text-sm text-gray-500 line-clamp-2">
|
||||||
|
{{ $feed->description }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
wire:click="toggle({{ $feed->id }})"
|
||||||
|
class="p-1 rounded-full {{ $feed->is_active ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400' }}"
|
||||||
|
title="{{ $feed->is_active ? 'Active - Click to disable' : 'Inactive - Click to enable' }}"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $feed->is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800' }}">
|
||||||
|
{{ $feed->is_active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
{{ strtoupper($feed->type) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if ($feed->url)
|
||||||
|
<a
|
||||||
|
href="{{ $feed->url }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-gray-400 hover:text-gray-600"
|
||||||
|
title="Open feed URL"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 text-xs text-gray-500">
|
||||||
|
Created {{ $feed->created_at->format('M d, Y') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="col-span-full text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-.75a7.5 7.5 0 0 0-7.5-7.5H4.5m0-6.75h.75c7.87 0 14.25 6.38 14.25 14.25v.75M6 18.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No feeds</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
No feeds have been configured yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
546
resources/views/livewire/onboarding.blade.php
Normal file
546
resources/views/livewire/onboarding.blade.php
Normal file
|
|
@ -0,0 +1,546 @@
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-lg w-full bg-white rounded-lg shadow-md p-8">
|
||||||
|
|
||||||
|
{{-- Step 1: Welcome --}}
|
||||||
|
@if ($step === 1)
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 mb-2">Welcome to FFR</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 text-left">
|
||||||
|
<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>Create a route</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">5</div>
|
||||||
|
<span>You're ready to go!</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<button
|
||||||
|
wire:click="nextStep"
|
||||||
|
class="w-full bg-blue-600 text-white py-3 px-4 rounded-md hover:bg-blue-700 transition duration-200"
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Step 2: Platform Account --}}
|
||||||
|
@if ($step === 2)
|
||||||
|
<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">
|
||||||
|
{{ $existingAccount ? 'Your connected Lemmy account' : '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>
|
||||||
|
|
||||||
|
@if (!empty($errors['general']))
|
||||||
|
<div class="p-3 bg-red-50 border border-red-200 rounded-md mt-6">
|
||||||
|
<p class="text-red-600 text-sm">{{ $errors['general'] }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($existingAccount)
|
||||||
|
{{-- Account Card --}}
|
||||||
|
<div class="mt-8">
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg p-6 text-left">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-12 h-12 bg-green-500 text-white rounded-full flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6" 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>
|
||||||
|
<div class="ml-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">Account Connected</h3>
|
||||||
|
<div class="text-sm text-gray-600 mt-1">
|
||||||
|
<p><strong>Username:</strong> {{ $existingAccount['username'] }}</p>
|
||||||
|
<p><strong>Instance:</strong> {{ str_replace('https://', '', $existingAccount['instance_url']) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
wire:click="deleteAccount"
|
||||||
|
wire:confirm="Are you sure you want to remove this account? You will need to re-enter your credentials."
|
||||||
|
class="text-red-600 hover:text-red-800 p-2"
|
||||||
|
title="Remove account"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between mt-6">
|
||||||
|
<button wire:click="previousStep" class="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200">
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
wire:click="continueWithExistingAccount"
|
||||||
|
class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
{{-- Login Form --}}
|
||||||
|
<form wire:submit="createPlatformAccount" class="space-y-6 mt-8 text-left">
|
||||||
|
<div>
|
||||||
|
<label for="instanceUrl" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Lemmy Instance Domain
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="instanceUrl"
|
||||||
|
wire:model="instanceUrl"
|
||||||
|
placeholder="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"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Enter just the domain name (e.g., lemmy.world, belgae.social)</p>
|
||||||
|
@error('instanceUrl') <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"
|
||||||
|
wire:model="username"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
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"
|
||||||
|
wire:model="password"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
@error('password') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<button type="button" wire:click="previousStep" class="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200">
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
@disabled($isLoading)
|
||||||
|
class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ $isLoading ? 'Connecting...' : 'Continue' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Step 3: Feed --}}
|
||||||
|
@if ($step === 3)
|
||||||
|
<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">
|
||||||
|
Choose from our supported news providers 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>
|
||||||
|
|
||||||
|
<form wire:submit="createFeed" class="space-y-6 mt-8 text-left">
|
||||||
|
@if (!empty($errors['general']))
|
||||||
|
<div class="p-3 bg-red-50 border border-red-200 rounded-md">
|
||||||
|
<p class="text-red-600 text-sm">{{ $errors['general'] }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="feedName" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Feed Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="feedName"
|
||||||
|
wire:model="feedName"
|
||||||
|
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"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
@error('feedName') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="feedProvider" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
News Provider
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="feedProvider"
|
||||||
|
wire:model="feedProvider"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select news provider</option>
|
||||||
|
@foreach ($feedProviders as $provider)
|
||||||
|
<option value="{{ $provider['code'] }}">{{ $provider['name'] }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('feedProvider') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="feedLanguageId" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Language
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="feedLanguageId"
|
||||||
|
wire:model="feedLanguageId"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select language</option>
|
||||||
|
@foreach ($languages as $language)
|
||||||
|
<option value="{{ $language->id }}">{{ $language->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('feedLanguageId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="feedDescription" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Description (Optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="feedDescription"
|
||||||
|
wire:model="feedDescription"
|
||||||
|
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"
|
||||||
|
></textarea>
|
||||||
|
@error('feedDescription') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<button type="button" wire:click="previousStep" class="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200">
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
@disabled($isLoading)
|
||||||
|
class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ $isLoading ? 'Creating...' : 'Continue' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Step 4: Channel --}}
|
||||||
|
@if ($step === 4)
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<form wire:submit="createChannel" class="space-y-6 mt-8 text-left">
|
||||||
|
@if (!empty($errors['general']))
|
||||||
|
<div class="p-3 bg-red-50 border border-red-200 rounded-md">
|
||||||
|
<p class="text-red-600 text-sm">{{ $errors['general'] }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="channelName" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Community Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="channelName"
|
||||||
|
wire:model="channelName"
|
||||||
|
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"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Enter the community name (without the @ or instance)</p>
|
||||||
|
@error('channelName') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="platformInstanceId" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Platform Instance
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="platformInstanceId"
|
||||||
|
wire:model="platformInstanceId"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select platform instance</option>
|
||||||
|
@foreach ($platformInstances as $instance)
|
||||||
|
<option value="{{ $instance->id }}">{{ $instance->name }} ({{ $instance->url }})</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('platformInstanceId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="channelLanguageId" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Language
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="channelLanguageId"
|
||||||
|
wire:model="channelLanguageId"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select language</option>
|
||||||
|
@foreach ($languages as $language)
|
||||||
|
<option value="{{ $language->id }}">{{ $language->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('channelLanguageId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="channelDescription" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Description (Optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="channelDescription"
|
||||||
|
wire:model="channelDescription"
|
||||||
|
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"
|
||||||
|
></textarea>
|
||||||
|
@error('channelDescription') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<button type="button" wire:click="previousStep" class="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200">
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
@disabled($isLoading)
|
||||||
|
class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ $isLoading ? 'Creating...' : 'Continue' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Step 5: Route --}}
|
||||||
|
@if ($step === 5)
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-2">Create Your First Route</h1>
|
||||||
|
<p class="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 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-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">4</div>
|
||||||
|
<div class="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 wire:submit="createRoute" class="space-y-6 mt-8 text-left">
|
||||||
|
@if (!empty($errors['general']))
|
||||||
|
<div class="p-3 bg-red-50 border border-red-200 rounded-md">
|
||||||
|
<p class="text-red-600 text-sm">{{ $errors['general'] }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="routeFeedId" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Select Feed
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="routeFeedId"
|
||||||
|
wire:model="routeFeedId"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select a feed</option>
|
||||||
|
@foreach ($feeds as $feed)
|
||||||
|
<option value="{{ $feed->id }}">{{ $feed->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('routeFeedId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="routeChannelId" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Select Channel
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="routeChannelId"
|
||||||
|
wire:model="routeChannelId"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select a channel</option>
|
||||||
|
@foreach ($channels as $channel)
|
||||||
|
<option value="{{ $channel->id }}">{{ $channel->display_name ?? $channel->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@if ($channels->isEmpty())
|
||||||
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
|
No channels available. Please create a channel first.
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
@error('routeChannelId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="routePriority" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Priority (1-100)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="routePriority"
|
||||||
|
wire:model="routePriority"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
placeholder="50"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
|
Higher priority routes are processed first (default: 50)
|
||||||
|
</p>
|
||||||
|
@error('routePriority') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<button type="button" wire:click="previousStep" class="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200">
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
@disabled($isLoading || $channels->isEmpty())
|
||||||
|
class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ $isLoading ? 'Creating...' : 'Continue' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Step 6: Complete --}}
|
||||||
|
@if ($step === 6)
|
||||||
|
<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 FFR. 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 other 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">
|
||||||
|
You can add more feeds, channels, and configure settings from the dashboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<button
|
||||||
|
wire:click="completeOnboarding"
|
||||||
|
@disabled($isLoading)
|
||||||
|
class="w-full bg-blue-600 text-white py-3 px-4 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ $isLoading ? 'Finishing...' : 'Go to Dashboard' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
<a href="{{ route('articles') }}" class="hover:text-blue-600">View Articles</a>
|
||||||
|
•
|
||||||
|
<a href="{{ route('feeds') }}" class="hover:text-blue-600">Manage Feeds</a>
|
||||||
|
•
|
||||||
|
<a href="{{ route('settings') }}" class="hover:text-blue-600">Settings</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
380
resources/views/livewire/routes.blade.php
Normal file
380
resources/views/livewire/routes.blade.php
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="mb-8 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Routes</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Manage connections between your feeds and channels
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
wire:click="openCreateModal"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
Create Route
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
@forelse ($routes as $route)
|
||||||
|
<div class="bg-white rounded-lg shadow p-6" wire:key="route-{{ $route->feed_id }}-{{ $route->platform_channel_id }}">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center space-x-3 mb-2">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">
|
||||||
|
{{ $route->feed?->name }} → {{ $route->platformChannel?->display_name ?? $route->platformChannel?->name }}
|
||||||
|
</h3>
|
||||||
|
@if ($route->is_active)
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||||
|
</svg>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||||
|
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||||
|
</svg>
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4 text-sm text-gray-600">
|
||||||
|
<span>Priority: {{ $route->priority }}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Feed: {{ $route->feed?->name }}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Channel: {{ $route->platformChannel?->display_name ?? $route->platformChannel?->name }}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Created: {{ $route->created_at->format('M d, Y') }}</span>
|
||||||
|
</div>
|
||||||
|
@if ($route->platformChannel?->description)
|
||||||
|
<p class="mt-2 text-sm text-gray-500">
|
||||||
|
{{ $route->platformChannel->description }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
@if ($route->keywords->isNotEmpty())
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="flex items-center space-x-2 mb-2">
|
||||||
|
<svg class="h-4 w-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium text-gray-700">Keywords</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach ($route->keywords as $keyword)
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $keyword->is_active ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-500' }}">
|
||||||
|
{{ $keyword->keyword }}
|
||||||
|
</span>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="mt-3 text-sm text-gray-400 italic">
|
||||||
|
No keyword filters - matches all articles
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2 ml-4">
|
||||||
|
<button
|
||||||
|
wire:click="openEditModal({{ $route->feed_id }}, {{ $route->platform_channel_id }})"
|
||||||
|
class="p-2 text-gray-400 hover:text-gray-600 rounded-md"
|
||||||
|
title="Edit route"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
wire:click="toggle({{ $route->feed_id }}, {{ $route->platform_channel_id }})"
|
||||||
|
class="p-2 text-gray-400 hover:text-gray-600 rounded-md"
|
||||||
|
title="{{ $route->is_active ? 'Deactivate route' : 'Activate route' }}"
|
||||||
|
>
|
||||||
|
@if ($route->is_active)
|
||||||
|
<svg class="h-4 w-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" />
|
||||||
|
</svg>
|
||||||
|
@else
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" />
|
||||||
|
</svg>
|
||||||
|
@endif
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
wire:click="delete({{ $route->feed_id }}, {{ $route->platform_channel_id }})"
|
||||||
|
wire:confirm="Are you sure you want to delete this route?"
|
||||||
|
class="p-2 text-gray-400 hover:text-red-600 rounded-md"
|
||||||
|
title="Delete route"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No routes</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Get started by creating a new route to connect feeds with channels.
|
||||||
|
</p>
|
||||||
|
<div class="mt-6">
|
||||||
|
<button
|
||||||
|
wire:click="openCreateModal"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
Create Route
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Route Modal -->
|
||||||
|
@if ($showCreateModal)
|
||||||
|
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||||
|
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" wire:click="closeCreateModal"></div>
|
||||||
|
|
||||||
|
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||||
|
|
||||||
|
<div class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Create New Route</h3>
|
||||||
|
<form wire:submit="createRoute" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="newFeedId" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Feed
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="newFeedId"
|
||||||
|
wire:model="newFeedId"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select a feed</option>
|
||||||
|
@foreach ($feeds as $feed)
|
||||||
|
<option value="{{ $feed->id }}">{{ $feed->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('newFeedId') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="newChannelId" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Channel
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="newChannelId"
|
||||||
|
wire:model="newChannelId"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select a channel</option>
|
||||||
|
@foreach ($channels as $channel)
|
||||||
|
<option value="{{ $channel->id }}">{{ $channel->display_name ?? $channel->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('newChannelId') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="newPriority" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Priority
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="newPriority"
|
||||||
|
wire:model="newPriority"
|
||||||
|
min="0"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Higher priority routes are processed first</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="closeCreateModal"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Create Route
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Edit Route Modal -->
|
||||||
|
@if ($editingRoute)
|
||||||
|
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||||
|
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" wire:click="closeEditModal"></div>
|
||||||
|
|
||||||
|
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||||
|
|
||||||
|
<div class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Edit Route</h3>
|
||||||
|
<button wire:click="closeEditModal" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 p-3 bg-gray-50 rounded-md">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
<strong>Feed:</strong> {{ $editingRoute->feed?->name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
<strong>Channel:</strong> {{ $editingRoute->platformChannel?->display_name ?? $editingRoute->platformChannel?->name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form wire:submit="updateRoute" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="editPriority" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Priority
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="editPriority"
|
||||||
|
wire:model="editPriority"
|
||||||
|
min="0"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Higher priority routes are processed first</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Keyword Management -->
|
||||||
|
<div class="border-t pt-4">
|
||||||
|
<div class="flex items-center space-x-2 mb-3">
|
||||||
|
<svg class="h-4 w-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium text-gray-700">Keywords</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="$set('showKeywordInput', true)"
|
||||||
|
class="inline-flex items-center p-1 text-gray-400 hover:text-gray-600 rounded"
|
||||||
|
title="Add keyword"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($showKeywordInput)
|
||||||
|
<div class="flex space-x-2 mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
wire:model="newKeyword"
|
||||||
|
wire:keydown.enter.prevent="addKeyword"
|
||||||
|
placeholder="Enter keyword..."
|
||||||
|
class="flex-1 px-2 py-1 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
autofocus
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="addKeyword"
|
||||||
|
class="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="$set('showKeywordInput', false)"
|
||||||
|
class="px-3 py-1 bg-gray-200 text-gray-700 text-sm rounded hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($editingKeywords->isNotEmpty())
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($editingKeywords as $keyword)
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 rounded border {{ $keyword->is_active ? 'border-blue-200 bg-blue-50' : 'border-gray-200 bg-gray-50' }}">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium {{ $keyword->is_active ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-500' }}">
|
||||||
|
{{ $keyword->keyword }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
{{ $keyword->is_active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="toggleKeyword({{ $keyword->id }})"
|
||||||
|
class="text-sm text-blue-600 hover:text-blue-800"
|
||||||
|
title="{{ $keyword->is_active ? 'Deactivate keyword' : 'Activate keyword' }}"
|
||||||
|
>
|
||||||
|
{{ $keyword->is_active ? 'Deactivate' : 'Activate' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="deleteKeyword({{ $keyword->id }})"
|
||||||
|
wire:confirm="Are you sure you want to delete this keyword?"
|
||||||
|
class="text-red-600 hover:text-red-800"
|
||||||
|
title="Delete keyword"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
@if (!$showKeywordInput)
|
||||||
|
<div class="text-sm text-gray-500 italic p-2 border border-gray-200 rounded">
|
||||||
|
No keywords defined. This route will match all articles.
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="closeEditModal"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Update Route
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
90
resources/views/livewire/settings.blade.php
Normal file
90
resources/views/livewire/settings.blade.php
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<div class="p-6" x-data x-on:clear-message.window="setTimeout(() => $wire.clearMessages(), 3000)">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Settings</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Configure your system preferences
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Article Processing Settings -->
|
||||||
|
<div class="bg-white rounded-lg shadow">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900 flex items-center">
|
||||||
|
<svg class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
|
</svg>
|
||||||
|
Article Processing
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Control how articles are processed and handled
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-900">
|
||||||
|
Article Processing Enabled
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Enable automatic fetching and processing of articles from feeds
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
wire:click="toggleArticleProcessing"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
>
|
||||||
|
@if ($articleProcessingEnabled)
|
||||||
|
<svg class="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" />
|
||||||
|
</svg>
|
||||||
|
@else
|
||||||
|
<svg class="h-6 w-6 text-gray-300" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" />
|
||||||
|
</svg>
|
||||||
|
@endif
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-900">
|
||||||
|
Publishing Approvals Required
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Require manual approval before articles are published to platforms
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
wire:click="togglePublishingApprovals"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
>
|
||||||
|
@if ($publishingApprovalsEnabled)
|
||||||
|
<svg class="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" />
|
||||||
|
</svg>
|
||||||
|
@else
|
||||||
|
<svg class="h-6 w-6 text-gray-300" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" />
|
||||||
|
</svg>
|
||||||
|
@endif
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Messages -->
|
||||||
|
@if ($successMessage)
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-md p-4">
|
||||||
|
<p class="text-green-600 text-sm">{{ $successMessage }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($errorMessage)
|
||||||
|
<div class="bg-red-50 border border-red-200 rounded-md p-4">
|
||||||
|
<p class="text-red-600 text-sm">{{ $errorMessage }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -1,16 +1,36 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
|
use App\Livewire\Articles;
|
||||||
|
use App\Livewire\Channels;
|
||||||
|
use App\Livewire\Dashboard;
|
||||||
|
use App\Livewire\Feeds;
|
||||||
|
use App\Livewire\Onboarding;
|
||||||
|
use App\Livewire\Routes;
|
||||||
|
use App\Livewire\Settings;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
// Redirect root to dashboard
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
return view('welcome');
|
return redirect()->route('dashboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::get('/dashboard', function () {
|
// Onboarding routes (protected by auth, but need incomplete onboarding)
|
||||||
return view('dashboard');
|
Route::middleware(['auth', 'onboarding.incomplete'])->group(function () {
|
||||||
})->middleware(['auth', 'verified'])->name('dashboard');
|
Route::get('/onboarding', Onboarding::class)->name('onboarding');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main app routes (protected by auth and require completed onboarding)
|
||||||
|
Route::middleware(['auth', 'onboarding.complete'])->group(function () {
|
||||||
|
Route::get('/dashboard', Dashboard::class)->name('dashboard');
|
||||||
|
Route::get('/articles', Articles::class)->name('articles');
|
||||||
|
Route::get('/feeds', Feeds::class)->name('feeds');
|
||||||
|
Route::get('/channels', Channels::class)->name('channels');
|
||||||
|
Route::get('/routes', Routes::class)->name('routes');
|
||||||
|
Route::get('/settings', Settings::class)->name('settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Profile routes (auth protected, no onboarding check needed)
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
||||||
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
|
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue