feat: Implement Keywords management page with DataGrid
Frontend Phase 2 - Keywords Management: - Add MainLayout component with sidebar navigation - Implement Keywords page with MUI DataGrid - Add Keywords CRUD operations (Create, Edit, Delete dialogs) - Add search and filter functionality (Category, Status) - Install @mui/x-data-grid package for table component - Update routing to include Keywords page - Update Dashboard to use MainLayout - Add navigation menu items for all planned pages Features implemented: - Keywords list with DataGrid table - Add/Edit keyword dialog with form validation - Delete confirmation dialog - Category filter (People, Topics, Companies) - Status filter (Active, Inactive) - Search functionality - Priority management Tested in browser: - Page loads successfully - API integration working (200 OK) - Layout and navigation functional - All UI components rendering correctly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
239
services/news-engine-console/frontend/package-lock.json
generated
239
services/news-engine-console/frontend/package-lock.json
generated
@ -12,6 +12,7 @@
|
|||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
"@mui/icons-material": "^6.1.3",
|
"@mui/icons-material": "^6.1.3",
|
||||||
"@mui/material": "^6.1.3",
|
"@mui/material": "^6.1.3",
|
||||||
|
"@mui/x-data-grid": "^8.16.0",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query": "^5.56.2",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"date-fns": "^4.1.0",
|
"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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"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==",
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@ -4567,6 +4797,15 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/victory-vendor": {
|
||||||
"version": "36.9.2",
|
"version": "36.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||||
|
|||||||
@ -11,17 +11,18 @@
|
|||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.2",
|
"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",
|
"recharts": "^2.12.7",
|
||||||
"date-fns": "^4.1.0",
|
|
||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom'
|
|||||||
import { useAuthStore } from './stores/authStore'
|
import { useAuthStore } from './stores/authStore'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import Keywords from './pages/Keywords'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
@ -24,6 +25,13 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/keywords"
|
||||||
|
element={
|
||||||
|
isAuthenticated ? <Keywords /> : <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Catch all - redirect to dashboard or login */}
|
{/* Catch all - redirect to dashboard or login */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -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: <DashboardIcon />, path: '/dashboard' },
|
||||||
|
{ text: 'Keywords', icon: <KeywordIcon />, path: '/keywords' },
|
||||||
|
{ text: 'Pipelines', icon: <PipelineIcon />, path: '/pipelines' },
|
||||||
|
{ text: 'Users', icon: <PeopleIcon />, path: '/users' },
|
||||||
|
{ text: 'Applications', icon: <AppsIcon />, path: '/applications' },
|
||||||
|
{ text: 'Articles', icon: <ArticleIcon />, path: '/articles' },
|
||||||
|
{ text: 'Monitoring', icon: <MonitoringIcon />, 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 = (
|
||||||
|
<div>
|
||||||
|
<Toolbar>
|
||||||
|
<Typography variant="h6" noWrap component="div">
|
||||||
|
News Engine
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
<Divider />
|
||||||
|
<List>
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<ListItem key={item.text} disablePadding>
|
||||||
|
<ListItemButton
|
||||||
|
selected={location.pathname === item.path}
|
||||||
|
onClick={() => handleNavigate(item.path)}
|
||||||
|
>
|
||||||
|
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||||
|
<ListItemText primary={item.text} />
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex' }}>
|
||||||
|
<AppBar
|
||||||
|
position="fixed"
|
||||||
|
sx={{
|
||||||
|
width: { sm: `calc(100% - ${drawerWidth}px)` },
|
||||||
|
ml: { sm: `${drawerWidth}px` },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar>
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
aria-label="open drawer"
|
||||||
|
edge="start"
|
||||||
|
onClick={handleDrawerToggle}
|
||||||
|
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||||
|
News Engine Console
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{user?.full_name} ({user?.role})
|
||||||
|
</Typography>
|
||||||
|
<Button color="inherit" onClick={handleLogout}>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<Box
|
||||||
|
component="nav"
|
||||||
|
sx={{ width: { sm: drawerWidth }, flexShrink: { sm: 0 } }}
|
||||||
|
aria-label="navigation"
|
||||||
|
>
|
||||||
|
{/* Mobile drawer */}
|
||||||
|
<Drawer
|
||||||
|
variant="temporary"
|
||||||
|
open={mobileOpen}
|
||||||
|
onClose={handleDrawerToggle}
|
||||||
|
ModalProps={{
|
||||||
|
keepMounted: true, // Better open performance on mobile.
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
display: { xs: 'block', sm: 'none' },
|
||||||
|
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{drawer}
|
||||||
|
</Drawer>
|
||||||
|
{/* Desktop drawer */}
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
display: { xs: 'none', sm: 'block' },
|
||||||
|
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||||
|
}}
|
||||||
|
open
|
||||||
|
>
|
||||||
|
{drawer}
|
||||||
|
</Drawer>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
component="main"
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
p: 3,
|
||||||
|
width: { sm: `calc(100% - ${drawerWidth}px)` },
|
||||||
|
minHeight: '100vh',
|
||||||
|
bgcolor: '#f5f5f5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar />
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,46 +1,10 @@
|
|||||||
import { Box, Container, Typography, Button, Paper, Grid } from '@mui/material'
|
import { Box, Typography, Paper, Grid } from '@mui/material'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import MainLayout from '../components/MainLayout'
|
||||||
import { useAuthStore } from '../stores/authStore'
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const navigate = useNavigate()
|
|
||||||
const { user, logout } = useAuthStore()
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await logout()
|
|
||||||
navigate('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: '#f5f5f5' }}>
|
<MainLayout>
|
||||||
{/* Header */}
|
<Box>
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'white',
|
|
||||||
borderBottom: 1,
|
|
||||||
borderColor: 'divider',
|
|
||||||
py: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Container maxWidth="xl">
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Typography variant="h5" component="h1">
|
|
||||||
News Engine Console
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{user?.full_name} ({user?.role})
|
|
||||||
</Typography>
|
|
||||||
<Button variant="outlined" size="small" onClick={handleLogout}>
|
|
||||||
Logout
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
|
||||||
<Typography variant="h4" gutterBottom>
|
<Typography variant="h4" gutterBottom>
|
||||||
Dashboard
|
Dashboard
|
||||||
</Typography>
|
</Typography>
|
||||||
@ -106,8 +70,8 @@ export default function Dashboard() {
|
|||||||
Frontend is now up and running! Next steps:
|
Frontend is now up and running! Next steps:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box component="ul" sx={{ mt: 1 }}>
|
<Box component="ul" sx={{ mt: 1 }}>
|
||||||
<li>Implement sidebar navigation</li>
|
<li>Implement sidebar navigation ✅</li>
|
||||||
<li>Create Keywords management page</li>
|
<li>Create Keywords management page ✅</li>
|
||||||
<li>Create Pipelines management page</li>
|
<li>Create Pipelines management page</li>
|
||||||
<li>Create Users management page</li>
|
<li>Create Users management page</li>
|
||||||
<li>Create Applications management page</li>
|
<li>Create Applications management page</li>
|
||||||
@ -116,7 +80,7 @@ export default function Dashboard() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Container>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
</MainLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
380
services/news-engine-console/frontend/src/pages/Keywords.tsx
Normal file
380
services/news-engine-console/frontend/src/pages/Keywords.tsx
Normal file
@ -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<Keyword[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<string>('all')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
|
||||||
|
// Dialog states
|
||||||
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
|
const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create')
|
||||||
|
const [selectedKeyword, setSelectedKeyword] = useState<Keyword | null>(null)
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
const [formData, setFormData] = useState<KeywordCreate>({
|
||||||
|
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<string, 'primary' | 'secondary' | 'success'> = {
|
||||||
|
people: 'primary',
|
||||||
|
topics: 'secondary',
|
||||||
|
companies: 'success',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={params.value}
|
||||||
|
color={colorMap[params.value] || 'default'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
headerName: 'Status',
|
||||||
|
width: 100,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={params.value}
|
||||||
|
color={params.value === 'active' ? 'success' : 'default'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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) => (
|
||||||
|
<Box>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleOpenDialog('edit', params.row)}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedKeyword(params.row)
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainLayout>
|
||||||
|
<Box>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ mb: 3, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Typography variant="h4" component="h1">
|
||||||
|
Keywords Management
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => handleOpenDialog('create')}
|
||||||
|
>
|
||||||
|
Add Keyword
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Paper sx={{ p: 2, mb: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
placeholder="Search keywords..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ flexGrow: 1 }}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <SearchIcon sx={{ color: 'action.active', mr: 1 }} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Category"
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ minWidth: 150 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="all">All Categories</MenuItem>
|
||||||
|
<MenuItem value="people">People</MenuItem>
|
||||||
|
<MenuItem value="topics">Topics</MenuItem>
|
||||||
|
<MenuItem value="companies">Companies</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Status"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ minWidth: 120 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="all">All Status</MenuItem>
|
||||||
|
<MenuItem value="active">Active</MenuItem>
|
||||||
|
<MenuItem value="inactive">Inactive</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<IconButton onClick={fetchKeywords} color="primary">
|
||||||
|
<RefreshIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Data Grid */}
|
||||||
|
<Paper sx={{ height: 600, width: '100%' }}>
|
||||||
|
<DataGrid
|
||||||
|
rows={keywords}
|
||||||
|
columns={columns}
|
||||||
|
loading={loading}
|
||||||
|
pageSizeOptions={[10, 25, 50, 100]}
|
||||||
|
initialState={{
|
||||||
|
pagination: {
|
||||||
|
paginationModel: { pageSize: 25 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
sx={{
|
||||||
|
'& .MuiDataGrid-cell:focus': {
|
||||||
|
outline: 'none',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Create/Edit Dialog */}
|
||||||
|
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>
|
||||||
|
{dialogMode === 'create' ? 'Add New Keyword' : 'Edit Keyword'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Keyword"
|
||||||
|
value={formData.keyword}
|
||||||
|
onChange={(e) => setFormData({ ...formData, keyword: e.target.value })}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Category"
|
||||||
|
value={formData.category}
|
||||||
|
onChange={(e) => setFormData({ ...formData, category: e.target.value as any })}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<MenuItem value="people">People</MenuItem>
|
||||||
|
<MenuItem value="topics">Topics</MenuItem>
|
||||||
|
<MenuItem value="companies">Companies</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Status"
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<MenuItem value="active">Active</MenuItem>
|
||||||
|
<MenuItem value="inactive">Inactive</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="Priority"
|
||||||
|
type="number"
|
||||||
|
value={formData.priority}
|
||||||
|
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) })}
|
||||||
|
fullWidth
|
||||||
|
inputProps={{ min: 1, max: 10 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCloseDialog}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} variant="contained" disabled={!formData.keyword}>
|
||||||
|
{dialogMode === 'create' ? 'Create' : 'Update'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||||
|
<DialogTitle>Confirm Delete</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
Are you sure you want to delete the keyword "{selectedKeyword?.keyword}"?
|
||||||
|
This action cannot be undone.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleDelete} variant="contained" color="error">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
</MainLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Keywords
|
||||||
Reference in New Issue
Block a user