Convert templates to react

This commit is contained in:
myrmidex 2025-08-02 15:28:38 +02:00
parent bb771d5e14
commit 01493ac0bf
14 changed files with 1434 additions and 6 deletions

View file

@ -6,10 +6,22 @@
"dev": "vite"
},
"devDependencies": {
"laravel-vite-plugin": "^1.0",
"vite": "^6.0",
"@tailwindcss/vite": "^4.0.0",
"laravel-vite-plugin": "^1.0",
"tailwindcss": "^4.0.0",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"vite": "^6.0"
},
"dependencies": {
"@tanstack/react-query": "^5.84.1",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^4.7.0",
"axios": "^1.11.0",
"lucide-react": "^0.536.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.7.1",
"typescript": "^5.9.2"
}
}

32
resources/js/App.tsx Normal file
View file

@ -0,0 +1,32 @@
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { apiClient } from './lib/api';
import Layout from './components/Layout';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Articles from './pages/Articles';
import Feeds from './pages/Feeds';
import Settings from './pages/Settings';
function App() {
const isAuthenticated = apiClient.isAuthenticated();
if (!isAuthenticated) {
return <Login />;
}
return (
<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="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Layout>
);
}
export default App;

34
resources/js/app.tsx Normal file
View file

@ -0,0 +1,34 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './bootstrap';
// Create React Query client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
});
// Get the root element
const container = document.getElementById('app');
if (!container) {
throw new Error('Root element not found');
}
const root = createRoot(container);
root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);

21
resources/js/bootstrap.ts Normal file
View file

@ -0,0 +1,21 @@
/**
* Bootstrap file for setting up global configurations
*/
// Set up axios defaults
import axios from 'axios';
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
axios.defaults.headers.common['Accept'] = 'application/json';
axios.defaults.headers.common['Content-Type'] = 'application/json';
// Add CSRF token if available
const token = document.head.querySelector('meta[name="csrf-token"]');
if (token) {
axios.defaults.headers.common['X-CSRF-TOKEN'] = (token as HTMLMetaElement).content;
}
// Set base URL for API calls
axios.defaults.baseURL = '/api/v1';
export default axios;

View file

@ -0,0 +1,150 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import {
Home,
FileText,
Rss,
Settings as SettingsIcon,
LogOut,
Menu,
X
} from 'lucide-react';
import { apiClient } from '../lib/api';
interface LayoutProps {
children: React.ReactNode;
}
const Layout: React.FC<LayoutProps> = ({ children }) => {
const [sidebarOpen, setSidebarOpen] = React.useState(false);
const location = useLocation();
const user = apiClient.getUser();
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: Home },
{ name: 'Articles', href: '/articles', icon: FileText },
{ name: 'Feeds', href: '/feeds', icon: Rss },
{ name: 'Settings', href: '/settings', icon: SettingsIcon },
];
const handleLogout = async () => {
try {
await apiClient.logout();
window.location.reload();
} catch (error) {
console.error('Logout failed:', error);
// Force logout even if API call fails
apiClient.clearAuth();
window.location.reload();
}
};
return (
<div className="min-h-screen bg-gray-50">
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* 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:translate-x-0 lg:static lg:inset-0
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
<div className="flex items-center">
<img className="h-8 w-auto" src="/images/ffr-logo-600.png" alt="FFR" />
<span className="ml-2 text-xl font-semibold text-gray-900">FFR</span>
</div>
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden p-2 rounded-md text-gray-400 hover:text-gray-500"
>
<X className="h-6 w-6" />
</button>
</div>
<nav className="flex-1 px-4 py-4 space-y-2">
{navigation.map((item) => {
const isActive = location.pathname === item.href;
const Icon = item.icon;
return (
<Link
key={item.name}
to={item.href}
className={`
flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors
${isActive
? 'bg-blue-50 text-blue-600 border-r-2 border-blue-600'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}
`}
onClick={() => setSidebarOpen(false)}
>
<Icon className="mr-3 h-5 w-5" />
{item.name}
</Link>
);
})}
</nav>
{/* User section */}
<div className="border-t border-gray-200 p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="h-8 w-8 rounded-full bg-blue-500 flex items-center justify-center">
<span className="text-sm font-medium text-white">
{user?.name?.charAt(0)?.toUpperCase()}
</span>
</div>
</div>
<div className="ml-3 flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{user?.name}
</p>
<p className="text-xs text-gray-500 truncate">
{user?.email}
</p>
</div>
<button
onClick={handleLogout}
className="ml-2 p-2 text-gray-400 hover:text-gray-500 rounded-md"
title="Logout"
>
<LogOut className="h-4 w-4" />
</button>
</div>
</div>
</div>
{/* Main content */}
<div className="lg:pl-64">
{/* Top header for mobile */}
<div className="lg:hidden flex items-center justify-between h-16 px-4 bg-white border-b border-gray-200">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-md text-gray-400 hover:text-gray-500"
>
<Menu className="h-6 w-6" />
</button>
<div className="flex items-center">
<img className="h-8 w-auto" src="/images/ffr-logo-600.png" alt="FFR" />
<span className="ml-2 text-xl font-semibold text-gray-900">FFR</span>
</div>
<div className="w-10" /> {/* Spacer for centering */}
</div>
{/* Page content */}
<main className="flex-1">
{children}
</main>
</div>
</div>
);
};
export default Layout;

288
resources/js/lib/api.ts Normal file
View file

@ -0,0 +1,288 @@
import axios from 'axios';
// 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;
};
}
// User types
export interface User {
id: number;
name: string;
email: string;
}
export interface LoginCredentials {
email: string;
password: string;
}
export interface RegisterData {
name: string;
email: string;
password: string;
password_confirmation: string;
}
export interface AuthResponse {
user: User;
token: string;
token_type: string;
}
// Article types
export interface Article {
id: number;
feed_id: number;
url: string;
title: string;
description: string;
is_valid: boolean;
is_duplicate: boolean;
approval_status: 'pending' | 'approved' | 'rejected';
approved_at: string | null;
approved_by: string | null;
fetched_at: string | null;
validated_at: string | null;
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;
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;
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;
created_at: string;
updated_at: string;
}
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;
}
// API Client class
class ApiClient {
private token: string | null = null;
constructor() {
// Get token from localStorage if available
this.token = localStorage.getItem('auth_token');
this.setupInterceptors();
}
private setupInterceptors() {
// Request interceptor to add auth token
axios.interceptors.request.use((config) => {
if (this.token) {
config.headers.Authorization = `Bearer ${this.token}`;
}
return config;
});
// Response interceptor to handle errors
axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
this.clearAuth();
window.location.href = '/login';
}
return Promise.reject(error);
}
);
}
setAuth(token: string, user: User) {
this.token = token;
localStorage.setItem('auth_token', token);
localStorage.setItem('user', JSON.stringify(user));
}
clearAuth() {
this.token = null;
localStorage.removeItem('auth_token');
localStorage.removeItem('user');
}
getUser(): User | null {
const userStr = localStorage.getItem('user');
return userStr ? JSON.parse(userStr) : null;
}
isAuthenticated(): boolean {
return !!this.token;
}
// Auth endpoints
async login(credentials: LoginCredentials): Promise<AuthResponse> {
const response = await axios.post<ApiResponse<AuthResponse>>('/auth/login', credentials);
return response.data.data;
}
async register(data: RegisterData): Promise<AuthResponse> {
const response = await axios.post<ApiResponse<AuthResponse>>('/auth/register', data);
return response.data.data;
}
async logout(): Promise<void> {
await axios.post('/auth/logout');
this.clearAuth();
}
async me(): Promise<User> {
const response = await axios.get<ApiResponse<{ user: User }>>('/auth/me');
return response.data.data.user;
}
// 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<Feed[]>>('/feeds');
return response.data.data;
}
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;
}
}
export const apiClient = new ApiClient();

View file

@ -0,0 +1,249 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckCircle, XCircle, ExternalLink, Calendar, Tag } from 'lucide-react';
import { apiClient, Article } from '../lib/api';
const Articles: React.FC = () => {
const [page, setPage] = useState(1);
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 handleApprove = (articleId: number) => {
approveMutation.mutate(articleId);
};
const handleReject = (articleId: number) => {
rejectMutation.mutate(articleId);
};
const getStatusBadge = (status: string) => {
switch (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">
<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>
<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>
{article.is_valid !== null && (
<>
<span></span>
<span className={article.is_valid ? 'text-green-600' : 'text-red-600'}>
{article.is_valid ? 'Valid' : 'Invalid'}
</span>
</>
)}
{article.is_duplicate && (
<>
<span></span>
<span className="text-orange-600">Duplicate</span>
</>
)}
</div>
</div>
<div className="flex items-center space-x-3 ml-4">
{getStatusBadge(article.approval_status)}
<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

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

View file

@ -0,0 +1,145 @@
import React from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Rss, Globe, ToggleLeft, ToggleRight, ExternalLink } from 'lucide-react';
import { apiClient, 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

@ -0,0 +1,129 @@
import React, { useState } from 'react';
import { Eye, EyeOff, LogIn } from 'lucide-react';
import { apiClient } from '../lib/api';
const Login: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const authData = await apiClient.login({ email, password });
apiClient.setAuth(authData.token, authData.user);
window.location.reload();
} catch (err: any) {
setError(err.response?.data?.message || 'Login failed. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<img className="h-12 w-auto" src="/images/ffr-logo-600.png" alt="FFR" />
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
Sign in to FFR
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Feed Feed Reader - Article Management System
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<form className="space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<div className="mt-1">
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Enter your email"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1 relative">
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Enter your password"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4 text-gray-400" />
) : (
<Eye className="h-4 w-4 text-gray-400" />
)}
</button>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center items-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium 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"
>
{loading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
) : (
<>
<LogIn className="h-4 w-4 mr-2" />
Sign in
</>
)}
</button>
</div>
</form>
<div className="mt-6">
<div className="text-center text-xs text-gray-500">
Demo credentials: test@example.com / password123
</div>
</div>
</div>
</div>
</div>
);
};
export default Login;

View file

@ -0,0 +1,148 @@
import React from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Settings as SettingsIcon, Save, 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

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'FFR') }}</title>
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="icon" type="image/png" href="/favicon.ico">
<!-- Vite -->
@vite(['resources/css/app.css', 'resources/js/app.tsx'])
</head>
<body class="font-sans antialiased bg-gray-50">
<div id="app"></div>
</body>
</html>

View file

@ -6,6 +6,13 @@
use App\Http\Controllers\SettingsController;
use Illuminate\Support\Facades\Route;
// React SPA - catch all routes and serve the React app
Route::get('/{path?}', function () {
return view('app');
})->where('path', '.*')->name('spa');
// Legacy routes (can be removed once fully migrated to React)
/*
// Onboarding routes
Route::get('/', [OnboardingController::class, 'index'])->name('onboarding.index');
Route::get('/onboarding/platform', [OnboardingController::class, 'platform'])->name('onboarding.platform');
@ -35,3 +42,4 @@
Route::put('/routing/{feed}/{channel}', [App\Http\Controllers\RoutingController::class, 'update'])->name('routing.update');
Route::delete('/routing/{feed}/{channel}', [App\Http\Controllers\RoutingController::class, 'destroy'])->name('routing.destroy');
Route::post('/routing/{feed}/{channel}/toggle', [App\Http\Controllers\RoutingController::class, 'toggle'])->name('routing.toggle');
*/

View file

@ -1,15 +1,16 @@
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
tailwindcss(),
react(),
laravel({
input: [
'resources/css/app.css',
'resources/js/app.js',
'resources/js/routing.js'
'resources/js/app.tsx',
],
refresh: true,
}),