73 - Port react frontend to blade+livewire

This commit is contained in:
myrmidex 2026-01-22 23:38:00 +01:00
parent 0823cb796c
commit 638983d42a
61 changed files with 2641 additions and 8298 deletions

View 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);
}
}

View 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
View 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
View 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');
}
}

View 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
View 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
View 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
View 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
View 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');
}
}

View file

@ -109,6 +109,11 @@ public function canBePublished(): bool
return $this->isApproved();
}
public function getIsPublishedAttribute(): bool
{
return $this->articlePublication()->exists();
}
/**
* @return HasOne<ArticlePublication, $this>
*/

View 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;
}
}

View file

@ -1,11 +1,10 @@
<?php
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\EnsureOnboardingComplete;
use App\Http\Middleware\RedirectIfOnboardingComplete;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
@ -15,12 +14,9 @@
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->web(append: [
HandleAppearance::class,
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
$middleware->alias([
'onboarding.complete' => EnsureOnboardingComplete::class,
'onboarding.incomplete' => RedirectIfOnboardingComplete::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {

24
frontend/.gitignore vendored
View file

@ -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?

View file

@ -1,3 +0,0 @@
resources/js/components/ui/*
resources/js/ziggy.js
resources/views/mail/*

View file

@ -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
}
}
]
}

View file

@ -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...
},
},
])
```

View file

@ -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,
},
},
])

View file

@ -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>

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View file

@ -1,6 +0,0 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

View file

@ -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

View file

@ -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;
}

View file

@ -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;

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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;
};

View file

@ -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;
}

View file

@ -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();

View file

@ -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>,
);

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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">&#8203;</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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -1 +0,0 @@
/// <reference types="vite/client" />

View file

@ -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"]
}

View file

@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -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"]
}

View file

@ -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,
},
},
})

View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<title>{{ config('app.name', 'FFR') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
@ -13,24 +13,100 @@
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>
<body class="font-sans antialiased">
<div class="min-h-screen bg-gray-100">
@include('layouts.navigation')
<div class="min-h-screen bg-gray-50" x-data="{ sidebarOpen: false }">
<!-- 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 -->
@isset($header)
<header class="bg-white shadow">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{{ $header }}
<!-- Mobile sidebar -->
<div
x-show="sidebarOpen"
x-transition:enter="transition ease-in-out duration-300 transform"
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>
</header>
@endisset
<!-- Page Content -->
<main>
<!-- Desktop sidebar -->
<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 }}
</main>
</div>
</div>
@livewireScripts
</body>
</html>

View file

@ -13,18 +13,10 @@
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>
<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 }}
</div>
</div>
@livewireScripts
</body>
</html>

View 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

View 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>&bull;</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>

View 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">&#8203;</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>

View 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>

View 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>

View 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>

View 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>&bull;</span>
<span>Feed: {{ $route->feed?->name }}</span>
<span>&bull;</span>
<span>Channel: {{ $route->platformChannel?->display_name ?? $route->platformChannel?->name }}</span>
<span>&bull;</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">&#8203;</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">&#8203;</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>

View 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>

View file

@ -1,16 +1,36 @@
<?php
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;
// Redirect root to dashboard
Route::get('/', function () {
return view('welcome');
return redirect()->route('dashboard');
});
Route::get('/dashboard', function () {
return view('dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');
// Onboarding routes (protected by auth, but need incomplete onboarding)
Route::middleware(['auth', 'onboarding.incomplete'])->group(function () {
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::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');