1085 lines
33 KiB
Markdown
1085 lines
33 KiB
Markdown
# Playlist Monitor Service - UI Integration Guide
|
|
|
|
## 📋 Table of Contents
|
|
1. [UI Options Overview](#ui-options-overview)
|
|
2. [Option 1: Standalone React App](#option-1-standalone-react-app)
|
|
3. [Option 2: Extend MeTube Angular](#option-2-extend-metube-angular)
|
|
4. [Option 3: Simple HTML Dashboard](#option-3-simple-html-dashboard)
|
|
5. [API Integration Examples](#api-integration-examples)
|
|
6. [UI Components Design](#ui-components-design)
|
|
7. [Styling Guidelines](#styling-guidelines)
|
|
8. [Responsive Design](#responsive-design)
|
|
9. [Testing UI](#testing-ui)
|
|
10. [Deployment Options](#deployment-options)
|
|
|
|
## 🎨 UI Options Overview
|
|
|
|
Since the Playlist Monitor Service is a separate microservice, you have several UI integration options:
|
|
|
|
### Option Comparison
|
|
| Option | Complexity | Maintenance | Features | Best For |
|
|
|--------|------------|-------------|----------|----------|
|
|
| **React App** | Medium | Low | Full-featured | New projects, standalone |
|
|
| **Extend MeTube** | High | High | Integrated | Existing MeTube users |
|
|
| **Simple HTML** | Low | Low | Basic | Quick setup, minimal needs |
|
|
|
|
## 🚀 Option 1: Standalone React App (Recommended)
|
|
|
|
### Quick Setup (5 minutes)
|
|
```bash
|
|
# Create React app with TypeScript
|
|
cd /root/workspace/tubewatch
|
|
npx create-react-app playlist-monitor-ui --template typescript
|
|
cd playlist-monitor-ui
|
|
|
|
# Install UI dependencies
|
|
npm install @mui/material @emotion/react @emotion/styled axios react-query
|
|
npm install @mui/icons-material @mui/x-data-grid date-fns
|
|
npm install recharts # For charts
|
|
npm install react-router-dom # For routing
|
|
|
|
# Start development server
|
|
npm start
|
|
```
|
|
|
|
### Project Structure
|
|
```
|
|
playlist-monitor-ui/
|
|
├── src/
|
|
│ ├── components/ # Reusable components
|
|
│ │ ├── PlaylistCard.tsx
|
|
│ │ ├── VideoTable.tsx
|
|
│ │ └── StatusChart.tsx
|
|
│ ├── pages/ # Main pages
|
|
│ │ ├── Dashboard.tsx
|
|
│ │ ├── Playlists.tsx
|
|
│ │ ├── PlaylistDetail.tsx
|
|
│ │ └── Settings.tsx
|
|
│ ├── services/ # API services
|
|
│ │ └── api.ts
|
|
│ ├── hooks/ # Custom hooks
|
|
│ │ └── usePlaylists.ts
|
|
│ ├── utils/ # Utilities
|
|
│ │ └── formatters.ts
|
|
│ └── App.tsx
|
|
├── public/
|
|
└── package.json
|
|
```
|
|
|
|
### Core API Service
|
|
```typescript
|
|
// src/services/api.ts
|
|
import axios from 'axios';
|
|
|
|
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8082/api';
|
|
|
|
const api = axios.create({
|
|
baseURL: API_BASE_URL,
|
|
timeout: 10000,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
// Request interceptor
|
|
api.interceptors.request.use(
|
|
(config) => {
|
|
console.log('API Request:', config.method?.toUpperCase(), config.url);
|
|
return config;
|
|
},
|
|
(error) => Promise.reject(error)
|
|
);
|
|
|
|
// Response interceptor
|
|
api.interceptors.response.use(
|
|
(response) => response,
|
|
(error) => {
|
|
console.error('API Error:', error.response?.data || error.message);
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
// Playlist API calls
|
|
export const playlistAPI = {
|
|
getAll: () => api.get('/playlists'),
|
|
getById: (id: string) => api.get(`/playlists/${id}`),
|
|
create: (data: any) => api.post('/playlists', data),
|
|
update: (id: string, data: any) => api.put(`/playlists/${id}`, data),
|
|
delete: (id: string) => api.delete(`/playlists/${id}`),
|
|
check: (id: string) => api.post(`/playlists/${id}/check`),
|
|
};
|
|
|
|
// Video API calls
|
|
export const videoAPI = {
|
|
getById: (id: string) => api.get(`/videos/${id}`),
|
|
download: (id: string) => api.post(`/videos/${id}/download`),
|
|
skip: (id: string) => api.post(`/videos/${id}/skip`),
|
|
reset: (id: string) => api.post(`/videos/${id}/reset`),
|
|
markAsMoved: (id: string, locationNote?: string) =>
|
|
api.post(`/videos/${id}/file-moved`, { location_note: locationNote }),
|
|
};
|
|
|
|
// System API calls
|
|
export const systemAPI = {
|
|
getStatus: () => api.get('/status'),
|
|
getSchedulerStatus: () => api.get('/scheduler/status'),
|
|
syncWithMeTube: () => api.post('/sync-metube'),
|
|
healthCheck: () => api.get('/health'),
|
|
};
|
|
```
|
|
|
|
### Main Dashboard Component
|
|
```typescript
|
|
// src/pages/Dashboard.tsx
|
|
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Container,
|
|
Grid,
|
|
Card,
|
|
CardContent,
|
|
Typography,
|
|
Box,
|
|
LinearProgress,
|
|
Alert
|
|
} from '@mui/material';
|
|
import {
|
|
PlaylistPlay,
|
|
VideoLibrary,
|
|
Download,
|
|
Error
|
|
} from '@mui/icons-material';
|
|
import { systemAPI } from '../services/api';
|
|
import StatusChart from '../components/StatusChart';
|
|
|
|
interface SystemStatus {
|
|
total_playlists: number;
|
|
active_playlists: number;
|
|
total_videos: number;
|
|
pending_downloads: number;
|
|
active_downloads: number;
|
|
completed_downloads: number;
|
|
failed_downloads: number;
|
|
metube_status: {
|
|
connected: boolean;
|
|
error?: string;
|
|
};
|
|
}
|
|
|
|
const Dashboard: React.FC = () => {
|
|
const [status, setStatus] = useState<SystemStatus | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
loadStatus();
|
|
const interval = setInterval(loadStatus, 30000); // Refresh every 30s
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
const loadStatus = async () => {
|
|
try {
|
|
const response = await systemAPI.getStatus();
|
|
setStatus(response.data);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError('Failed to load system status');
|
|
console.error('Error loading status:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
if (loading) return <LinearProgress />;
|
|
if (error) return <Alert severity="error">{error}</Alert>;
|
|
if (!status) return <Alert severity="info">No data available</Alert>;
|
|
|
|
const stats = [
|
|
{ label: 'Total Playlists', value: status.total_playlists, icon: <PlaylistPlay /> },
|
|
{ label: 'Active Playlists', value: status.active_playlists, icon: <PlaylistPlay /> },
|
|
{ label: 'Total Videos', value: status.total_videos, icon: <VideoLibrary /> },
|
|
{ label: 'Pending Downloads', value: status.pending_downloads, icon: <Download /> },
|
|
{ label: 'Active Downloads', value: status.active_downloads, icon: <Download /> },
|
|
{ label: 'Completed', value: status.completed_downloads, icon: <VideoLibrary /> },
|
|
{ label: 'Failed', value: status.failed_downloads, icon: <Error /> },
|
|
];
|
|
|
|
return (
|
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
|
<Grid container spacing={3}>
|
|
{/* Status Alert */}
|
|
{!status.metube_status.connected && (
|
|
<Grid item xs={12}>
|
|
<Alert severity="warning">
|
|
MeTube connection failed: {status.metube_status.error}
|
|
</Alert>
|
|
</Grid>
|
|
)}
|
|
|
|
{/* Stats Cards */}
|
|
{stats.map((stat, index) => (
|
|
<Grid item xs={12} sm={6} md={4} lg={3} key={index}>
|
|
<Card>
|
|
<CardContent>
|
|
<Box display="flex" alignItems="center" mb={2}>
|
|
{stat.icon}
|
|
<Typography variant="h6" sx={{ ml: 1 }}>
|
|
{stat.label}
|
|
</Typography>
|
|
</Box>
|
|
<Typography variant="h3" component="div">
|
|
{stat.value}
|
|
</Typography>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
))}
|
|
|
|
{/* Status Chart */}
|
|
<Grid item xs={12}>
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="h6" gutterBottom>
|
|
Video Status Distribution
|
|
</Typography>
|
|
<StatusChart status={status} />
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
</Grid>
|
|
</Container>
|
|
);
|
|
};
|
|
|
|
export default Dashboard;
|
|
```
|
|
|
|
### Playlist Management Component
|
|
```typescript
|
|
// src/pages/Playlists.tsx
|
|
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Container,
|
|
Grid,
|
|
Card,
|
|
CardContent,
|
|
Typography,
|
|
Button,
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
DialogActions,
|
|
TextField,
|
|
Box,
|
|
Chip,
|
|
IconButton,
|
|
Alert,
|
|
} from '@mui/material';
|
|
import {
|
|
Add,
|
|
PlayArrow,
|
|
Delete,
|
|
Edit,
|
|
Refresh,
|
|
CheckCircle,
|
|
PauseCircle,
|
|
} from '@mui/icons-material';
|
|
import { useQuery, useMutation, useQueryClient } from 'react-query';
|
|
import { playlistAPI } from '../services/api';
|
|
|
|
interface Playlist {
|
|
id: string;
|
|
url: string;
|
|
title?: string;
|
|
check_interval: number;
|
|
enabled: boolean;
|
|
created_at: string;
|
|
stats: {
|
|
total: number;
|
|
pending: number;
|
|
completed: number;
|
|
failed: number;
|
|
};
|
|
}
|
|
|
|
const Playlists: React.FC = () => {
|
|
const queryClient = useQueryClient();
|
|
const [addDialog, setAddDialog] = useState(false);
|
|
const [newPlaylist, setNewPlaylist] = useState({
|
|
url: '',
|
|
title: '',
|
|
check_interval: 60,
|
|
quality: 'best',
|
|
format: 'mp4',
|
|
folder: '',
|
|
enabled: true,
|
|
});
|
|
|
|
// Fetch playlists
|
|
const { data: playlists, isLoading, error } = useQuery(
|
|
'playlists',
|
|
() => playlistAPI.getAll().then(res => res.data),
|
|
{
|
|
refetchInterval: 30000, // Refresh every 30s
|
|
}
|
|
);
|
|
|
|
// Create playlist mutation
|
|
const createMutation = useMutation(
|
|
(data: any) => playlistAPI.create(data),
|
|
{
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries('playlists');
|
|
setAddDialog(false);
|
|
setNewPlaylist({
|
|
url: '',
|
|
title: '',
|
|
check_interval: 60,
|
|
quality: 'best',
|
|
format: 'mp4',
|
|
folder: '',
|
|
enabled: true,
|
|
});
|
|
},
|
|
}
|
|
);
|
|
|
|
// Check playlist mutation
|
|
const checkMutation = useMutation(
|
|
(id: string) => playlistAPI.check(id),
|
|
{
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries('playlists');
|
|
},
|
|
}
|
|
);
|
|
|
|
// Delete playlist mutation
|
|
const deleteMutation = useMutation(
|
|
(id: string) => playlistAPI.delete(id),
|
|
{
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries('playlists');
|
|
},
|
|
}
|
|
);
|
|
|
|
const handleAddPlaylist = () => {
|
|
createMutation.mutate(newPlaylist);
|
|
};
|
|
|
|
const handleCheckPlaylist = (id: string) => {
|
|
checkMutation.mutate(id);
|
|
};
|
|
|
|
const handleDeletePlaylist = (id: string) => {
|
|
if (window.confirm('Are you sure you want to delete this playlist?')) {
|
|
deleteMutation.mutate(id);
|
|
}
|
|
};
|
|
|
|
if (isLoading) return <div>Loading...</div>;
|
|
if (error) return <Alert severity="error">Failed to load playlists</Alert>;
|
|
|
|
return (
|
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
|
<Typography variant="h4" component="h1">
|
|
Playlists
|
|
</Typography>
|
|
<Button
|
|
variant="contained"
|
|
startIcon={<Add />}
|
|
onClick={() => setAddDialog(true)}
|
|
>
|
|
Add Playlist
|
|
</Button>
|
|
</Box>
|
|
|
|
<Grid container spacing={3}>
|
|
{playlists?.map((playlist: Playlist) => (
|
|
<Grid item xs={12} sm={6} md={4} lg={3} key={playlist.id}>
|
|
<Card>
|
|
<CardContent>
|
|
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={2}>
|
|
<Typography variant="h6" component="h2" noWrap>
|
|
{playlist.title || 'Untitled Playlist'}
|
|
</Typography>
|
|
<Chip
|
|
icon={playlist.enabled ? <CheckCircle /> : <PauseCircle />}
|
|
label={playlist.enabled ? 'Enabled' : 'Disabled'}
|
|
color={playlist.enabled ? 'success' : 'default'}
|
|
size="small"
|
|
/>
|
|
</Box>
|
|
|
|
<Typography variant="body2" color="text.secondary" noWrap mb={1}>
|
|
{playlist.url}
|
|
</Typography>
|
|
|
|
<Box mb={2}>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Check Interval: {playlist.check_interval} minutes
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Total Videos: {playlist.stats.total}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Pending: {playlist.stats.pending} | Completed: {playlist.stats.completed}
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Box display="flex" justifyContent="space-between">
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => handleCheckPlaylist(playlist.id)}
|
|
disabled={checkMutation.isLoading}
|
|
title="Check for new videos"
|
|
>
|
|
<Refresh />
|
|
</IconButton>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => handleDeletePlaylist(playlist.id)}
|
|
disabled={deleteMutation.isLoading}
|
|
title="Delete playlist"
|
|
>
|
|
<Delete />
|
|
</IconButton>
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
|
|
{/* Add Playlist Dialog */}
|
|
<Dialog open={addDialog} onClose={() => setAddDialog(false)} maxWidth="sm" fullWidth>
|
|
<DialogTitle>Add New Playlist</DialogTitle>
|
|
<DialogContent>
|
|
<TextField
|
|
fullWidth
|
|
label="YouTube Playlist URL"
|
|
value={newPlaylist.url}
|
|
onChange={(e) => setNewPlaylist({ ...newPlaylist, url: e.target.value })}
|
|
margin="normal"
|
|
placeholder="https://www.youtube.com/playlist?list=..."
|
|
/>
|
|
<TextField
|
|
fullWidth
|
|
label="Title (optional)"
|
|
value={newPlaylist.title}
|
|
onChange={(e) => setNewPlaylist({ ...newPlaylist, title: e.target.value })}
|
|
margin="normal"
|
|
/>
|
|
<TextField
|
|
fullWidth
|
|
label="Check Interval (minutes)"
|
|
type="number"
|
|
value={newPlaylist.check_interval}
|
|
onChange={(e) => setNewPlaylist({ ...newPlaylist, check_interval: parseInt(e.target.value) })}
|
|
margin="normal"
|
|
/>
|
|
<TextField
|
|
fullWidth
|
|
label="Download Folder (optional)"
|
|
value={newPlaylist.folder}
|
|
onChange={(e) => setNewPlaylist({ ...newPlaylist, folder: e.target.value })}
|
|
margin="normal"
|
|
/>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setAddDialog(false)}>Cancel</Button>
|
|
<Button
|
|
onClick={handleAddPlaylist}
|
|
disabled={!newPlaylist.url || createMutation.isLoading}
|
|
variant="contained"
|
|
>
|
|
Add
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Container>
|
|
);
|
|
};
|
|
|
|
export default Playlists;
|
|
```
|
|
|
|
## 🔄 Option 2: Extend MeTube Angular
|
|
|
|
### Integration Approach
|
|
Since MeTube uses Angular, you can extend its existing UI:
|
|
|
|
```typescript
|
|
// Add to MeTube's existing structure
|
|
metube/ui/src/app/
|
|
├── playlist-monitor/ # New module
|
|
│ ├── playlist-monitor.module.ts
|
|
│ ├── components/
|
|
│ │ ├── playlist-list/
|
|
│ │ ├── playlist-detail/
|
|
│ │ └── video-dashboard/
|
|
│ ├── services/
|
|
│ │ └── playlist-monitor.service.ts
|
|
│ └── models/
|
|
│ └── playlist.model.ts
|
|
```
|
|
|
|
### Service Integration
|
|
```typescript
|
|
// metube/ui/src/app/playlist-monitor/services/playlist-monitor.service.ts
|
|
import { Injectable } from '@angular/core';
|
|
import { HttpClient } from '@angular/common/http';
|
|
import { Observable } from 'rxjs';
|
|
|
|
@Injectable({
|
|
providedIn: 'root'
|
|
})
|
|
export class PlaylistMonitorService {
|
|
private apiUrl = 'http://localhost:8082/api';
|
|
|
|
constructor(private http: HttpClient) {}
|
|
|
|
getPlaylists(): Observable<any> {
|
|
return this.http.get(`${this.apiUrl}/playlists`);
|
|
}
|
|
|
|
createPlaylist(playlist: any): Observable<any> {
|
|
return this.http.post(`${this.apiUrl}/playlists`, playlist);
|
|
}
|
|
|
|
checkPlaylist(id: string): Observable<any> {
|
|
return this.http.post(`${this.apiUrl}/playlists/${id}/check`, {});
|
|
}
|
|
}
|
|
```
|
|
|
|
### Component Integration
|
|
```typescript
|
|
// Add to MeTube's navigation
|
|
// metube/ui/src/app/app.component.html
|
|
<nav class="navbar">
|
|
<!-- Existing navigation -->
|
|
<li class="nav-item">
|
|
<a class="nav-link" routerLink="/playlists" routerLinkActive="active">
|
|
<fa-icon [icon]="['fas', 'list']"></fa-icon> Playlists
|
|
</a>
|
|
</li>
|
|
</nav>
|
|
```
|
|
|
|
## 📝 Option 3: Simple HTML Dashboard
|
|
|
|
### Basic HTML Dashboard
|
|
```html
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Playlist Monitor Dashboard</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
|
<style>
|
|
.status-card { transition: transform 0.2s; }
|
|
.status-card:hover { transform: translateY(-2px); }
|
|
.playlist-card { cursor: pointer; }
|
|
.video-status { font-size: 0.8rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav class="navbar navbar-dark bg-dark">
|
|
<div class="container-fluid">
|
|
<span class="navbar-brand mb-0 h1">
|
|
<i class="fas fa-list"></i> Playlist Monitor
|
|
</span>
|
|
<button class="btn btn-outline-light btn-sm" onclick="refreshData()">
|
|
<i class="fas fa-sync-alt"></i> Refresh
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="container-fluid mt-4">
|
|
<!-- Status Cards -->
|
|
<div class="row mb-4" id="status-cards">
|
|
<div class="col-md-3">
|
|
<div class="card status-card border-primary">
|
|
<div class="card-body text-center">
|
|
<i class="fas fa-list fa-2x text-primary mb-2"></i>
|
|
<h5 class="card-title">Loading...</h5>
|
|
<p class="card-text">Total Playlists</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Playlists Table -->
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0">Playlists</h5>
|
|
<button class="btn btn-primary btn-sm" onclick="showAddPlaylistModal()">
|
|
<i class="fas fa-plus"></i> Add Playlist
|
|
</button>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover" id="playlists-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Title</th>
|
|
<th>URL</th>
|
|
<th>Status</th>
|
|
<th>Videos</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td colspan="5" class="text-center">Loading playlists...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Playlist Modal -->
|
|
<div class="modal fade" id="addPlaylistModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Add New Playlist</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="add-playlist-form">
|
|
<div class="mb-3">
|
|
<label for="playlist-url" class="form-label">YouTube Playlist URL</label>
|
|
<input type="url" class="form-control" id="playlist-url" required
|
|
placeholder="https://www.youtube.com/playlist?list=...">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="playlist-title" class="form-label">Title (optional)</label>
|
|
<input type="text" class="form-control" id="playlist-title"
|
|
placeholder="My Playlist">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="check-interval" class="form-label">Check Interval (minutes)</label>
|
|
<input type="number" class="form-control" id="check-interval" value="60" min="5" max="1440">
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" onclick="addPlaylist()">Add Playlist</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script src="dashboard.js"></script>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
### JavaScript for Simple Dashboard
|
|
```javascript
|
|
// dashboard.js
|
|
const API_BASE_URL = 'http://localhost:8082/api';
|
|
|
|
// API helper functions
|
|
async function apiCall(endpoint, options = {}) {
|
|
try {
|
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
...options
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('API call failed:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Load system status
|
|
async function loadStatus() {
|
|
try {
|
|
const status = await apiCall('/status');
|
|
updateStatusCards(status);
|
|
} catch (error) {
|
|
showError('Failed to load system status');
|
|
}
|
|
}
|
|
|
|
// Update status cards
|
|
function updateStatusCards(status) {
|
|
const statusCards = document.getElementById('status-cards');
|
|
statusCards.innerHTML = `
|
|
<div class="col-md-3">
|
|
<div class="card status-card border-primary">
|
|
<div class="card-body text-center">
|
|
<i class="fas fa-list fa-2x text-primary mb-2"></i>
|
|
<h3 class="card-title">${status.total_playlists}</h3>
|
|
<p class="card-text">Total Playlists</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card status-card border-success">
|
|
<div class="card-body text-center">
|
|
<i class="fas fa-play-circle fa-2x text-success mb-2"></i>
|
|
<h3 class="card-title">${status.active_playlists}</h3>
|
|
<p class="card-text">Active Playlists</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card status-card border-info">
|
|
<div class="card-body text-center">
|
|
<i class="fas fa-video fa-2x text-info mb-2"></i>
|
|
<h3 class="card-title">${status.total_videos}</h3>
|
|
<p class="card-text">Total Videos</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card status-card border-warning">
|
|
<div class="card-body text-center">
|
|
<i class="fas fa-download fa-2x text-warning mb-2"></i>
|
|
<h3 class="card-title">${status.pending_downloads}</h3>
|
|
<p class="card-text">Pending Downloads</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Load playlists
|
|
async function loadPlaylists() {
|
|
try {
|
|
const playlists = await apiCall('/playlists');
|
|
updatePlaylistsTable(playlists);
|
|
} catch (error) {
|
|
showError('Failed to load playlists');
|
|
}
|
|
}
|
|
|
|
// Update playlists table
|
|
function updatePlaylistsTable(playlists) {
|
|
const tbody = document.querySelector('#playlists-table tbody');
|
|
|
|
if (playlists.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center">No playlists found</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = playlists.map(playlist => `
|
|
<tr class="playlist-card" onclick="showPlaylistDetails('${playlist.id}')">
|
|
<td>${playlist.title || 'Untitled'}</td>
|
|
<td><small>${playlist.url}</small></td>
|
|
<td>
|
|
<span class="badge ${playlist.enabled ? 'bg-success' : 'bg-secondary'}">
|
|
${playlist.enabled ? 'Enabled' : 'Disabled'}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<span class="badge bg-primary">${playlist.stats.total}</span>
|
|
<small class="text-muted ms-1">
|
|
(${playlist.stats.pending} pending, ${playlist.stats.completed} completed)
|
|
</small>
|
|
</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation(); checkPlaylist('${playlist.id}')">
|
|
<i class="fas fa-sync-alt"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" onclick="event.stopPropagation(); deletePlaylist('${playlist.id}')">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
// Add playlist
|
|
async function addPlaylist() {
|
|
const url = document.getElementById('playlist-url').value;
|
|
const title = document.getElementById('playlist-title').value;
|
|
const checkInterval = parseInt(document.getElementById('check-interval').value);
|
|
|
|
if (!url) {
|
|
alert('Please enter a YouTube playlist URL');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await apiCall('/playlists', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
url: url,
|
|
title: title || undefined,
|
|
check_interval: checkInterval,
|
|
enabled: true
|
|
})
|
|
});
|
|
|
|
// Close modal and refresh
|
|
bootstrap.Modal.getInstance(document.getElementById('addPlaylistModal')).hide();
|
|
refreshData();
|
|
showSuccess('Playlist added successfully');
|
|
} catch (error) {
|
|
showError('Failed to add playlist');
|
|
}
|
|
}
|
|
|
|
// Check playlist
|
|
async function checkPlaylist(id) {
|
|
try {
|
|
await apiCall(`/playlists/${id}/check`, { method: 'POST' });
|
|
showSuccess('Playlist check initiated');
|
|
refreshData();
|
|
} catch (error) {
|
|
showError('Failed to check playlist');
|
|
}
|
|
}
|
|
|
|
// UI helper functions
|
|
function showAddPlaylistModal() {
|
|
new bootstrap.Modal(document.getElementById('addPlaylistModal')).show();
|
|
}
|
|
|
|
function showError(message) {
|
|
// Simple error display - you could enhance this with toast notifications
|
|
alert(`Error: ${message}`);
|
|
}
|
|
|
|
function showSuccess(message) {
|
|
alert(`Success: ${message}`);
|
|
}
|
|
|
|
function refreshData() {
|
|
loadStatus();
|
|
loadPlaylists();
|
|
}
|
|
|
|
// Initialize dashboard
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
refreshData();
|
|
// Auto-refresh every 30 seconds
|
|
setInterval(refreshData, 30000);
|
|
});
|
|
```
|
|
|
|
## 🔗 API Integration Examples
|
|
|
|
### React Hook for Real-time Updates
|
|
```typescript
|
|
// src/hooks/useRealtimeUpdates.ts
|
|
import { useEffect, useRef } from 'react';
|
|
import { systemAPI } from '../services/api';
|
|
|
|
export const useRealtimeUpdates = (onUpdate: (data: any) => void) => {
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
|
|
useEffect(() => {
|
|
// Connect to WebSocket for real-time updates
|
|
const connectWebSocket = () => {
|
|
const ws = new WebSocket('ws://localhost:8082/ws');
|
|
|
|
ws.onopen = () => {
|
|
console.log('WebSocket connected');
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
onUpdate(data);
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
console.log('WebSocket disconnected');
|
|
// Reconnect after 5 seconds
|
|
setTimeout(connectWebSocket, 5000);
|
|
};
|
|
|
|
ws.onerror = (error) => {
|
|
console.error('WebSocket error:', error);
|
|
};
|
|
|
|
wsRef.current = ws;
|
|
};
|
|
|
|
connectWebSocket();
|
|
|
|
return () => {
|
|
if (wsRef.current) {
|
|
wsRef.current.close();
|
|
}
|
|
};
|
|
}, [onUpdate]);
|
|
|
|
return wsRef.current;
|
|
};
|
|
```
|
|
|
|
## 🎨 UI Components Design
|
|
|
|
### Key Components Needed
|
|
1. **Dashboard** - System overview with charts and statistics
|
|
2. **Playlist List** - Table/card view of all playlists
|
|
3. **Playlist Detail** - Individual playlist management
|
|
4. **Video Table** - Video status and management
|
|
5. **Add/Edit Forms** - Playlist creation and editing
|
|
6. **Status Indicators** - Real-time status updates
|
|
7. **Charts/Graphs** - Statistics visualization
|
|
|
|
### Design System Integration
|
|
```typescript
|
|
// src/theme.ts
|
|
import { createTheme } from '@mui/material/styles';
|
|
|
|
// Custom theme matching MeTube's style
|
|
export const theme = createTheme({
|
|
palette: {
|
|
primary: {
|
|
main: '#1976d2', // Blue matching MeTube
|
|
},
|
|
secondary: {
|
|
main: '#dc004e',
|
|
},
|
|
background: {
|
|
default: '#f5f5f5',
|
|
},
|
|
},
|
|
typography: {
|
|
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
|
},
|
|
components: {
|
|
MuiCard: {
|
|
styleOverrides: {
|
|
root: {
|
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
|
borderRadius: 8,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
## 📱 Responsive Design
|
|
|
|
### Mobile-First Approach
|
|
```typescript
|
|
// Responsive grid breakpoints
|
|
<Grid container spacing={{ xs: 2, md: 3 }}>
|
|
<Grid item xs={12} sm={6} md={4} lg={3}>
|
|
<Card>
|
|
{/* Card content */}
|
|
</Card>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
// Responsive typography
|
|
<Typography
|
|
variant={{ xs: 'h6', md: 'h5' }}
|
|
fontSize={{ xs: '1rem', md: '1.25rem' }}
|
|
>
|
|
Playlist Title
|
|
</Typography>
|
|
```
|
|
|
|
## 🧪 Testing UI
|
|
|
|
### Component Testing with React Testing Library
|
|
```typescript
|
|
// src/components/__tests__/PlaylistCard.test.tsx
|
|
import { render, screen, fireEvent } from '@testing-library/react';
|
|
import PlaylistCard from '../PlaylistCard';
|
|
|
|
describe('PlaylistCard', () => {
|
|
const mockPlaylist = {
|
|
id: '1',
|
|
title: 'Test Playlist',
|
|
url: 'https://www.youtube.com/playlist?list=TEST123',
|
|
enabled: true,
|
|
stats: { total: 10, pending: 2, completed: 8 }
|
|
};
|
|
|
|
it('renders playlist information correctly', () => {
|
|
render(<PlaylistCard playlist={mockPlaylist} />);
|
|
|
|
expect(screen.getByText('Test Playlist')).toBeInTheDocument();
|
|
expect(screen.getByText('10 videos')).toBeInTheDocument();
|
|
});
|
|
|
|
it('calls onCheck when check button is clicked', () => {
|
|
const onCheck = jest.fn();
|
|
render(<PlaylistCard playlist={mockPlaylist} onCheck={onCheck} />);
|
|
|
|
fireEvent.click(screen.getByLabelText('Check playlist'));
|
|
expect(onCheck).toHaveBeenCalledWith('1');
|
|
});
|
|
});
|
|
```
|
|
|
|
## 🚀 Deployment Options
|
|
|
|
### Option 1: Serve with Backend
|
|
```typescript
|
|
// Modify app/main.py to serve static files
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
app.mount("/ui", StaticFiles(directory="ui/build", html=True), name="ui")
|
|
|
|
# Add redirect
|
|
@app.get("/")
|
|
async def serve_ui():
|
|
return RedirectResponse(url="/ui")
|
|
```
|
|
|
|
### Option 2: Separate Deployment
|
|
```bash
|
|
# Build React app
|
|
npm run build
|
|
|
|
# Deploy to static hosting
|
|
# - Netlify
|
|
# - Vercel
|
|
# - GitHub Pages
|
|
# - Nginx
|
|
```
|
|
|
|
### Option 3: Docker Multi-Service
|
|
```dockerfile
|
|
# Dockerfile for React app
|
|
FROM node:18-alpine as build
|
|
WORKDIR /app
|
|
COPY package*.json ./
|
|
RUN npm ci --only=production
|
|
COPY . .
|
|
RUN npm run build
|
|
|
|
FROM nginx:alpine
|
|
COPY --from=build /app/build /usr/share/nginx/html
|
|
COPY nginx.conf /etc/nginx/nginx.conf
|
|
EXPOSE 80
|
|
CMD ["nginx", "-g", "daemon off;"]
|
|
```
|
|
|
|
## 📚 Next Steps
|
|
|
|
1. **Choose UI Option** based on your needs and technical requirements
|
|
2. **Implement Core Components** following the examples above
|
|
3. **Add Real-time Updates** with WebSocket integration
|
|
4. **Test Thoroughly** with different screen sizes and browsers
|
|
5. **Deploy** using one of the suggested deployment options
|
|
|
|
The UI implementation will provide a complete, user-friendly interface for managing your playlists and monitoring download progress! 🎨✨ |