diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 179022a..b2b4f18 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import Layout from './components/Layout'; import Dashboard from './pages/Dashboard'; import Articles from './pages/Articles'; import Feeds from './pages/Feeds'; +import RoutesPage from './pages/Routes'; import Settings from './pages/Settings'; import OnboardingWizard from './pages/onboarding/OnboardingWizard'; @@ -21,6 +22,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 5f07b0d..989e2cc 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -5,6 +5,7 @@ import { FileText, Rss, Settings as SettingsIcon, + Route, Menu, X } from 'lucide-react'; @@ -21,6 +22,7 @@ const Layout: React.FC = ({ children }) => { { name: 'Dashboard', href: '/dashboard', icon: Home }, { name: 'Articles', href: '/articles', icon: FileText }, { name: 'Feeds', href: '/feeds', icon: Rss }, + { name: 'Routes', href: '/routes', icon: Route }, { name: 'Settings', href: '/settings', icon: SettingsIcon }, ]; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ad43f37..d2fed7f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -190,6 +190,7 @@ export interface ChannelRequest { } export interface Route { + id?: number; feed_id: number; platform_channel_id: number; is_active: boolean; @@ -334,6 +335,31 @@ class ApiClient { async refreshArticles(): Promise { await axios.post('/articles/refresh'); } + + // Routes endpoints + async getRoutes(): Promise { + const response = await axios.get>('/routing'); + return response.data.data; + } + + async createRoute(data: RouteRequest): Promise { + const response = await axios.post>('/routing', data); + return response.data.data; + } + + async updateRoute(feedId: number, channelId: number, data: Partial): Promise { + const response = await axios.put>(`/routing/${feedId}/${channelId}`, data); + return response.data.data; + } + + async deleteRoute(feedId: number, channelId: number): Promise { + await axios.delete(`/routing/${feedId}/${channelId}`); + } + + async toggleRoute(feedId: number, channelId: number): Promise { + const response = await axios.post>(`/routing/${feedId}/${channelId}/toggle`); + return response.data.data; + } } export const apiClient = new ApiClient(); \ No newline at end of file diff --git a/frontend/src/pages/Routes.tsx b/frontend/src/pages/Routes.tsx new file mode 100644 index 0000000..b4dd684 --- /dev/null +++ b/frontend/src/pages/Routes.tsx @@ -0,0 +1,405 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Edit2, Trash2, ToggleLeft, ToggleRight, ExternalLink, CheckCircle, XCircle } from 'lucide-react'; +import { apiClient, type Route, type RouteRequest, type Feed, type PlatformChannel } from '../lib/api'; + +const Routes: React.FC = () => { + const [showCreateModal, setShowCreateModal] = useState(false); + const [editingRoute, setEditingRoute] = useState(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 }) => + 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 ( +
+
+
+
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+ ))} +
+
+
+ ); + } + + if (error) { + return ( +
+
+

Failed to load routes

+
+
+ ); + } + + return ( +
+
+
+

Routes

+

+ Manage connections between your feeds and channels +

+
+ +
+ +
+ {routes && routes.length > 0 ? ( + routes.map((route: Route) => ( +
+
+
+
+

+ {route.feed?.name} → {route.platform_channel?.display_name || route.platform_channel?.name} +

+ {route.is_active ? ( + + + Active + + ) : ( + + + Inactive + + )} +
+
+ Priority: {route.priority} + + Feed: {route.feed?.name} + + Channel: {route.platform_channel?.display_name || route.platform_channel?.name} + + Created: {new Date(route.created_at).toLocaleDateString()} +
+ {route.platform_channel?.description && ( +

+ {route.platform_channel.description} +

+ )} +
+
+ + + +
+
+
+ )) + ) : ( +
+
+ +
+

No routes

+

+ Get started by creating a new route to connect feeds with channels. +

+
+ +
+
+ )} +
+ + {/* Create Route Modal */} + {showCreateModal && ( + setShowCreateModal(false)} + onSubmit={(data) => createMutation.mutate(data)} + isLoading={createMutation.isPending} + /> + )} + + {/* Edit Route Modal */} + {editingRoute && ( + setEditingRoute(null)} + onSubmit={(data) => updateMutation.mutate({ + feedId: editingRoute.feed_id, + channelId: editingRoute.platform_channel_id, + data + })} + isLoading={updateMutation.isPending} + /> + )} +
+ ); +}; + +interface CreateRouteModalProps { + feeds: Feed[]; + channels: PlatformChannel[]; + onClose: () => void; + onSubmit: (data: RouteRequest) => void; + isLoading: boolean; +} + +const CreateRouteModal: React.FC = ({ feeds, channels, onClose, onSubmit, isLoading }) => { + const [formData, setFormData] = useState({ + feed_id: 0, + platform_channel_id: 0, + priority: 50, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(formData); + }; + + return ( +
+
+
+

Create New Route

+
+
+ + +
+ +
+ + +
+ +
+ + 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" + /> +

Higher priority routes are processed first

+
+ +
+ + +
+
+
+
+
+ ); +}; + +interface EditRouteModalProps { + route: Route; + onClose: () => void; + onSubmit: (data: Partial) => void; + isLoading: boolean; +} + +const EditRouteModal: React.FC = ({ route, onClose, onSubmit, isLoading }) => { + const [priority, setPriority] = useState(route.priority || 50); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit({ priority }); + }; + + return ( +
+
+
+

Edit Route

+
+

+ Feed: {route.feed?.name} +

+

+ Channel: {route.platform_channel?.display_name || route.platform_channel?.name} +

+
+
+
+ + 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" + /> +

Higher priority routes are processed first

+
+ +
+ + +
+
+
+
+
+ ); +}; + +export default Routes; \ No newline at end of file