feat: Implement Phase 2 Frontend basic structure

Frontend Setup:
- Vite + React 18 + TypeScript configuration
- Material-UI v7 integration
- React Query for data fetching
- Zustand for state management
- React Router for routing

Project Configuration:
- package.json with all dependencies (React, MUI, TanStack Query, Zustand, etc.)
- tsconfig.json with path aliases (@/components, @/pages, etc.)
- vite.config.ts with dev server and proxy settings
- Dockerfile and Dockerfile.dev for production and development
- nginx.conf for production deployment
- .env and .gitignore files
- docker-compose.yml for local development

TypeScript Types:
- Complete type definitions for all API models
- User, Keyword, Pipeline, Application types
- Monitoring and system status types
- API response and pagination types

API Client Implementation:
- axios client with interceptors
- Token refresh logic
- Error handling
- Auto token injection
- Complete API service functions:
  * users.ts (11 endpoints)
  * keywords.ts (8 endpoints)
  * pipelines.ts (11 endpoints)
  * applications.ts (7 endpoints)
  * monitoring.ts (8 endpoints)

State Management:
- authStore with Zustand
- Login/logout functionality
- Token persistence
- Current user management

Pages Implemented:
- Login page with MUI components
- Dashboard page with basic layout
- App.tsx with protected routes

Docker Configuration:
- docker-compose.yml for backend + frontend
- Dockerfile for production build
- Dockerfile.dev for development hot reload

Files Created: 23 files
- Frontend structure: src/{api,pages,stores,types}
- Configuration files: 8 files
- Docker files: 3 files

Next Steps:
- Test frontend in Docker environment
- Implement sidebar navigation
- Create full management pages (Keywords, Pipelines, Users, etc.)
- Connect to backend API and test authentication

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2025-11-04 20:51:23 +09:00
parent a09ea72c00
commit 94bcf9fe9f
23 changed files with 1487 additions and 0 deletions

View File

@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:8101
VITE_APP_TITLE=News Engine Console

View File

@ -0,0 +1,29 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment variables
.env
.env.local
.env.production

View File

@ -0,0 +1,29 @@
# Multi-stage build for production
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json ./
# Install dependencies
RUN npm install
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,18 @@
# Development Dockerfile for frontend
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package.json ./
# Install dependencies
RUN npm install
# Copy source code
COPY . .
EXPOSE 3000
# Start development server
CMD ["npm", "run", "dev"]

View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>News Engine Console</title>
<meta name="description" content="News Engine Console - Manage your news pipelines, keywords, and content generation" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,34 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Enable gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# SPA routing - fallback to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend
location /api {
proxy_pass http://news-engine-console-backend:8101;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View File

@ -0,0 +1,39 @@
{
"name": "news-engine-console-frontend",
"version": "1.0.0",
"description": "News Engine Console - Frontend (React + TypeScript + MUI v7)",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"type-check": "tsc --noEmit"
},
"dependencies": {
"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": {
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.8.0",
"@typescript-eslint/parser": "^8.8.0",
"@vitejs/plugin-react": "^4.3.2",
"eslint": "^9.11.1",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.12",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}

View File

@ -0,0 +1,33 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from './stores/authStore'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
function App() {
const { isAuthenticated } = useAuthStore()
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
isAuthenticated ? <Navigate to="/dashboard" replace /> : <Navigate to="/login" replace />
}
/>
<Route
path="/dashboard"
element={
isAuthenticated ? <Dashboard /> : <Navigate to="/login" replace />
}
/>
{/* Catch all - redirect to dashboard or login */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
export default App

View File

@ -0,0 +1,66 @@
import apiClient from './client'
import type {
Application,
ApplicationCreate,
ApplicationUpdate,
ApplicationSecretResponse,
} from '@/types'
export const getApplications = async (params?: {
skip?: number
limit?: number
}): Promise<Application[]> => {
const response = await apiClient.get<Application[]>('/api/v1/applications/', { params })
return response.data
}
export const getApplication = async (applicationId: string): Promise<Application> => {
const response = await apiClient.get<Application>(
`/api/v1/applications/${applicationId}`
)
return response.data
}
export const createApplication = async (
applicationData: ApplicationCreate
): Promise<Application> => {
const response = await apiClient.post<Application>(
'/api/v1/applications/',
applicationData
)
return response.data
}
export const updateApplication = async (
applicationId: string,
applicationData: ApplicationUpdate
): Promise<Application> => {
const response = await apiClient.put<Application>(
`/api/v1/applications/${applicationId}`,
applicationData
)
return response.data
}
export const deleteApplication = async (
applicationId: string
): Promise<{ message: string }> => {
const response = await apiClient.delete<{ message: string }>(
`/api/v1/applications/${applicationId}`
)
return response.data
}
export const regenerateSecret = async (
applicationId: string
): Promise<ApplicationSecretResponse> => {
const response = await apiClient.post<ApplicationSecretResponse>(
`/api/v1/applications/${applicationId}/regenerate-secret`
)
return response.data
}
export const getMyApplications = async (): Promise<Application[]> => {
const response = await apiClient.get<Application[]>('/api/v1/applications/my-apps')
return response.data
}

View File

@ -0,0 +1,78 @@
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios'
import type { ApiError } from '@/types'
// Create axios instance with default config
const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8101',
headers: {
'Content-Type': 'application/json',
},
timeout: 30000, // 30 seconds
})
// Request interceptor - add auth token
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor - handle errors and token refresh
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError<ApiError>) => {
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean }
// Handle 401 Unauthorized - token expired
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
try {
const refreshToken = localStorage.getItem('refresh_token')
if (!refreshToken) {
throw new Error('No refresh token available')
}
// Try to refresh the token
const response = await axios.post(
`${import.meta.env.VITE_API_URL || 'http://localhost:8101'}/api/v1/users/refresh`,
{ refresh_token: refreshToken }
)
const { access_token, refresh_token: new_refresh_token } = response.data
// Store new tokens
localStorage.setItem('access_token', access_token)
if (new_refresh_token) {
localStorage.setItem('refresh_token', new_refresh_token)
}
// Retry original request with new token
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${access_token}`
}
return apiClient(originalRequest)
} catch (refreshError) {
// Refresh failed - redirect to login
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user')
window.location.href = '/login'
return Promise.reject(refreshError)
}
}
// Handle other errors
const errorMessage = error.response?.data?.detail || error.message || 'An error occurred'
return Promise.reject({ message: errorMessage, status: error.response?.status })
}
)
export default apiClient

View File

@ -0,0 +1,7 @@
// Export all API modules
export * from './users'
export * from './keywords'
export * from './pipelines'
export * from './applications'
export * from './monitoring'
export { default as apiClient } from './client'

View File

@ -0,0 +1,72 @@
import apiClient from './client'
import type {
Keyword,
KeywordCreate,
KeywordUpdate,
KeywordStats,
KeywordBulkCreate,
} from '@/types'
export const getKeywords = async (params?: {
category?: string
status?: string
search?: string
skip?: number
limit?: number
sort_by?: string
sort_order?: 'asc' | 'desc'
}): Promise<Keyword[]> => {
const response = await apiClient.get<Keyword[]>('/api/v1/keywords/', { params })
return response.data
}
export const getKeyword = async (keywordId: string): Promise<Keyword> => {
const response = await apiClient.get<Keyword>(`/api/v1/keywords/${keywordId}`)
return response.data
}
export const createKeyword = async (keywordData: KeywordCreate): Promise<Keyword> => {
const response = await apiClient.post<Keyword>('/api/v1/keywords/', keywordData)
return response.data
}
export const updateKeyword = async (
keywordId: string,
keywordData: KeywordUpdate
): Promise<Keyword> => {
const response = await apiClient.put<Keyword>(
`/api/v1/keywords/${keywordId}`,
keywordData
)
return response.data
}
export const deleteKeyword = async (keywordId: string): Promise<{ message: string }> => {
const response = await apiClient.delete<{ message: string }>(
`/api/v1/keywords/${keywordId}`
)
return response.data
}
export const toggleKeywordStatus = async (keywordId: string): Promise<Keyword> => {
const response = await apiClient.post<Keyword>(`/api/v1/keywords/${keywordId}/toggle`)
return response.data
}
export const getKeywordStats = async (keywordId: string): Promise<KeywordStats> => {
const response = await apiClient.get<KeywordStats>(
`/api/v1/keywords/${keywordId}/stats`
)
return response.data
}
export const bulkCreateKeywords = async (
bulkData: KeywordBulkCreate
): Promise<{ created: number; failed: number; errors: string[] }> => {
const response = await apiClient.post<{
created: number
failed: number
errors: string[]
}>('/api/v1/keywords/bulk', bulkData)
return response.data
}

View File

@ -0,0 +1,67 @@
import apiClient from './client'
import type {
MonitoringOverview,
SystemStatus,
ServiceStatus,
SystemMetrics,
DatabaseStats,
LogEntry,
PipelineLog,
} from '@/types'
export const getMonitoringOverview = async (): Promise<MonitoringOverview> => {
const response = await apiClient.get<MonitoringOverview>('/api/v1/monitoring/')
return response.data
}
export const getHealthCheck = async (): Promise<SystemStatus> => {
const response = await apiClient.get<SystemStatus>('/api/v1/monitoring/health')
return response.data
}
export const getSystemStatus = async (): Promise<SystemMetrics> => {
const response = await apiClient.get<SystemMetrics>('/api/v1/monitoring/system')
return response.data
}
export const getServiceStatus = async (): Promise<ServiceStatus[]> => {
const response = await apiClient.get<ServiceStatus[]>('/api/v1/monitoring/services')
return response.data
}
export const getDatabaseStats = async (): Promise<DatabaseStats> => {
const response = await apiClient.get<DatabaseStats>('/api/v1/monitoring/database')
return response.data
}
export const getRecentLogs = async (params?: { limit?: number }): Promise<LogEntry[]> => {
const response = await apiClient.get<LogEntry[]>('/api/v1/monitoring/logs/recent', {
params,
})
return response.data
}
export const getMetrics = async (): Promise<{
cpu: number
memory: number
disk: number
timestamp: string
}> => {
const response = await apiClient.get<{
cpu: number
memory: number
disk: number
timestamp: string
}>('/api/v1/monitoring/metrics')
return response.data
}
export const getPipelineActivity = async (params?: {
limit?: number
}): Promise<PipelineLog[]> => {
const response = await apiClient.get<PipelineLog[]>(
'/api/v1/monitoring/pipelines/activity',
{ params }
)
return response.data
}

View File

@ -0,0 +1,90 @@
import apiClient from './client'
import type {
Pipeline,
PipelineCreate,
PipelineUpdate,
PipelineLog,
PipelineType,
} from '@/types'
export const getPipelines = async (params?: {
type?: string
status?: string
skip?: number
limit?: number
}): Promise<Pipeline[]> => {
const response = await apiClient.get<Pipeline[]>('/api/v1/pipelines/', { params })
return response.data
}
export const getPipeline = async (pipelineId: string): Promise<Pipeline> => {
const response = await apiClient.get<Pipeline>(`/api/v1/pipelines/${pipelineId}`)
return response.data
}
export const createPipeline = async (pipelineData: PipelineCreate): Promise<Pipeline> => {
const response = await apiClient.post<Pipeline>('/api/v1/pipelines/', pipelineData)
return response.data
}
export const updatePipeline = async (
pipelineId: string,
pipelineData: PipelineUpdate
): Promise<Pipeline> => {
const response = await apiClient.put<Pipeline>(
`/api/v1/pipelines/${pipelineId}`,
pipelineData
)
return response.data
}
export const deletePipeline = async (pipelineId: string): Promise<{ message: string }> => {
const response = await apiClient.delete<{ message: string }>(
`/api/v1/pipelines/${pipelineId}`
)
return response.data
}
export const startPipeline = async (pipelineId: string): Promise<Pipeline> => {
const response = await apiClient.post<Pipeline>(`/api/v1/pipelines/${pipelineId}/start`)
return response.data
}
export const stopPipeline = async (pipelineId: string): Promise<Pipeline> => {
const response = await apiClient.post<Pipeline>(`/api/v1/pipelines/${pipelineId}/stop`)
return response.data
}
export const restartPipeline = async (pipelineId: string): Promise<Pipeline> => {
const response = await apiClient.post<Pipeline>(
`/api/v1/pipelines/${pipelineId}/restart`
)
return response.data
}
export const getPipelineLogs = async (
pipelineId: string,
params?: { limit?: number }
): Promise<PipelineLog[]> => {
const response = await apiClient.get<PipelineLog[]>(
`/api/v1/pipelines/${pipelineId}/logs`,
{ params }
)
return response.data
}
export const updatePipelineConfig = async (
pipelineId: string,
config: Record<string, any>
): Promise<Pipeline> => {
const response = await apiClient.put<Pipeline>(
`/api/v1/pipelines/${pipelineId}/config`,
config
)
return response.data
}
export const getPipelineTypes = async (): Promise<PipelineType[]> => {
const response = await apiClient.get<PipelineType[]>('/api/v1/pipelines/types')
return response.data
}

View File

@ -0,0 +1,81 @@
import apiClient from './client'
import type {
User,
UserCreate,
UserUpdate,
UserLogin,
TokenResponse,
} from '@/types'
// Authentication
export const login = async (credentials: UserLogin): Promise<TokenResponse> => {
const formData = new URLSearchParams()
formData.append('username', credentials.username)
formData.append('password', credentials.password)
const response = await apiClient.post<TokenResponse>('/api/v1/users/login', formData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
return response.data
}
export const register = async (userData: UserCreate): Promise<User> => {
const response = await apiClient.post<User>('/api/v1/users/register', userData)
return response.data
}
export const refreshToken = async (refreshToken: string): Promise<TokenResponse> => {
const response = await apiClient.post<TokenResponse>('/api/v1/users/refresh', {
refresh_token: refreshToken,
})
return response.data
}
export const logout = async (): Promise<void> => {
await apiClient.post('/api/v1/users/logout')
}
// Current user
export const getCurrentUser = async (): Promise<User> => {
const response = await apiClient.get<User>('/api/v1/users/me')
return response.data
}
export const updateCurrentUser = async (userData: UserUpdate): Promise<User> => {
const response = await apiClient.put<User>('/api/v1/users/me', userData)
return response.data
}
// User management
export const getUsers = async (params?: {
role?: string
disabled?: boolean
search?: string
skip?: number
limit?: number
}): Promise<User[]> => {
const response = await apiClient.get<User[]>('/api/v1/users/', { params })
return response.data
}
export const getUser = async (userId: string): Promise<User> => {
const response = await apiClient.get<User>(`/api/v1/users/${userId}`)
return response.data
}
export const createUser = async (userData: UserCreate): Promise<User> => {
const response = await apiClient.post<User>('/api/v1/users/', userData)
return response.data
}
export const updateUser = async (userId: string, userData: UserUpdate): Promise<User> => {
const response = await apiClient.put<User>(`/api/v1/users/${userId}`, userData)
return response.data
}
export const deleteUser = async (userId: string): Promise<{ message: string }> => {
const response = await apiClient.delete<{ message: string }>(`/api/v1/users/${userId}`)
return response.data
}

View File

@ -0,0 +1,55 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import CssBaseline from '@mui/material/CssBaseline'
import { ThemeProvider, createTheme } from '@mui/material/styles'
import App from './App'
// Create a client for React Query
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000, // 5 minutes
},
},
})
// Create MUI theme
const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#1976d2',
},
secondary: {
main: '#dc004e',
},
},
typography: {
fontFamily: [
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'sans-serif',
].join(','),
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
</QueryClientProvider>
</React.StrictMode>
)

View File

@ -0,0 +1,122 @@
import { Box, Container, Typography, Button, Paper, Grid } from '@mui/material'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
export default function Dashboard() {
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const handleLogout = async () => {
await logout()
navigate('/login')
}
return (
<Box sx={{ minHeight: '100vh', bgcolor: '#f5f5f5' }}>
{/* Header */}
<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>
Dashboard
</Typography>
<Grid container spacing={3} sx={{ mt: 2 }}>
<Grid item xs={12} md={6} lg={3}>
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="h6" color="text.secondary" gutterBottom>
Keywords
</Typography>
<Typography variant="h3">0</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Total keywords
</Typography>
</Paper>
</Grid>
<Grid item xs={12} md={6} lg={3}>
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="h6" color="text.secondary" gutterBottom>
Pipelines
</Typography>
<Typography variant="h3">0</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Active pipelines
</Typography>
</Paper>
</Grid>
<Grid item xs={12} md={6} lg={3}>
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="h6" color="text.secondary" gutterBottom>
Users
</Typography>
<Typography variant="h3">1</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Registered users
</Typography>
</Paper>
</Grid>
<Grid item xs={12} md={6} lg={3}>
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="h6" color="text.secondary" gutterBottom>
Applications
</Typography>
<Typography variant="h3">0</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
OAuth apps
</Typography>
</Paper>
</Grid>
<Grid item xs={12}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Welcome to News Engine Console
</Typography>
<Typography variant="body1" color="text.secondary">
This is your central dashboard for managing news pipelines, keywords, users, and applications.
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Frontend is now up and running! Next steps:
</Typography>
<Box component="ul" sx={{ mt: 1 }}>
<li>Implement sidebar navigation</li>
<li>Create Keywords management page</li>
<li>Create Pipelines management page</li>
<li>Create Users management page</li>
<li>Create Applications management page</li>
<li>Create Monitoring page</li>
</Box>
</Paper>
</Grid>
</Grid>
</Container>
</Box>
)
}

View File

@ -0,0 +1,124 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Box,
Card,
CardContent,
TextField,
Button,
Typography,
Alert,
Container,
Link,
} from '@mui/material'
import { useAuthStore } from '../stores/authStore'
export default function Login() {
const navigate = useNavigate()
const { login, isLoading, error, clearError } = useAuthStore()
const [formData, setFormData] = useState({
username: '',
password: '',
})
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
})
clearError()
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
await login(formData)
navigate('/dashboard')
} catch (error) {
console.error('Login failed:', error)
}
}
return (
<Container maxWidth="sm">
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Card sx={{ width: '100%', maxWidth: 450 }}>
<CardContent sx={{ p: 4 }}>
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
News Engine Console
</Typography>
<Typography variant="body2" color="text.secondary">
Sign in to manage your news pipelines
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="Username"
name="username"
value={formData.username}
onChange={handleChange}
margin="normal"
required
autoFocus
autoComplete="username"
/>
<TextField
fullWidth
label="Password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
margin="normal"
required
autoComplete="current-password"
/>
<Button
type="submit"
fullWidth
variant="contained"
size="large"
disabled={isLoading}
sx={{ mt: 3, mb: 2 }}
>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
<Box sx={{ textAlign: 'center', mt: 2 }}>
<Typography variant="body2" color="text.secondary">
Default credentials: admin / admin123
</Typography>
</Box>
<Box sx={{ textAlign: 'center', mt: 2 }}>
<Link href="#" variant="body2" underline="hover">
Forgot password?
</Link>
</Box>
</form>
</CardContent>
</Card>
</Box>
</Container>
)
}

View File

@ -0,0 +1,148 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User, UserLogin } from '@/types'
import * as authApi from '@/api/users'
interface AuthState {
user: User | null
accessToken: string | null
refreshToken: string | null
isAuthenticated: boolean
isLoading: boolean
error: string | null
// Actions
login: (credentials: UserLogin) => Promise<void>
logout: () => Promise<void>
register: (userData: any) => Promise<void>
fetchCurrentUser: () => Promise<void>
updateCurrentUser: (userData: any) => Promise<void>
clearError: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: async (credentials: UserLogin) => {
try {
set({ isLoading: true, error: null })
const tokenResponse = await authApi.login(credentials)
// Store tokens in localStorage
localStorage.setItem('access_token', tokenResponse.access_token)
localStorage.setItem('refresh_token', tokenResponse.refresh_token)
// Fetch user details
const user = await authApi.getCurrentUser()
set({
user,
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
isAuthenticated: true,
isLoading: false,
})
} catch (error: any) {
set({
error: error.message || 'Login failed',
isLoading: false,
isAuthenticated: false,
})
throw error
}
},
logout: async () => {
try {
await authApi.logout()
} catch (error) {
console.error('Logout error:', error)
} finally {
// Clear tokens from localStorage
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user')
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
error: null,
})
}
},
register: async (userData: any) => {
try {
set({ isLoading: true, error: null })
await authApi.register(userData)
set({ isLoading: false })
} catch (error: any) {
set({
error: error.message || 'Registration failed',
isLoading: false,
})
throw error
}
},
fetchCurrentUser: async () => {
try {
set({ isLoading: true, error: null })
const user = await authApi.getCurrentUser()
set({
user,
isAuthenticated: true,
isLoading: false,
})
} catch (error: any) {
set({
error: error.message || 'Failed to fetch user',
isLoading: false,
isAuthenticated: false,
})
throw error
}
},
updateCurrentUser: async (userData: any) => {
try {
set({ isLoading: true, error: null })
const updatedUser = await authApi.updateCurrentUser(userData)
set({
user: updatedUser,
isLoading: false,
})
} catch (error: any) {
set({
error: error.message || 'Failed to update user',
isLoading: false,
})
throw error
}
},
clearError: () => {
set({ error: null })
},
}),
{
name: 'auth-storage',
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated,
}),
}
)
)

View File

@ -0,0 +1,264 @@
// =====================================================
// User Types
// =====================================================
export interface User {
id?: string
username: string
email: string
full_name: string
role: 'admin' | 'editor' | 'viewer'
disabled: boolean
created_at: string
last_login?: string
}
export interface UserCreate {
username: string
email: string
password: string
full_name: string
role?: 'admin' | 'editor' | 'viewer'
}
export interface UserUpdate {
email?: string
password?: string
full_name?: string
role?: 'admin' | 'editor' | 'viewer'
disabled?: boolean
}
export interface UserLogin {
username: string
password: string
}
export interface TokenResponse {
access_token: string
refresh_token: string
token_type: string
}
// =====================================================
// Keyword Types
// =====================================================
export interface Keyword {
id?: string
keyword: string
category: 'people' | 'topics' | 'companies'
status: 'active' | 'inactive'
pipeline_type: string
priority: number
metadata: Record<string, any>
created_at: string
updated_at: string
created_by?: string
}
export interface KeywordCreate {
keyword: string
category: 'people' | 'topics' | 'companies'
status?: 'active' | 'inactive'
pipeline_type?: string
priority?: number
metadata?: Record<string, any>
}
export interface KeywordUpdate {
keyword?: string
category?: 'people' | 'topics' | 'companies'
status?: 'active' | 'inactive'
pipeline_type?: string
priority?: number
metadata?: Record<string, any>
}
export interface KeywordStats {
keyword_id: string
keyword: string
total_articles: number
total_translations: number
total_images: number
last_processed: string | null
success_rate: number
}
export interface KeywordBulkCreate {
keywords: KeywordCreate[]
}
// =====================================================
// Pipeline Types
// =====================================================
export interface PipelineStats {
total_runs: number
successful_runs: number
failed_runs: number
last_success: string | null
last_failure: string | null
avg_duration_seconds: number
}
export interface Pipeline {
id?: string
name: string
type: 'rss_collector' | 'translator' | 'image_generator'
status: 'running' | 'stopped' | 'error'
config: Record<string, any>
schedule?: string
stats: PipelineStats
last_run?: string
next_run?: string
created_at: string
updated_at: string
created_by?: string
}
export interface PipelineCreate {
name: string
type: 'rss_collector' | 'translator' | 'image_generator'
config?: Record<string, any>
schedule?: string
}
export interface PipelineUpdate {
name?: string
type?: 'rss_collector' | 'translator' | 'image_generator'
status?: 'running' | 'stopped' | 'error'
config?: Record<string, any>
schedule?: string
}
export interface PipelineLog {
timestamp: string
level: 'info' | 'warning' | 'error'
message: string
details?: Record<string, any>
}
export interface PipelineType {
type: string
name: string
description: string
}
// =====================================================
// Application Types (OAuth2)
// =====================================================
export interface Application {
id?: string
name: string
client_id: string
client_secret?: string
redirect_uris: string[]
grant_types: string[]
scopes: string[]
owner_id: string
created_at: string
updated_at?: string
}
export interface ApplicationCreate {
name: string
redirect_uris?: string[]
grant_types?: string[]
scopes?: string[]
}
export interface ApplicationUpdate {
name?: string
redirect_uris?: string[]
grant_types?: string[]
scopes?: string[]
}
export interface ApplicationSecretResponse {
client_id: string
client_secret: string
message: string
}
// =====================================================
// Monitoring Types
// =====================================================
export interface SystemStatus {
status: 'healthy' | 'degraded' | 'down'
timestamp: string
uptime_seconds: number
version: string
}
export interface ServiceStatus {
name: string
status: 'up' | 'down'
response_time_ms?: number
last_check: string
details?: Record<string, any>
}
export interface DatabaseStats {
collections: number
total_documents: number
total_size_mb: number
indexes: number
}
export interface SystemMetrics {
cpu_percent: number
memory_percent: number
memory_used_mb: number
memory_total_mb: number
disk_percent: number
disk_used_gb: number
disk_total_gb: number
}
export interface LogEntry {
timestamp: string
level: 'info' | 'warning' | 'error' | 'debug'
message: string
source?: string
details?: Record<string, any>
}
export interface MonitoringOverview {
system: SystemStatus
services: ServiceStatus[]
metrics: SystemMetrics
recent_logs: LogEntry[]
}
// =====================================================
// API Response Types
// =====================================================
export interface ApiError {
detail: string
status_code?: number
}
export interface PaginationParams {
skip?: number
limit?: number
}
export interface PaginatedResponse<T> {
items: T[]
total: number
skip: number
limit: number
}
// =====================================================
// Common Types
// =====================================================
export type Status = 'active' | 'inactive' | 'running' | 'stopped' | 'error'
export type Role = 'admin' | 'editor' | 'viewer'
export type Category = 'people' | 'topics' | 'companies'
export type PipelineTypeEnum = 'rss_collector' | 'translator' | 'image_generator'

View File

@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/pages/*": ["./src/pages/*"],
"@/api/*": ["./src/api/*"],
"@/hooks/*": ["./src/hooks/*"],
"@/types/*": ["./src/types/*"],
"@/utils/*": ["./src/utils/*"]
}
},
"include": ["src"]
}

View File

@ -0,0 +1,33 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@/components': path.resolve(__dirname, './src/components'),
'@/pages': path.resolve(__dirname, './src/pages'),
'@/api': path.resolve(__dirname, './src/api'),
'@/hooks': path.resolve(__dirname, './src/hooks'),
'@/types': path.resolve(__dirname, './src/types'),
'@/utils': path.resolve(__dirname, './src/utils'),
},
},
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:8101',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
})