diff --git a/services/news-engine-console/frontend/package-lock.json b/services/news-engine-console/frontend/package-lock.json index 470a5a3..73ee922 100644 --- a/services/news-engine-console/frontend/package-lock.json +++ b/services/news-engine-console/frontend/package-lock.json @@ -12,6 +12,7 @@ "@emotion/styled": "^11.13.0", "@mui/icons-material": "^6.1.3", "@mui/material": "^6.1.3", + "@mui/x-data-grid": "^8.16.0", "@tanstack/react-query": "^5.56.2", "axios": "^1.7.7", "date-fns": "^4.1.0", @@ -1304,6 +1305,229 @@ } } }, + "node_modules/@mui/x-data-grid": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.16.0.tgz", + "integrity": "sha512-yJ+v+E1yI1HxrEUdOfgrUTCxobAFvotGggU6cy6MnM7c7/TPPg9d5mDzjzxb0imOCJ6WyiM/vtd5WKbY/5sUNw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.3", + "@mui/x-internals": "8.16.0", + "@mui/x-virtualizer": "0.2.6", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/x-data-grid/node_modules/@mui/types": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.8.tgz", + "integrity": "sha512-ZNXLBjkPV6ftLCmmRCafak3XmSn8YV0tKE/ZOhzKys7TZXUiE0mZxlH8zKDo6j6TTUaDnuij68gIG+0Ucm7Xhw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-data-grid/node_modules/@mui/utils": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.5.tgz", + "integrity": "sha512-jisvFsEC3sgjUjcPnR4mYfhzjCDIudttSGSbe1o/IXFNu0kZuR+7vqQI0jg8qtcVZBHWrwTfvAZj9MNMumcq1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/types": "^7.4.8", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.16.0.tgz", + "integrity": "sha512-JR53WOFqmQYQzurOpB0H91K7/9uMcte1ooxHxTLGB+97PgB+rKY6siRWvUALGS56XyPV+1a2ALI33hd2E7+Rgg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.3", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mui/x-internals/node_modules/@mui/types": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.8.tgz", + "integrity": "sha512-ZNXLBjkPV6ftLCmmRCafak3XmSn8YV0tKE/ZOhzKys7TZXUiE0mZxlH8zKDo6j6TTUaDnuij68gIG+0Ucm7Xhw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals/node_modules/@mui/utils": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.5.tgz", + "integrity": "sha512-jisvFsEC3sgjUjcPnR4mYfhzjCDIudttSGSbe1o/IXFNu0kZuR+7vqQI0jg8qtcVZBHWrwTfvAZj9MNMumcq1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/types": "^7.4.8", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-virtualizer": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.2.6.tgz", + "integrity": "sha512-t45EHhD9kStSwIYMkqYYQIFbZNVQws9LRANktf0e/+j+MxsRTFk41r0rgiazMSOSugJlCuSh/H8xUUuMCZdtow==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.3", + "@mui/x-internals": "8.16.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mui/x-virtualizer/node_modules/@mui/types": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.8.tgz", + "integrity": "sha512-ZNXLBjkPV6ftLCmmRCafak3XmSn8YV0tKE/ZOhzKys7TZXUiE0mZxlH8zKDo6j6TTUaDnuij68gIG+0Ucm7Xhw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-virtualizer/node_modules/@mui/utils": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.5.tgz", + "integrity": "sha512-jisvFsEC3sgjUjcPnR4mYfhzjCDIudttSGSbe1o/IXFNu0kZuR+7vqQI0jg8qtcVZBHWrwTfvAZj9MNMumcq1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/types": "^7.4.8", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4193,6 +4417,12 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -4567,6 +4797,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", diff --git a/services/news-engine-console/frontend/package.json b/services/news-engine-console/frontend/package.json index 2a26ac8..4c17b60 100644 --- a/services/news-engine-console/frontend/package.json +++ b/services/news-engine-console/frontend/package.json @@ -11,17 +11,18 @@ "type-check": "tsc --noEmit" }, "dependencies": { + "@emotion/react": "^11.13.3", + "@emotion/styled": "^11.13.0", + "@mui/icons-material": "^6.1.3", + "@mui/material": "^6.1.3", + "@mui/x-data-grid": "^8.16.0", + "@tanstack/react-query": "^5.56.2", + "axios": "^1.7.7", + "date-fns": "^4.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.2", - "@mui/material": "^6.1.3", - "@mui/icons-material": "^6.1.3", - "@emotion/react": "^11.13.3", - "@emotion/styled": "^11.13.0", - "@tanstack/react-query": "^5.56.2", - "axios": "^1.7.7", "recharts": "^2.12.7", - "date-fns": "^4.1.0", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/services/news-engine-console/frontend/src/App.tsx b/services/news-engine-console/frontend/src/App.tsx index cf9ddd4..d133eda 100644 --- a/services/news-engine-console/frontend/src/App.tsx +++ b/services/news-engine-console/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom' import { useAuthStore } from './stores/authStore' import Login from './pages/Login' import Dashboard from './pages/Dashboard' +import Keywords from './pages/Keywords' function App() { const { isAuthenticated } = useAuthStore() @@ -24,6 +25,13 @@ function App() { } /> + : + } + /> + {/* Catch all - redirect to dashboard or login */} } /> diff --git a/services/news-engine-console/frontend/src/components/MainLayout.tsx b/services/news-engine-console/frontend/src/components/MainLayout.tsx new file mode 100644 index 0000000..a54c6c2 --- /dev/null +++ b/services/news-engine-console/frontend/src/components/MainLayout.tsx @@ -0,0 +1,175 @@ +import { ReactNode, useState } from 'react' +import { + Box, + Drawer, + AppBar, + Toolbar, + List, + Typography, + Divider, + IconButton, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Button, +} from '@mui/material' +import { + Menu as MenuIcon, + Dashboard as DashboardIcon, + Label as KeywordIcon, + AccountTree as PipelineIcon, + People as PeopleIcon, + Apps as AppsIcon, + Article as ArticleIcon, + BarChart as MonitoringIcon, +} from '@mui/icons-material' +import { useNavigate, useLocation } from 'react-router-dom' +import { useAuthStore } from '../stores/authStore' + +const drawerWidth = 240 + +interface MainLayoutProps { + children: ReactNode +} + +interface MenuItem { + text: string + icon: ReactNode + path: string +} + +const menuItems: MenuItem[] = [ + { text: 'Dashboard', icon: , path: '/dashboard' }, + { text: 'Keywords', icon: , path: '/keywords' }, + { text: 'Pipelines', icon: , path: '/pipelines' }, + { text: 'Users', icon: , path: '/users' }, + { text: 'Applications', icon: , path: '/applications' }, + { text: 'Articles', icon: , path: '/articles' }, + { text: 'Monitoring', icon: , path: '/monitoring' }, +] + +export default function MainLayout({ children }: MainLayoutProps) { + const [mobileOpen, setMobileOpen] = useState(false) + const navigate = useNavigate() + const location = useLocation() + const { user, logout } = useAuthStore() + + const handleDrawerToggle = () => { + setMobileOpen(!mobileOpen) + } + + const handleLogout = async () => { + await logout() + navigate('/login') + } + + const handleNavigate = (path: string) => { + navigate(path) + setMobileOpen(false) + } + + const drawer = ( +
+ + + News Engine + + + + + {menuItems.map((item) => ( + + handleNavigate(item.path)} + > + {item.icon} + + + + ))} + +
+ ) + + return ( + + + + + + + + News Engine Console + + + + {user?.full_name} ({user?.role}) + + + + + + + {/* Mobile drawer */} + + {drawer} + + {/* Desktop drawer */} + + {drawer} + + + + + {children} + + + ) +} diff --git a/services/news-engine-console/frontend/src/pages/Dashboard.tsx b/services/news-engine-console/frontend/src/pages/Dashboard.tsx index 9ad45db..a8d58fb 100644 --- a/services/news-engine-console/frontend/src/pages/Dashboard.tsx +++ b/services/news-engine-console/frontend/src/pages/Dashboard.tsx @@ -1,46 +1,10 @@ -import { Box, Container, Typography, Button, Paper, Grid } from '@mui/material' -import { useNavigate } from 'react-router-dom' -import { useAuthStore } from '../stores/authStore' +import { Box, Typography, Paper, Grid } from '@mui/material' +import MainLayout from '../components/MainLayout' export default function Dashboard() { - const navigate = useNavigate() - const { user, logout } = useAuthStore() - - const handleLogout = async () => { - await logout() - navigate('/login') - } - return ( - - {/* Header */} - - - - - News Engine Console - - - - {user?.full_name} ({user?.role}) - - - - - - - - {/* Main Content */} - + + Dashboard @@ -106,8 +70,8 @@ export default function Dashboard() { Frontend is now up and running! Next steps: -
  • Implement sidebar navigation
  • -
  • Create Keywords management page
  • +
  • Implement sidebar navigation ✅
  • +
  • Create Keywords management page ✅
  • Create Pipelines management page
  • Create Users management page
  • Create Applications management page
  • @@ -116,7 +80,7 @@ export default function Dashboard() { -
    -
    + + ) } diff --git a/services/news-engine-console/frontend/src/pages/Keywords.tsx b/services/news-engine-console/frontend/src/pages/Keywords.tsx new file mode 100644 index 0000000..8ee4e15 --- /dev/null +++ b/services/news-engine-console/frontend/src/pages/Keywords.tsx @@ -0,0 +1,380 @@ +import { useState, useEffect } from 'react' +import { + Box, + Button, + Paper, + Typography, + IconButton, + Chip, + TextField, + MenuItem, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Alert, +} from '@mui/material' +import { + Add as AddIcon, + Edit as EditIcon, + Delete as DeleteIcon, + Search as SearchIcon, + Refresh as RefreshIcon, +} from '@mui/icons-material' +import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid' +import MainLayout from '../components/MainLayout' +import { getKeywords, createKeyword, updateKeyword, deleteKeyword } from '@/api/keywords' +import type { Keyword, KeywordCreate, KeywordUpdate } from '@/types' + +const Keywords = () => { + const [keywords, setKeywords] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [search, setSearch] = useState('') + const [categoryFilter, setCategoryFilter] = useState('all') + const [statusFilter, setStatusFilter] = useState('all') + + // Dialog states + const [openDialog, setOpenDialog] = useState(false) + const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create') + const [selectedKeyword, setSelectedKeyword] = useState(null) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + + // Form states + const [formData, setFormData] = useState({ + keyword: '', + category: 'topics', + status: 'active', + priority: 5, + }) + + useEffect(() => { + fetchKeywords() + }, [search, categoryFilter, statusFilter]) + + const fetchKeywords = async () => { + setLoading(true) + setError(null) + try { + const params: any = {} + if (search) params.search = search + if (categoryFilter !== 'all') params.category = categoryFilter + if (statusFilter !== 'all') params.status = statusFilter + + const data = await getKeywords(params) + setKeywords(data) + } catch (err: any) { + setError(err.message || 'Failed to fetch keywords') + } finally { + setLoading(false) + } + } + + const handleOpenDialog = (mode: 'create' | 'edit', keyword?: Keyword) => { + setDialogMode(mode) + if (mode === 'edit' && keyword) { + setSelectedKeyword(keyword) + setFormData({ + keyword: keyword.keyword, + category: keyword.category, + status: keyword.status, + priority: keyword.priority, + }) + } else { + setSelectedKeyword(null) + setFormData({ + keyword: '', + category: 'topics', + status: 'active', + priority: 5, + }) + } + setOpenDialog(true) + } + + const handleCloseDialog = () => { + setOpenDialog(false) + setSelectedKeyword(null) + setFormData({ + keyword: '', + category: 'topics', + status: 'active', + priority: 5, + }) + } + + const handleSubmit = async () => { + try { + if (dialogMode === 'create') { + await createKeyword(formData) + } else if (selectedKeyword?.id) { + await updateKeyword(selectedKeyword.id, formData as KeywordUpdate) + } + handleCloseDialog() + fetchKeywords() + } catch (err: any) { + setError(err.message || 'Failed to save keyword') + } + } + + const handleDelete = async () => { + if (!selectedKeyword?.id) return + + try { + await deleteKeyword(selectedKeyword.id) + setDeleteDialogOpen(false) + setSelectedKeyword(null) + fetchKeywords() + } catch (err: any) { + setError(err.message || 'Failed to delete keyword') + } + } + + const columns: GridColDef[] = [ + { + field: 'keyword', + headerName: 'Keyword', + flex: 1, + minWidth: 200, + }, + { + field: 'category', + headerName: 'Category', + width: 120, + renderCell: (params: GridRenderCellParams) => { + const colorMap: Record = { + people: 'primary', + topics: 'secondary', + companies: 'success', + } + return ( + + ) + }, + }, + { + field: 'status', + headerName: 'Status', + width: 100, + renderCell: (params: GridRenderCellParams) => { + return ( + + ) + }, + }, + { + field: 'priority', + headerName: 'Priority', + width: 80, + align: 'center', + }, + { + field: 'created_at', + headerName: 'Created At', + width: 180, + valueFormatter: (value) => { + return new Date(value).toLocaleString() + }, + }, + { + field: 'actions', + headerName: 'Actions', + width: 150, + sortable: false, + renderCell: (params: GridRenderCellParams) => ( + + handleOpenDialog('edit', params.row)} + color="primary" + > + + + { + setSelectedKeyword(params.row) + setDeleteDialogOpen(true) + }} + color="error" + > + + + + ), + }, + ] + + return ( + + + {/* Header */} + + + Keywords Management + + + + + {/* Error Alert */} + {error && ( + setError(null)}> + {error} + + )} + + {/* Filters */} + + + setSearch(e.target.value)} + size="small" + sx={{ flexGrow: 1 }} + InputProps={{ + startAdornment: , + }} + /> + setCategoryFilter(e.target.value)} + size="small" + sx={{ minWidth: 150 }} + > + All Categories + People + Topics + Companies + + setStatusFilter(e.target.value)} + size="small" + sx={{ minWidth: 120 }} + > + All Status + Active + Inactive + + + + + + + + {/* Data Grid */} + + + + + {/* Create/Edit Dialog */} + + + {dialogMode === 'create' ? 'Add New Keyword' : 'Edit Keyword'} + + + + setFormData({ ...formData, keyword: e.target.value })} + fullWidth + required + /> + setFormData({ ...formData, category: e.target.value as any })} + fullWidth + required + > + People + Topics + Companies + + setFormData({ ...formData, status: e.target.value as any })} + fullWidth + > + Active + Inactive + + setFormData({ ...formData, priority: parseInt(e.target.value) })} + fullWidth + inputProps={{ min: 1, max: 10 }} + /> + + + + + + + + + {/* Delete Confirmation Dialog */} + setDeleteDialogOpen(false)}> + Confirm Delete + + + Are you sure you want to delete the keyword "{selectedKeyword?.keyword}"? + This action cannot be undone. + + + + + + + + + + ) +} + +export default Keywords