Convert templates to react
This commit is contained in:
parent
bb771d5e14
commit
01493ac0bf
14 changed files with 1434 additions and 6 deletions
20
package.json
20
package.json
|
|
@ -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
32
resources/js/App.tsx
Normal 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
34
resources/js/app.tsx
Normal 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
21
resources/js/bootstrap.ts
Normal 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;
|
||||
150
resources/js/components/Layout.tsx
Normal file
150
resources/js/components/Layout.tsx
Normal 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
288
resources/js/lib/api.ts
Normal 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();
|
||||
249
resources/js/pages/Articles.tsx
Normal file
249
resources/js/pages/Articles.tsx
Normal 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;
|
||||
191
resources/js/pages/Dashboard.tsx
Normal file
191
resources/js/pages/Dashboard.tsx
Normal 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;
|
||||
145
resources/js/pages/Feeds.tsx
Normal file
145
resources/js/pages/Feeds.tsx
Normal 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;
|
||||
129
resources/js/pages/Login.tsx
Normal file
129
resources/js/pages/Login.tsx
Normal 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;
|
||||
148
resources/js/pages/Settings.tsx
Normal file
148
resources/js/pages/Settings.tsx
Normal 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;
|
||||
20
resources/views/app.blade.php
Normal file
20
resources/views/app.blade.php
Normal 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>
|
||||
|
|
@ -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');
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
Loading…
Reference in a new issue