33 KiB
33 KiB
Playlist Monitor Service - UI Integration Guide
📋 Table of Contents
- UI Options Overview
- Option 1: Standalone React App
- Option 2: Extend MeTube Angular
- Option 3: Simple HTML Dashboard
- API Integration Examples
- UI Components Design
- Styling Guidelines
- Responsive Design
- Testing UI
- 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)
# 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
// 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
// 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
// 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:
// 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
// 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
// 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
<!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
// 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
// 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
- Dashboard - System overview with charts and statistics
- Playlist List - Table/card view of all playlists
- Playlist Detail - Individual playlist management
- Video Table - Video status and management
- Add/Edit Forms - Playlist creation and editing
- Status Indicators - Real-time status updates
- Charts/Graphs - Statistics visualization
Design System Integration
// 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
// 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
// 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
// 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
# Build React app
npm run build
# Deploy to static hosting
# - Netlify
# - Vercel
# - GitHub Pages
# - Nginx
Option 3: Docker Multi-Service
# 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
- Choose UI Option based on your needs and technical requirements
- Implement Core Components following the examples above
- Add Real-time Updates with WebSocket integration
- Test Thoroughly with different screen sizes and browsers
- 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! 🎨✨