Functionnal UI with All Files

This commit is contained in:
2025-07-15 02:29:44 +02:00
parent 9d7b1d3b76
commit 5929c758d3
52 changed files with 11979 additions and 0 deletions

1801
src/AvaRundownMessages.h Normal file

File diff suppressed because it is too large Load Diff

268
src/app/dev-tools.ts Normal file
View File

@ -0,0 +1,268 @@
// src/app/dev-tools.ts
/**
* Outils de développement pour DTFlux Titrage Client
* À utiliser uniquement en mode développement
*/
import { TitleSheet, createEmptyTitleSheet } from './models/title-sheet.model';
export class DevTools {
/**
* Génère des données de test pour l'application
*/
static generateSampleTitleSheets(): TitleSheet[] {
const sampleData = [
{
FirstName: 'Jean-Michel',
LastName: 'Dubois',
Function1: 'Directeur Général',
Function2: 'Marketing & Communication'
},
{
FirstName: 'Marie-Claire',
LastName: 'Martin',
Function1: 'Chef de Projet Senior',
Function2: 'Innovation & Développement'
},
{
FirstName: 'Pierre-Alexandre',
LastName: 'Bernard',
Function1: 'Développeur Full Stack',
Function2: 'Team Lead Frontend'
},
{
FirstName: 'Sophie',
LastName: 'Laurent-Moreau',
Function1: 'Designer UX/UI',
Function2: 'Responsable Créative'
},
{
FirstName: 'Lucas',
LastName: 'Moreau',
Function1: 'Directeur Technique',
Function2: 'Architecture & DevOps'
},
{
FirstName: 'Amélie',
LastName: 'Rousseau',
Function1: 'Product Owner',
Function2: 'Stratégie Produit'
},
{
FirstName: 'Thomas',
LastName: 'Lefevre',
Function1: 'Data Scientist',
Function2: 'Intelligence Artificielle'
},
{
FirstName: 'Camille',
LastName: 'Durand',
Function1: 'Responsable QA',
Function2: 'Test Automation'
},
{
FirstName: 'Julien',
LastName: 'Petit',
Function1: 'Développeur Backend',
Function2: 'Microservices & API'
},
{
FirstName: 'Élise',
LastName: 'Garnier',
Function1: 'Chef de Projet Digital',
Function2: 'Transformation Numérique'
},
{
FirstName: 'Nicolas',
LastName: 'Fabre',
Function1: 'Architecte Solution',
Function2: 'Cloud & Infrastructure'
},
{
FirstName: 'Laure',
LastName: 'Morel',
Function1: 'Business Analyst',
Function2: 'Optimisation Processus'
}
];
return sampleData.map(data => {
const titleSheet = createEmptyTitleSheet();
const now = new Date();
const createdDate = new Date(now.getTime() - Math.random() * 30 * 24 * 60 * 60 * 1000);
const modifiedDate = new Date(now.getTime() - Math.random() * 7 * 24 * 60 * 60 * 1000);
return {
...titleSheet,
...data,
// Décaler les dates de création pour simuler un historique
CreatedAt: createdDate,
LastModified: modifiedDate
};
});
}
/**
* Génère des données de test avec des cas limites
*/
static generateEdgeCaseTitleSheets(): TitleSheet[] {
const edgeCases = [
{
FirstName: '',
LastName: '',
Function1: '',
Function2: ''
},
{
FirstName: 'Jean-Baptiste-Alexandre-Emmanuel',
LastName: 'De La Rochefoucauld-Montmorency',
Function1: 'Directeur Général Adjoint en charge du Développement Commercial et des Relations Internationales',
Function2: 'Responsable de la Stratégie d\'Expansion Européenne et des Partenariats Stratégiques'
},
{
FirstName: 'José-María',
LastName: 'García-López',
Function1: 'Spécialiste Intégration Systèmes',
Function2: ''
},
{
FirstName: 'X',
LastName: 'Y',
Function1: 'A',
Function2: 'B'
},
{
FirstName: 'Test avec des caractères spéciaux: éèàùç',
LastName: 'ñóëüï@#$%&*',
Function1: 'Fonction avec émojis 🚀 ✨ 💻',
Function2: 'Unicode: 中文 русский العربية'
}
];
return edgeCases.map(data => {
const titleSheet = createEmptyTitleSheet();
const now = new Date();
return {
...titleSheet,
...data,
CreatedAt: now,
LastModified: now
};
});
}
/**
* Simule une séquence de tests automatisés
*/
static async runAutomatedTest(titleSheetsService: any): Promise<void> {
console.log('🧪 Démarrage des tests automatisés...');
// Test 1: Ajout de données
const sampleSheets = this.generateSampleTitleSheets();
console.log('📝 Ajout de', sampleSheets.length, 'fiches de test');
sampleSheets.forEach(sheet => titleSheetsService.saveTitleSheet(sheet));
await this.delay(1000);
// Test 2: Modification d'une fiche
const sheets = titleSheetsService.getAllTitleSheets();
if (sheets.length > 0) {
const firstSheet = { ...sheets[0] };
firstSheet.Function1 = 'MODIFIÉ PAR TEST AUTOMATIQUE';
titleSheetsService.saveTitleSheet(firstSheet);
console.log('✏️ Modification d\'une fiche');
}
await this.delay(1000);
// Test 3: Suppression d'une fiche
if (sheets.length > 1) {
titleSheetsService.deleteTitleSheet(sheets[1].Id);
console.log('🗑️ Suppression d\'une fiche');
}
await this.delay(1000);
console.log('✅ Tests automatisés terminés');
}
/**
* Vérifie la performance de l'application avec beaucoup de données
*/
static generateLargeDataset(count: number = 1000): TitleSheet[] {
console.log('📊 Génération de', count, 'fiches pour test de performance');
const titles = ['Manager', 'Developer', 'Designer', 'Analyst', 'Consultant', 'Specialist', 'Coordinator', 'Assistant'];
const departments = ['IT', 'Marketing', 'Sales', 'HR', 'Finance', 'Operations', 'R&D', 'Support'];
const firstNames = ['Jean', 'Marie', 'Pierre', 'Sophie', 'Lucas', 'Amélie', 'Thomas', 'Camille'];
const lastNames = ['Martin', 'Bernard', 'Dubois', 'Laurent', 'Moreau', 'Lefevre', 'Durand', 'Rousseau'];
return Array.from({ length: count }, (_, index) => {
const titleSheet = createEmptyTitleSheet();
const now = new Date();
const createdDate = new Date(now.getTime() - Math.random() * 365 * 24 * 60 * 60 * 1000);
const modifiedDate = new Date(now.getTime() - Math.random() * 30 * 24 * 60 * 60 * 1000);
return {
...titleSheet,
FirstName: firstNames[index % firstNames.length] + (index > firstNames.length ? ` ${Math.floor(index / firstNames.length)}` : ''),
LastName: lastNames[index % lastNames.length],
Function1: `${titles[index % titles.length]} ${departments[index % departments.length]}`,
Function2: index % 3 === 0 ? `Senior ${titles[(index + 1) % titles.length]}` : '',
CreatedAt: createdDate,
LastModified: modifiedDate
};
});
}
/**
* Nettoie toutes les données de test
*/
static clearAllTestData(titleSheetsService: any): void {
const sheets = titleSheetsService.getAllTitleSheets();
sheets.forEach((sheet: TitleSheet) => {
titleSheetsService.deleteTitleSheet(sheet.Id);
});
console.log('🧹 Toutes les données de test ont été supprimées');
}
/**
* Utilitaire pour ajouter des délais dans les tests
*/
private static delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Affiche les statistiques de l'application
*/
static displayStats(titleSheetsService: any): void {
const sheets = titleSheetsService.getAllTitleSheets();
const totalChars = sheets.reduce((sum: number, sheet: TitleSheet) =>
sum + sheet.FirstName.length + sheet.LastName.length + sheet.Function1.length + sheet.Function2.length, 0);
const emptyFields = sheets.reduce((sum: number, sheet: TitleSheet) => {
let empty = 0;
if (!sheet.FirstName) empty++;
if (!sheet.LastName) empty++;
if (!sheet.Function1) empty++;
if (!sheet.Function2) empty++;
return sum + empty;
}, 0);
console.table({
'Total fiches': sheets.length,
'Caractères totaux': totalChars,
'Champs vides': emptyFields,
'Moyenne caractères/fiche': Math.round(totalChars / (sheets.length || 1)),
'Complétude moyenne': Math.round(((sheets.length * 4 - emptyFields) / (sheets.length * 4 || 1)) * 100) + '%'
});
}
}
// Exposer les outils de développement dans la console du navigateur
if (typeof window !== 'undefined' && !!(window as any)['ng']) {
(window as any)['DTFluxDevTools'] = DevTools;
console.log('🛠️ DTFlux Dev Tools disponibles via window.DTFluxDevTools');
}

View File

@ -0,0 +1,106 @@
export interface AppConfig {
UnrealEngine: {
// Network Configuration
Address: string;
Timeout: number;
// Remote Control Configuration
RemoteControl: {
HttpPort: number;
WebSocketPort: number;
UseWebSocket: boolean;
ApiVersion: string;
KeepAlive: boolean;
MaxRetries: number;
};
// Motion Design Configuration
MotionDesign: {
BlueprintPath: string;
PresetName: string;
WebSocketPort: number;
RundownAsset: string; // Format: [PackagePath]/[AssetName].[AssetName]
AutoLoadRundown: boolean;
AutoSubscribeToRemoteControl: boolean;
SubscribedProperties: string[];
DefaultTransition: {
In: string;
Out: string;
Duration: number;
};
};
};
Storage: {
SavePath: string;
AutoSave: boolean;
AutoSaveInterval: number;
};
UI: {
DefaultSort: 'LastName' | 'FirstName' | 'LastModified';
SearchPlaceholder: string;
};
Development: {
EnableDevTools: boolean;
LogLevel: 'debug' | 'info' | 'warn' | 'error';
MockUnreal: boolean;
};
}
export function createDefaultConfig(): AppConfig {
return {
UnrealEngine: {
// Network Configuration
Address: "127.0.0.1",
Timeout: 5000,
// Remote Control Configuration
RemoteControl: {
HttpPort: 30010,
WebSocketPort: 30020,
UseWebSocket: true, // WebSocket only - HTTP removed
ApiVersion: "v1",
KeepAlive: true,
MaxRetries: 3
},
// Motion Design Configuration
MotionDesign: {
BlueprintPath: "/Game/MotionDesign/TitleSheet_BP.TitleSheet_BP_C",
PresetName: "Default",
WebSocketPort: 30021, // Dedicated port for Motion Design rundowns
RundownAsset: "/Game/MotionDesign/Rundowns/DefaultRundown.DefaultRundown",
AutoLoadRundown: true,
AutoSubscribeToRemoteControl: true,
SubscribedProperties: [
"FirstName",
"LastName",
"Function1",
"Function2",
"BibNumber",
"Country",
"Flag"
],
DefaultTransition: {
In: "FadeIn",
Out: "FadeOut",
Duration: 1000 // milliseconds
}
}
},
Storage: {
SavePath: "./configs",
AutoSave: true,
AutoSaveInterval: 5
},
UI: {
DefaultSort: 'LastName',
SearchPlaceholder: 'Search by name or function or bib...'
},
Development: {
EnableDevTools: false,
LogLevel: 'info',
MockUnreal: false
}
};
}

View File

@ -0,0 +1,49 @@
// src/app/models/title-sheet.model.ts
export interface TitleSheet {
Id: string;
LastName: string;
FirstName: string;
Function1: string;
Function2: string;
CreatedAt: Date;
LastModified: Date;
IsSelected?: boolean;
IsPlaying?: boolean;
}
export function createEmptyTitleSheet(): TitleSheet {
const now = new Date();
return {
Id: crypto.randomUUID(),
LastName: '',
FirstName: '',
Function1: '',
Function2: '',
CreatedAt: now,
LastModified: now,
IsSelected: false,
IsPlaying: false
};
}
export interface TitleSheetListStatus{
currentTitleSheet: TitleSheet | null;
isPlaying: boolean;
lastUpdate: Date | null;
errorMessage: string |undefined
motionDesignConnected: boolean;
remoteControlConnected: boolean
};
export function createTitleSheetListStatus(){
const now = new Date();
return {
currentTitleSheet: null,
isPlaying: false,
lastUpdate: now,
errorMessage: undefined,
motionDesignConnected: false,
remoteControlConnected: false,
}
};

View File

@ -0,0 +1,299 @@
// src/app/services/config.service.ts
import { Injectable, signal } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { AppConfig, createDefaultConfig } from '../models/app-config.model';
@Injectable({
providedIn: 'root'
})
export class ConfigService {
private readonly STORAGE_KEY = 'dtflux-config';
// === SIGNALS ===
private configSubject = new BehaviorSubject<AppConfig>(createDefaultConfig());
public config$ = this.configSubject.asObservable();
private autoSaveInterval: any = null;
constructor() {
this.loadFromStorage();
this.startAutoSave();
}
// === PUBLIC METHODS ===
/**
* Get current configuration
*/
getCurrentConfig(): AppConfig {
return this.configSubject.value;
}
/**
* Update configuration
*/
updateConfig(updates: Partial<AppConfig>): void {
const currentConfig = this.configSubject.value;
const newConfig: AppConfig = {
...currentConfig,
...updates,
// Deep merge for nested objects
UnrealEngine: {
...currentConfig.UnrealEngine,
...updates.UnrealEngine,
RemoteControl: {
...currentConfig.UnrealEngine.RemoteControl,
...updates.UnrealEngine?.RemoteControl
},
MotionDesign: {
...currentConfig.UnrealEngine.MotionDesign,
...updates.UnrealEngine?.MotionDesign,
DefaultTransition: {
...currentConfig.UnrealEngine.MotionDesign.DefaultTransition,
...updates.UnrealEngine?.MotionDesign?.DefaultTransition
}
}
},
Storage: { ...currentConfig.Storage, ...updates.Storage },
UI: { ...currentConfig.UI, ...updates.UI },
Development: { ...currentConfig.Development, ...updates.Development }
};
this.configSubject.next(newConfig);
this.saveToStorage();
// Restart auto-save if interval changed
if (updates.Storage?.AutoSaveInterval || updates.Storage?.AutoSave !== undefined) {
this.restartAutoSave();
}
console.log('⚙️ Configuration updated:', newConfig);
}
/**
* Reset to default configuration
*/
resetToDefault(): void {
const defaultConfig = createDefaultConfig();
this.configSubject.next(defaultConfig);
this.saveToStorage();
this.restartAutoSave(); // Restart auto-save with default settings
console.log('🔄 Configuration reset to default');
}
/**
* Load configuration from file
*/
async loadFromFile(): Promise<void> {
try {
// In a real app, this would open a file dialog
// For now, we'll simulate loading from a file
const fileContent = await this.simulateFileLoad();
if (fileContent) {
const config = JSON.parse(fileContent) as AppConfig;
this.validateAndSetConfig(config);
console.log('📂 Configuration loaded from file');
}
} catch (error) {
console.error('❌ Failed to load configuration from file:', error);
throw new Error('Failed to load configuration file');
}
}
/**
* Save configuration to file
*/
async saveToFile(): Promise<void> {
try {
const config = this.getCurrentConfig();
const configJson = JSON.stringify(config, null, 2);
// In a real app, this would open a save dialog
await this.simulateFileSave(configJson);
console.log('💾 Configuration saved to file');
} catch (error) {
console.error('❌ Failed to save configuration to file:', error);
throw new Error('Failed to save configuration file');
}
}
/**
* Export configuration as JSON
*/
exportConfig(): string {
const config = this.getCurrentConfig();
return JSON.stringify(config, null, 2);
}
/**
* Import configuration from JSON
*/
importConfig(configJson: string): void {
try {
const config = JSON.parse(configJson) as AppConfig;
this.validateAndSetConfig(config);
console.log('📥 Configuration imported');
} catch (error) {
console.error('❌ Failed to import configuration:', error);
throw new Error('Invalid configuration format');
}
}
// === PRIVATE METHODS ===
/**
* Load configuration from localStorage
*/
private loadFromStorage(): void {
try {
const storedConfig = localStorage.getItem(this.STORAGE_KEY);
if (storedConfig) {
const config = JSON.parse(storedConfig) as AppConfig;
this.validateAndSetConfig(config);
console.log('📋 Configuration loaded from storage');
}
} catch (error) {
console.error('❌ Failed to load configuration from storage:', error);
this.configSubject.next(createDefaultConfig());
}
}
/**
* Save configuration to localStorage
*/
private saveToStorage(): void {
try {
const config = this.getCurrentConfig();
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(config));
} catch (error) {
console.error('❌ Failed to save configuration to storage:', error);
}
}
/**
* Validate and set configuration
*/
private validateAndSetConfig(config: AppConfig): void {
// Basic validation
if (!this.isValidConfig(config)) {
throw new Error('Invalid configuration structure');
}
this.configSubject.next(config);
this.saveToStorage();
this.restartAutoSave(); // Restart auto-save with loaded settings
}
/**
* Validate configuration structure
*/
private isValidConfig(config: any): config is AppConfig {
return (
config &&
typeof config === 'object' &&
// UnrealEngine validation
config.UnrealEngine &&
typeof config.UnrealEngine.Address === 'string' &&
typeof config.UnrealEngine.Timeout === 'number' &&
// RemoteControl validation
config.UnrealEngine.RemoteControl &&
typeof config.UnrealEngine.RemoteControl.HttpPort === 'number' &&
typeof config.UnrealEngine.RemoteControl.WebSocketPort === 'number' &&
typeof config.UnrealEngine.RemoteControl.UseWebSocket === 'boolean' &&
// MotionDesign validation
config.UnrealEngine.MotionDesign &&
typeof config.UnrealEngine.MotionDesign.BlueprintPath === 'string' &&
typeof config.UnrealEngine.MotionDesign.WebSocketPort === 'number' &&
config.UnrealEngine.MotionDesign.DefaultTransition &&
// Storage validation
config.Storage &&
typeof config.Storage.SavePath === 'string' &&
typeof config.Storage.AutoSave === 'boolean' &&
// UI validation
config.UI &&
typeof config.UI.DefaultSort === 'string' &&
// Development validation
config.Development &&
typeof config.Development.EnableDevTools === 'boolean'
);
}
/**
* Start auto-save functionality
*/
private startAutoSave(): void {
// Get current config to determine interval
const currentConfig = this.getCurrentConfig();
// Auto-save every X seconds if enabled
this.autoSaveInterval = setInterval(() => {
const config = this.getCurrentConfig();
if (config.Storage.AutoSave) {
this.saveToStorage();
}
}, currentConfig.Storage.AutoSaveInterval * 1000);
}
/**
* Restart auto-save with updated configuration
*/
private restartAutoSave(): void {
// Clear existing interval
if (this.autoSaveInterval) {
clearInterval(this.autoSaveInterval);
}
// Start new interval with current config
this.startAutoSave();
}
/**
* Simulate file loading (in a real app, this would use File API)
*/
private async simulateFileLoad(): Promise<string | null> {
return new Promise((resolve) => {
// In a real implementation, you would:
// 1. Create an input element with type="file"
// 2. Trigger click to open file dialog
// 3. Read the file content
// For now, return null to indicate no file selected
setTimeout(() => resolve(null), 100);
});
}
/**
* Simulate file saving (in a real app, this would create a download)
*/
private async simulateFileSave(content: string): Promise<void> {
return new Promise((resolve) => {
// In a real implementation, you would:
// 1. Create a Blob with the content
// 2. Create a download link
// 3. Trigger download
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'dtflux-config.json';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
setTimeout(() => resolve(), 100);
});
}
/**
* Cleanup on destroy
*/
ngOnDestroy(): void {
if (this.autoSaveInterval) {
clearInterval(this.autoSaveInterval);
}
}
}

View File

@ -0,0 +1,124 @@
// src/app/services/title-sheets.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { TitleSheet, createEmptyTitleSheet } from '../models/title-sheet.model';
@Injectable({
providedIn: 'root'
})
export class TitleSheetsService {
private readonly STORAGE_KEY = 'title-sheets';
// Observable for components to subscribe to changes
private titleSheetsSubject = new BehaviorSubject<TitleSheet[]>([]);
public titleSheets$ = this.titleSheetsSubject.asObservable();
constructor() {
// Load title sheets on startup
this.loadFromStorage();
}
// Load from localStorage
private loadFromStorage(): void {
const data = localStorage.getItem(this.STORAGE_KEY);
if (data) {
try {
const titleSheets = JSON.parse(data);
// Convertir les chaînes de date en objets Date
const convertedTitleSheets = titleSheets.map((sheet: any) => ({
...sheet,
CreatedAt: new Date(sheet.CreatedAt),
LastModified: new Date(sheet.LastModified)
}));
this.titleSheetsSubject.next(convertedTitleSheets);
} catch (error) {
console.error('Error loading title sheets:', error);
this.titleSheetsSubject.next([]);
}
} else {
// No data in localStorage, emit empty array to initialize
this.titleSheetsSubject.next([]);
}
}
// Save to localStorage
private saveToStorage(titleSheets: TitleSheet[]): void {
try {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(titleSheets));
} catch (error) {
console.error('❌ Error saving title sheets to localStorage:', error);
// Don't throw error, continue with in-memory storage
}
}
// Get all title sheets
getAllTitleSheets(): TitleSheet[] {
return this.titleSheetsSubject.value;
}
// Add or update a title sheet
saveTitleSheet(titleSheet: TitleSheet): void {
// Ensure we have a valid ID
if (!titleSheet.Id || titleSheet.Id.trim() === '') {
console.warn('⚠️ TitleSheet has no ID, generating new one');
titleSheet.Id = crypto.randomUUID();
}
const titleSheets = this.getAllTitleSheets();
const index = titleSheets.findIndex(ts => ts.Id === titleSheet.Id);
// Update modification date
titleSheet.LastModified = new Date();
if (index >= 0) {
// Update existing title sheet
titleSheets[index] = { ...titleSheet };
} else {
// Add new title sheet
titleSheets.push({ ...titleSheet });
}
// Update and save
this.titleSheetsSubject.next(titleSheets);
this.saveToStorage(titleSheets);
}
// Add a new title sheet (more explicit method)
addTitleSheet(titleSheet: TitleSheet): void {
// Ensure we have a valid ID
if (!titleSheet.Id || titleSheet.Id.trim() === '') {
titleSheet.Id = crypto.randomUUID();
}
const titleSheets = this.getAllTitleSheets();
// Set creation and modification dates
const now = new Date();
titleSheet.CreatedAt = now;
titleSheet.LastModified = now;
// Add to list
titleSheets.push({ ...titleSheet });
// Update and save
this.titleSheetsSubject.next(titleSheets);
this.saveToStorage(titleSheets);
}
// Delete a title sheet
deleteTitleSheet(id: string): void {
const titleSheets = this.getAllTitleSheets().filter(ts => ts.Id !== id);
this.titleSheetsSubject.next(titleSheets);
this.saveToStorage(titleSheets);
}
// Create new empty title sheet
createNewTitleSheet(): TitleSheet {
return createEmptyTitleSheet();
}
// Find title sheet by ID
getTitleSheetById(id: string): TitleSheet | undefined {
return this.getAllTitleSheets().find(ts => ts.Id === id);
}
}

View File

@ -0,0 +1,167 @@
// src/app/services/toast/toast.service.ts
import { Injectable, signal } from '@angular/core';
export interface Toast {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title: string;
message: string;
duration: number;
timestamp: Date;
progress?: number;
showClose?: boolean;
showProgress?: boolean;
}
@Injectable({
providedIn: 'root'
})
export class ToastService {
private toasts = signal<Toast[]>([]);
private nextId = 1;
// Public getter for toasts
public toasts$ = this.toasts.asReadonly();
constructor() {}
/**
* Show a success toast
*/
success(title: string, message: string = '', duration: number = 5000): string {
return this.addToast('success', title, message, duration);
}
/**
* Show an error toast
*/
error(title: string, message: string = '', duration: number = 10000): string {
return this.addToast('error', title, message, duration);
}
/**
* Show a warning toast
*/
warning(title: string, message: string = '', duration: number = 7000): string {
return this.addToast('warning', title, message, duration);
}
/**
* Show an info toast
*/
info(title: string, message: string = '', duration: number = 5000): string {
return this.addToast('info', title, message, duration);
}
/**
* Show a connection test result toast
*/
connectionTest(success: boolean, testName: string, details: string = '', duration: number = 30000): string {
const title = success
? `${testName} - Connection Successful`
: `${testName} - Connection Failed`;
const message = details ? `<strong>Details:</strong> ${details}` : '';
return this.addToast(success ? 'success' : 'error', title, message, duration);
}
/**
* Show a persistent toast (manual close only)
*/
persistent(type: Toast['type'], title: string, message: string = ''): string {
return this.addToast(type, title, message, 0); // 0 duration = persistent
}
/**
* Remove a specific toast
*/
remove(id: string): void {
const currentToasts = this.toasts();
this.toasts.set(currentToasts.filter(toast => toast.id !== id));
}
/**
* Clear all toasts
*/
clear(): void {
this.toasts.set([]);
}
/**
* Clear all toasts of a specific type
*/
clearByType(type: Toast['type']): void {
const currentToasts = this.toasts();
this.toasts.set(currentToasts.filter(toast => toast.type !== type));
}
/**
* Update toast progress (for long-running operations)
*/
updateProgress(id: string, progress: number): void {
const currentToasts = this.toasts();
const toastIndex = currentToasts.findIndex(toast => toast.id === id);
if (toastIndex >= 0) {
const updatedToasts = [...currentToasts];
updatedToasts[toastIndex] = {
...updatedToasts[toastIndex],
progress: Math.max(0, Math.min(100, progress))
};
this.toasts.set(updatedToasts);
}
}
/**
* Add a new toast
*/
private addToast(type: Toast['type'], title: string, message: string, duration: number): string {
const id = `toast-${this.nextId++}`;
const toast: Toast = {
id,
type,
title,
message,
duration,
timestamp: new Date(),
showClose: true,
showProgress: duration > 0
};
// Add to beginning of array so new toasts appear on top
const currentToasts = this.toasts();
this.toasts.set([toast, ...currentToasts]);
// Auto-remove after duration (unless duration is 0 for persistent toasts)
if (duration > 0) {
this.startAutoRemove(id, duration);
}
console.log(`🔔 Toast shown: ${toast.type} - ${toast.title}`);
return id;
}
/**
* Start auto-remove timer with progress
*/
private startAutoRemove(id: string, duration: number): void {
const startTime = Date.now();
const interval = 100; // Update progress every 100ms
const updateProgress = () => {
const elapsed = Date.now() - startTime;
const progress = Math.max(0, Math.min(100, (elapsed / duration) * 100));
this.updateProgress(id, progress);
if (elapsed >= duration) {
this.remove(id);
} else {
setTimeout(updateProgress, interval);
}
};
setTimeout(updateProgress, interval);
}
}

View File

@ -0,0 +1,338 @@
// src/app/services/utils/cbor.service.ts
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class CborService {
private CBOR: any;
private isInitialized = false;
constructor() {
this.initializeCBOR();
}
// === INITIALIZATION ===
private async initializeCBOR(): Promise<void> {
try {
const cborModule = await import('cbor-x');
this.CBOR = cborModule;
this.isInitialized = true;
console.log('✅ CBOR service initialized with cbor-x');
} catch (error) {
console.error('❌ Failed to initialize CBOR service:', error);
console.log('📝 CBOR features will be disabled, falling back to JSON only');
this.isInitialized = false;
}
}
/**
* Check if CBOR service is ready to use
*/
isReady(): boolean {
return this.isInitialized && this.CBOR;
}
/**
* Detect if data is CBOR format
*/
isCBORData(data: any): boolean {
if (!data) return false;
// CBOR data is typically ArrayBuffer, Uint8Array, or binary-looking data
if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
return true;
}
// Check if it's a string that looks like binary data
if (typeof data === 'string') {
// CBOR often starts with specific byte patterns
// Major types: arrays (0x80-0x9F), maps (0xA0-0xBF), etc.
const firstChar = data.charCodeAt(0);
// Check for common CBOR major types
return (firstChar >= 0x80 && firstChar <= 0xBF) || // arrays and maps
(firstChar >= 0x40 && firstChar <= 0x5F) || // byte strings
(firstChar >= 0x60 && firstChar <= 0x7F); // text strings
}
return false;
}
/**
* Parse CBOR data to JavaScript object
*/
parseCBOR(data: any): any {
if (!this.isReady()) {
throw new Error('CBOR service not initialized');
}
try {
let binaryData: Uint8Array;
// Convert different data types to Uint8Array
if (data instanceof ArrayBuffer) {
binaryData = new Uint8Array(data);
} else if (data instanceof Uint8Array) {
binaryData = data;
} else if (typeof data === 'string') {
// Convert binary string to Uint8Array
binaryData = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) {
binaryData[i] = data.charCodeAt(i);
}
} else {
throw new Error('Unsupported data type for CBOR parsing');
}
const result = this.CBOR.decode(binaryData);
console.log('🔧 CBOR parsed successfully:', result);
return result;
} catch (error) {
console.error('❌ CBOR parsing failed:', error);
console.log('Raw data sample:', this.getDataSample(data));
throw new Error(`CBOR parsing failed: ${error}`);
}
}
/**
* Encode JavaScript object to CBOR
*/
encodeCBOR(obj: any): Uint8Array {
if (!this.isReady()) {
throw new Error('CBOR service not initialized');
}
try {
const encoded = this.CBOR.encode(obj);
console.log('🔧 Object encoded to CBOR:', obj);
return encoded;
} catch (error) {
console.error('❌ CBOR encoding failed:', error);
throw new Error(`CBOR encoding failed: ${error}`);
}
}
/**
* Universal parser - detects format and parses accordingly
*/
parseUniversal(data: any): any {
if (!data) {
return null;
}
// Handle Blob data (convert to ArrayBuffer first)
if (data instanceof Blob) {
console.log('🔧 Detected Blob format, converting to ArrayBuffer...');
return data.arrayBuffer().then((arrayBuffer: ArrayBuffer) => {
console.log('✅ Blob converted to ArrayBuffer, parsing...');
return this.parseUniversal(arrayBuffer);
}).catch((error: any) => {
console.error('❌ Failed to convert Blob to ArrayBuffer:', error);
return data; // Return as-is if conversion fails
});
}
// Try JSON first for string data
if (typeof data === 'string') {
try {
const jsonResult = JSON.parse(data);
console.log('📄 Parsed as JSON:', jsonResult);
return jsonResult;
} catch (jsonError) {
// If JSON parsing fails, check if it's CBOR
if (this.isCBORData(data) && this.isReady()) {
console.log('🔧 Detected CBOR format in string, parsing...');
try {
return this.parseCBOR(data);
} catch (cborError) {
console.warn('⚠️ CBOR parsing also failed:', cborError);
return data; // Return as-is if both fail
}
} else {
console.log(' String data is neither JSON nor CBOR, returning as-is');
return data;
}
}
}
// Check for binary CBOR data
if (this.isCBORData(data) && this.isReady()) {
console.log('🔧 Detected binary CBOR format, parsing...');
try {
return this.parseCBOR(data);
} catch (cborError) {
console.warn('⚠️ CBOR parsing failed:', cborError);
return data; // Return as-is if parsing fails
}
}
// If it's already an object, return as-is
if (typeof data === 'object') {
return data;
}
// Default: return as-is
console.log(' Data returned as-is (unknown format):', typeof data);
return data;
}
/**
* Format data for sending - prefers JSON but can use CBOR
*/
formatForSending(obj: any, preferCBOR: boolean = false): string | Uint8Array {
if (!obj) {
return preferCBOR && this.isReady() ? this.encodeCBOR(null) : JSON.stringify(null);
}
if (preferCBOR && this.isReady()) {
try {
return this.encodeCBOR(obj);
} catch (error) {
console.warn('⚠️ CBOR encoding failed, falling back to JSON:', error);
return JSON.stringify(obj);
}
} else {
return JSON.stringify(obj);
}
}
/**
* Get debug info about data format
*/
getDataInfo(data: any): {
type: string,
size: number,
format: 'json' | 'cbor' | 'binary' | 'unknown',
parseable: boolean
} {
const type = typeof data;
let size = 0;
let format: 'json' | 'cbor' | 'binary' | 'unknown' = 'unknown';
let parseable = false;
if (data instanceof Blob) {
size = data.size;
format = 'binary';
parseable = true; // Blobs can be converted to ArrayBuffer
} else if (data instanceof ArrayBuffer) {
size = data.byteLength;
format = 'binary';
parseable = this.isCBORData(data) && this.isReady();
} else if (data instanceof Uint8Array) {
size = data.length;
format = 'binary';
parseable = this.isCBORData(data) && this.isReady();
} else if (typeof data === 'string') {
size = data.length;
// Check if it's JSON
try {
JSON.parse(data);
format = 'json';
parseable = true;
} catch {
// Check if it's CBOR
if (this.isCBORData(data)) {
format = 'cbor';
parseable = this.isReady();
}
}
} else if (typeof data === 'object') {
size = JSON.stringify(data).length;
format = 'json';
parseable = true;
}
return { type, size, format, parseable };
}
/**
* Log data format info for debugging
*/
logDataInfo(data: any, context: string = ''): void {
const info = this.getDataInfo(data);
console.log(`🔍 Data Info ${context}:`, {
type: info.type,
size: `${info.size} bytes`,
format: info.format,
parseable: info.parseable,
cborReady: this.isReady(),
sample: this.getDataSample(data)
});
}
/**
* Get a safe sample of data for logging
*/
private getDataSample(data: any): string {
if (typeof data === 'string') {
return data.length > 100 ? data.substring(0, 100) + '...' : data;
} else if (data instanceof Blob) {
return `[Blob: ${data.size} bytes, type: ${data.type || 'unknown'}]`;
} else if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
const view = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
const sample = Array.from(view.slice(0, 16)).map(b => b.toString(16).padStart(2, '0')).join(' ');
return `[${sample}${view.length > 16 ? '...' : ''}]`;
} else {
return String(data).substring(0, 100);
}
}
/**
* Test CBOR functionality
*/
async testCBOR(): Promise<boolean> {
if (!this.isReady()) {
console.log('❌ CBOR service not ready');
return false;
}
try {
// Test encoding
const testObj = { test: true, number: 42, text: 'hello' };
const encoded = this.encodeCBOR(testObj);
console.log('✅ CBOR encoding test passed');
// Test decoding
const decoded = this.parseCBOR(encoded);
console.log('✅ CBOR decoding test passed');
// Verify data integrity
const matches = JSON.stringify(testObj) === JSON.stringify(decoded);
console.log(matches ? '✅ CBOR round-trip test passed' : '❌ CBOR round-trip test failed');
return matches;
} catch (error) {
console.error('❌ CBOR test failed:', error);
return false;
}
}
/**
* Get service status for debugging
*/
getStatus(): {
initialized: boolean,
ready: boolean,
library: string,
features: string[]
} {
return {
initialized: this.isInitialized,
ready: this.isReady(),
library: this.isReady() ? 'cbor-x' : 'none',
features: [
this.isReady() ? 'encode' : 'encode (disabled)',
this.isReady() ? 'decode' : 'decode (disabled)',
'json-fallback',
'auto-detection'
]
};
}
}

View File

@ -0,0 +1,9 @@
<!-- src/app/components/big-card/big-card.component.html -->
<div class="big-card-container">
<!-- Card Content - Full height -->
<div class="big-card-content">
<ng-content></ng-content>
</div>
</div>

View File

@ -0,0 +1,91 @@
// src/app/components/big-card/big-card.component.scss
.big-card-container {
display: flex;
flex-direction: column;
height: 100%;
min-width: 800px; // Largeur minimale requise
max-width: 1200px; // Largeur maximale pour éviter que ça soit trop large
margin: 0 auto; // Centrer la card
background: var(--bg-primary);
border-radius: var(--radius-medium);
overflow: hidden;
}
.big-card-content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0; // Important for flexbox scrolling
overflow-y: auto; // Allow vertical scrolling when content overflows
overflow-x: hidden; // Prevent horizontal scrolling
// Custom scrollbar styling - consistent with other components
scrollbar-width: thin;
scrollbar-color: #cbd5e0 #f7fafc;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: #f7fafc;
border-radius: 4px;
margin: 2px;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e0;
border-radius: 4px;
border: 1px solid #f7fafc;
&:hover {
background: #a0aec0;
}
&:active {
background: #718096;
}
}
}
// === RESPONSIVE DESIGN ===
@media (max-width: 900px) {
.big-card-container {
min-width: 700px;
border-radius: var(--radius-small);
}
}
@media (max-width: 768px) {
.big-card-container {
min-width: 600px;
}
}
@media (max-width: 600px) {
.big-card-container {
min-width: 100%; // Sur très petit écran, prendre toute la largeur
margin: 0;
}
}
// === ACCESSIBILITY ===
@media (prefers-reduced-motion: reduce) {
.big-card-container {
transition: none;
}
}
// === PRINT STYLES ===
@media print {
.big-card-container {
height: auto;
overflow: visible;
min-width: auto;
.big-card-content {
overflow: visible;
}
}
}

View File

@ -0,0 +1,15 @@
// src/app/components/big-card/big-card.component.ts
import { Component, input } from '@angular/core';
@Component({
selector: 'app-big-card',
standalone: true,
imports: [],
templateUrl: './big-card.component.html',
styleUrl: './big-card.component.scss'
})
export class BigCardComponent {
showHeader = input<boolean>(true);
}

View File

@ -0,0 +1,58 @@
<!-- src/app/components/cued-title/cued-title.component.html -->
<div class="cued-title-container">
<div class="cued-title-panel" [class]="statusClass()">
<!-- Status Indicator -->
<div class="status-indicator">
<div class="status-dot" [class]="statusClass()"></div>
<span class="status-text">{{ statusClass() | titlecase }}</span>
</div>
<!-- Title Info or Empty State -->
<!-- Shows current title or last attempted title (even if there was an error) -->
@if (hasCurrentTitle()) {
<div class="title-info">
<div class="title-details">
<h3 class="title-name">
{{ displayedTitle()?.FirstName }}
{{ displayedTitle()?.LastName }}
</h3>
<p class="title-positions">
<span class="position-primary">{{ displayedTitle()?.Function1 }}</span>
@if (displayedTitle()?.Function2) {
<span class="position-secondary">
• {{ displayedTitle()?.Function2 }}
</span>
}
</p>
</div>
<!-- Timer Section -->
<div class="timer-section">
<span class="timer" [class]="timerColorClass()">
{{ formattedTimer() }}
</span>
@if (isPlaying()) {
<span class="timer-label">Playing</span>
} @else {
<span class="timer-label">Stopped</span>
}
</div>
</div>
} @else {
<div class="empty-state">
<i class="fas fa-video-slash empty-icon" aria-hidden="true"></i>
<span class="empty-text">No title currently cued</span>
</div>
}
<!-- Error Message -->
@if (unrealStatus()?.errorMessage) {
<div class="error-message">
<i class="fas fa-exclamation-triangle" aria-hidden="true"></i>
<span>{{ unrealStatus()?.errorMessage }}</span>
</div>
}
</div>
</div>

View File

@ -0,0 +1,145 @@
.cued-title-container {
margin-bottom: var(--spacing-lg);
}
.cued-title-panel {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-medium);
padding: var(--spacing-lg);
box-shadow: var(--shadow-light);
display: flex;
align-items: center;
gap: var(--spacing-lg);
min-height: 70px;
transition: all 0.3s ease;
&.playing {
border-color: var(--color-success);
background: linear-gradient(90deg, var(--bg-tertiary) 0%, rgba(76, 175, 80, 0.05) 100%);
}
&.stopped {
border-color: var(--border-secondary);
}
&.error {
border-color: var(--color-danger);
background: linear-gradient(90deg, var(--bg-tertiary) 0%, rgba(244, 67, 54, 0.05) 100%);
}
}
.status-indicator {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-xs);
min-width: 80px;
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--text-muted);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
&.playing {
background: var(--color-success);
box-shadow: 0 0 12px rgba(76, 175, 80, 0.4);
}
&.stopped {
background: var(--text-muted);
}
&.error {
background: var(--color-danger);
box-shadow: 0 0 12px rgba(244, 67, 54, 0.4);
}
}
.status-text {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
}
}
.title-info {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.title-details {
.title-name {
margin: 0 0 var(--spacing-xs) 0;
color: var(--text-primary);
font-size: 18px;
font-weight: 600;
line-height: 1.2;
}
.title-positions {
margin: 0;
color: var(--text-secondary);
font-size: 14px;
.position-primary {
color: var(--text-accent);
font-weight: 500;
}
.position-secondary {
color: var(--text-secondary);
}
}
}
.title-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--spacing-xs);
.duration {
display: flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--color-success);
font-size: 13px;
font-weight: 600;
i {
font-size: 12px;
}
}
.last-update {
color: var(--text-muted);
font-size: 11px;
}
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-md);
color: var(--text-muted);
.empty-icon {
font-size: 20px;
opacity: 0.5;
}
.empty-text {
font-size: 14px;
font-style: italic;
}
}

View File

@ -0,0 +1,132 @@
// src/app/components/cued-title/cued-title.component.ts
import { Component, input, computed, signal, effect } from '@angular/core';
import { DatePipe } from '@angular/common';
import { TitleCasePipe } from '@angular/common';
import { TitleSheet, TitleSheetListStatus } from '../../../models/title-sheet.model';
@Component({
selector: 'app-cued-title',
standalone: true,
imports: [TitleCasePipe],
templateUrl: './cued-title.component.html',
styleUrl: './cued-title.component.scss'
})
export class CuedTitleComponent {
// === INPUT SIGNALS (ANGULAR 19) ===
unrealStatus = input<TitleSheetListStatus | null>(null);
// === INTERNAL SIGNALS ===
private playStartTime = signal<Date | null>(null);
private elapsedSeconds = signal<number>(0);
private intervalId : any = signal<number | null>(null);
// Keep track of the last attempted title (even if it failed)
private lastAttemptedTitle = signal<TitleSheet | null>(null);
// === COMPUTED SIGNALS ===
statusClass = computed(() => {
const status = this.unrealStatus();
if (!status) return 'stopped';
return status.isPlaying ? 'playing' : 'stopped';
});
hasCurrentTitle = computed(() => {
const status = this.unrealStatus();
const lastAttempted = this.lastAttemptedTitle();
// Show title if:
// 1. There's a current title, OR
// 2. There's an error/stopped state but we have a last attempted title
return status?.currentTitleSheet !== null ||
(lastAttempted !== null && (status?.errorMessage));
});
// Get the title to display (current or last attempted)
displayedTitle = computed(() => {
const status = this.unrealStatus();
// Prefer current title, fallback to last attempted title
return status?.currentTitleSheet || this.lastAttemptedTitle();
});
isPlaying = computed(() => {
const status = this.unrealStatus();
return status?.isPlaying || false;
});
// Format du timer : MM:SS
formattedTimer = computed(() => {
const seconds = this.elapsedSeconds();
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
});
// Couleur du timer selon les règles
timerColorClass = computed(() => {
if (!this.isPlaying()) return 'timer-stopped';
const seconds = this.elapsedSeconds();
if (seconds < 10) return 'timer-white';
return 'timer-red';
});
constructor() {
// Track the last attempted title for error display
effect(() => {
const status = this.unrealStatus();
if (status?.currentTitleSheet) {
// Update last attempted title when a new title is set
this.lastAttemptedTitle.set(status.currentTitleSheet);
} else if (!status?.isPlaying && !status?.errorMessage) {
// Clear last attempted title when voluntarily stopped (no error)
this.lastAttemptedTitle.set(null);
}
});
// Effect pour gérer le démarrage/arrêt du timer
effect(() => {
const isPlaying = this.isPlaying();
if (isPlaying) {
this.startTimer();
} else {
this.stopTimer();
this.resetTimer();
}
});
}
ngOnDestroy(): void {
this.stopTimer();
}
private startTimer(): void {
this.stopTimer();
this.playStartTime.set(new Date());
this.elapsedSeconds.set(0);
this.intervalId = setInterval(() => {
const startTime = this.playStartTime();
if (startTime) {
const now = new Date();
const elapsed = Math.floor((now.getTime() - startTime.getTime()) / 1000);
this.elapsedSeconds.set(elapsed);
}
}, 1000);
}
private stopTimer(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
private resetTimer(): void {
this.playStartTime.set(null);
this.elapsedSeconds.set(0);
}
}

View File

@ -0,0 +1,143 @@
<!-- src/app/view/components/details-panel/details-panel.component.html -->
<div class="details-panel">
@if (hasSelection()) {
<div class="panel-content">
<!-- Header Section -->
<div class="panel-header">
<div class="title-info">
<h3 class="panel-title">{{ fullName() }}</h3>
<div class="status-badge" [class]="statusInfo()?.isPlaying ? 'playing' : statusInfo()?.isSelected ? 'selected' : 'available'">
<i [class]="statusInfo()?.isPlaying ? 'fas fa-play' : statusInfo()?.isSelected ? 'fas fa-check' : 'fas fa-circle'" aria-hidden="true"></i>
<span>{{ statusInfo()?.status }}</span>
</div>
</div>
<div class="id-info">
<span class="id-label">ID:</span>
<code class="id-value">{{ formattedId() }}</code>
</div>
</div>
<!-- Functions Section -->
<div class="panel-section">
<h4 class="section-title">
<i class="fas fa-briefcase" aria-hidden="true"></i>
Functions
</h4>
<div class="functions-grid">
<div class="function-item primary">
<label class="function-label">Primary</label>
<div class="function-value" [class.empty]="!selectedTitleSheet()?.Function1">
{{ selectedTitleSheet()?.Function1 || 'Not specified' }}
</div>
</div>
<div class="function-item secondary">
<label class="function-label">Secondary</label>
<div class="function-value" [class.empty]="!selectedTitleSheet()?.Function2">
{{ selectedTitleSheet()?.Function2 || 'Not specified' }}
</div>
</div>
</div>
</div>
<!-- Statistics Section -->
<div class="panel-section">
<h4 class="section-title">
<i class="fas fa-chart-bar" aria-hidden="true"></i>
Statistics
</h4>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">Completeness</span>
<div class="stat-value">
<span class="stat-number">{{ fieldsInfo()?.completeness }}%</span>
<div class="progress-bar">
<div class="progress-fill" [style.width.%]="fieldsInfo()?.completeness"></div>
</div>
</div>
</div>
<div class="stat-item">
<span class="stat-label">Total Characters</span>
<span class="stat-number">{{ fieldsInfo()?.totalChars }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Filled Fields</span>
<span class="stat-number">{{ fieldsInfo()?.filledFields }}/4</span>
</div>
<div class="stat-item">
<span class="stat-label">Empty Fields</span>
<span class="stat-number">{{ fieldsInfo()?.emptyFields }}</span>
</div>
</div>
</div>
<!-- Timestamps Section -->
<div class="panel-section">
<h4 class="section-title">
<i class="fas fa-clock" aria-hidden="true"></i>
Timeline
</h4>
<div class="timestamps">
<div class="timestamp-item">
<i class="fas fa-plus timestamp-icon created" aria-hidden="true"></i>
<div class="timestamp-content">
<span class="timestamp-label">Created</span>
<span class="timestamp-date">{{ createdDateFormatted() }}</span>
</div>
</div>
<div class="timestamp-item">
<i class="fas fa-edit timestamp-icon modified" aria-hidden="true"></i>
<div class="timestamp-content">
<span class="timestamp-label">Last Modified</span>
<span class="timestamp-date">{{ modifiedDateFormatted() }}</span>
<span class="timestamp-relative">({{ timeSinceModified() }})</span>
</div>
</div>
</div>
</div>
<!-- Field Details Section -->
<div class="panel-section">
<h4 class="section-title">
<i class="fas fa-list" aria-hidden="true"></i>
Field Details
</h4>
<div class="field-details">
@for (field of fieldsInfo()?.fields; track field.label) {
<div class="field-detail-item" [class.empty]="!field.value">
<div class="field-header">
<span class="field-name">{{ field.label }}</span>
@if (field.value) {
<span class="field-length">{{ field.length }} chars</span>
} @else {
<span class="field-empty">Empty</span>
}
</div>
<div class="field-content">
@if (field.value) {
<span class="field-text">{{ field.value }}</span>
} @else {
<span class="field-placeholder">No value set</span>
}
</div>
</div>
}
</div>
</div>
</div>
} @else {
<!-- Empty State -->
<div class="empty-state">
<div class="empty-content">
<i class="fas fa-mouse-pointer empty-icon" aria-hidden="true"></i>
<h3 class="empty-title">No Selection</h3>
<p class="empty-description">
Select a title sheet from the list to view detailed information here.
</p>
</div>
</div>
}
</div>

View File

@ -0,0 +1,412 @@
// src/app/view/components/details-panel/details-panel.component.scss
.details-panel {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-medium);
height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.panel-content {
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
// === HEADER SECTION ===
.panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing-md);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border-primary);
}
.title-info {
flex: 1;
}
.panel-title {
margin: 0 0 var(--spacing-sm) 0;
color: var(--text-primary);
font-size: 18px;
font-weight: 600;
line-height: 1.3;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-small);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
&.playing {
background: rgba(76, 175, 80, 0.2);
color: var(--color-success);
border: 1px solid var(--color-success);
}
&.selected {
background: rgba(0, 122, 204, 0.2);
color: var(--color-primary);
border: 1px solid var(--color-primary);
}
&.available {
background: rgba(157, 157, 157, 0.2);
color: var(--text-secondary);
border: 1px solid var(--border-secondary);
}
i {
font-size: 10px;
}
}
.id-info {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--spacing-xs);
}
.id-label {
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.id-value {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 11px;
color: var(--text-secondary);
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: var(--radius-small);
}
// === SECTION STYLES ===
.panel-section {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.section-title {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin: 0;
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
i {
color: var(--text-accent);
font-size: 12px;
}
}
// === FUNCTIONS SECTION ===
.functions-grid {
display: grid;
gap: var(--spacing-md);
}
.function-item {
padding: var(--spacing-md);
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-medium);
&.primary {
border-left: 3px solid var(--color-primary);
}
&.secondary {
border-left: 3px solid var(--text-accent);
}
}
.function-label {
display: block;
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: var(--spacing-xs);
}
.function-value {
color: var(--text-primary);
font-size: 13px;
line-height: 1.4;
word-break: break-word;
&.empty {
color: var(--text-muted);
font-style: italic;
}
}
// === STATISTICS SECTION ===
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-md);
}
.stat-item {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
padding: var(--spacing-sm);
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-small);
}
.stat-label {
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-number {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.stat-value {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.progress-bar {
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-primary), var(--text-accent));
border-radius: 2px;
transition: width 0.3s ease;
}
// === TIMESTAMPS SECTION ===
.timestamps {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.timestamp-item {
display: flex;
gap: var(--spacing-md);
align-items: flex-start;
}
.timestamp-icon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
flex-shrink: 0;
margin-top: 2px;
&.created {
background: rgba(76, 175, 80, 0.2);
color: var(--color-success);
}
&.modified {
background: rgba(255, 152, 0, 0.2);
color: var(--color-warning);
}
}
.timestamp-content {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.timestamp-label {
font-size: 11px;
color: var(--text-secondary);
font-weight: 600;
}
.timestamp-date {
font-size: 12px;
color: var(--text-primary);
line-height: 1.3;
}
.timestamp-relative {
font-size: 10px;
color: var(--text-muted);
font-style: italic;
}
// === FIELD DETAILS SECTION ===
.field-details {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.field-detail-item {
padding: var(--spacing-sm);
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-small);
&.empty {
opacity: 0.6;
}
}
.field-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xs);
}
.field-name {
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.field-length {
font-size: 9px;
color: var(--text-accent);
font-family: 'Consolas', 'Monaco', monospace;
}
.field-empty {
font-size: 9px;
color: var(--color-warning);
text-transform: uppercase;
font-weight: 600;
}
.field-content {
min-height: 20px;
}
.field-text {
font-size: 12px;
color: var(--text-primary);
line-height: 1.4;
word-break: break-word;
}
.field-placeholder {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}
// === EMPTY STATE ===
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-xxl);
}
.empty-content {
text-align: center;
max-width: 250px;
}
.empty-icon {
font-size: 48px;
color: var(--text-muted);
margin-bottom: var(--spacing-lg);
opacity: 0.5;
}
.empty-title {
margin: 0 0 var(--spacing-md) 0;
color: var(--text-secondary);
font-size: 18px;
font-weight: 600;
}
.empty-description {
margin: 0;
color: var(--text-muted);
font-size: 13px;
line-height: 1.5;
}
// === RESPONSIVE DESIGN ===
@media (max-width: 768px) {
.panel-content {
padding: var(--spacing-md);
gap: var(--spacing-md);
}
.panel-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-sm);
}
.stats-grid {
grid-template-columns: 1fr;
}
.panel-title {
font-size: 16px;
}
}
// === ACCESSIBILITY ===
@media (prefers-reduced-motion: reduce) {
.progress-fill {
transition: none;
}
}
// === HIGH CONTRAST MODE ===
@media (prefers-contrast: high) {
.function-item,
.stat-item,
.field-detail-item {
border-width: 2px;
}
.status-badge {
border-width: 2px;
}
}

View File

@ -0,0 +1,111 @@
// src/app/view/components/details-panel/details-panel.component.ts
import { Component, input, computed } from '@angular/core';
import { DatePipe } from '@angular/common';
import { TitleSheet } from '../../../models/title-sheet.model';
@Component({
selector: 'app-details-panel',
standalone: true,
imports: [],
templateUrl: './details-panel.component.html',
styleUrl: './details-panel.component.scss'
})
export class DetailsPanelComponent {
// === INPUT SIGNALS ===
selectedTitleSheet = input<TitleSheet | null>(null);
// === COMPUTED SIGNALS ===
hasSelection = computed(() => this.selectedTitleSheet() !== null);
fullName = computed(() => {
const title = this.selectedTitleSheet();
if (!title) return '';
return `${title.FirstName} ${title.LastName}`.trim();
});
createdDateFormatted = computed(() => {
const title = this.selectedTitleSheet();
if (!title) return '';
return new Intl.DateTimeFormat('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(title.CreatedAt);
});
modifiedDateFormatted = computed(() => {
const title = this.selectedTitleSheet();
if (!title) return '';
return new Intl.DateTimeFormat('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(title.LastModified);
});
timeSinceModified = computed(() => {
const title = this.selectedTitleSheet();
if (!title) return '';
const now = new Date();
const modified = title.LastModified;
const diffMs = now.getTime() - modified.getTime();
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMinutes < 1) return 'Just now';
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
});
statusInfo = computed(() => {
const title = this.selectedTitleSheet();
if (!title) return null;
return {
isSelected: title.IsSelected || false,
isPlaying: title.IsPlaying || false,
status: title.IsPlaying ? 'Currently Playing' :
title.IsSelected ? 'Selected' : 'Available'
};
});
formattedId = computed(() => {
const title = this.selectedTitleSheet();
return title?.Id ? title.Id.substring(0, 8) + '...' : 'N/A';
});
fieldsInfo = computed(() => {
const title = this.selectedTitleSheet();
if (!title) return null;
const fields = [
{ label: 'First Name', value: title.FirstName, length: title.FirstName.length },
{ label: 'Last Name', value: title.LastName, length: title.LastName.length },
{ label: 'Primary Function', value: title.Function1, length: title.Function1.length },
{ label: 'Secondary Function', value: title.Function2, length: title.Function2.length }
];
const totalChars = fields.reduce((sum, field) => sum + field.length, 0);
const emptyFields = fields.filter(field => !field.value).length;
const filledFields = fields.length - emptyFields;
return {
fields,
totalChars,
emptyFields,
filledFields,
completeness: Math.round((filledFields / fields.length) * 100)
};
});
}

View File

@ -0,0 +1,38 @@
<!-- src/app/view/components/list-header/list-header.component.html -->
<div class="list-header">
<!-- Actions Column -->
<div class="header-column actions-column">
<span class="column-title">Actions</span>
</div>
<!-- Last Name Column -->
<div [class]="getColumnClass('LastName')" (click)="onColumnClick('LastName')">
<span class="column-title">Last Name</span>
<i [class]="getSortIconClass('LastName')" aria-hidden="true"></i>
</div>
<!-- First Name Column -->
<div [class]="getColumnClass('FirstName')" (click)="onColumnClick('FirstName')">
<span class="column-title">First Name</span>
<i [class]="getSortIconClass('FirstName')" aria-hidden="true"></i>
</div>
<!-- Function 1 Column -->
<div [class]="getColumnClass('Function1')" (click)="onColumnClick('Function1')">
<span class="column-title">Function 1</span>
<i [class]="getSortIconClass('Function1')" aria-hidden="true"></i>
</div>
<!-- Function 2 Column -->
<div [class]="getColumnClass('Function2')" (click)="onColumnClick('Function2')">
<span class="column-title">Function 2</span>
<i [class]="getSortIconClass('Function2')" aria-hidden="true"></i>
</div>
<!-- Play Controls Column -->
<div class="header-column controls-column">
<span class="column-title">Controls</span>
</div>
</div>

View File

@ -0,0 +1,158 @@
// src/app/view/components/list-header/list-header.component.scss
.list-header {
display: grid;
grid-template-columns: 120px 1fr 1fr 1fr 1fr 140px;
gap: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-lg);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
border-radius: var(--radius-medium) var(--radius-medium) 0 0;
position: sticky;
top: 0;
z-index: 50;
}
.header-column {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-sm);
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: all 0.2s ease;
user-select: none;
&:not(.actions-column):not(.controls-column) {
cursor: pointer;
border-radius: var(--radius-small);
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
.sort-icon.inactive {
opacity: 0.6;
}
}
&:active {
transform: translateY(1px);
}
&.active {
background: var(--bg-active);
color: white;
box-shadow: var(--shadow-light);
}
}
.column-title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sort-icon {
font-size: 10px;
margin-left: var(--spacing-xs);
transition: all 0.2s ease;
&.inactive {
opacity: 0.3;
color: var(--text-muted);
}
&.active {
opacity: 1;
color: currentColor;
}
}
}
// === SPECIFIC COLUMN STYLES ===
.actions-column,
.controls-column {
justify-content: center;
color: var(--text-muted);
font-size: 11px;
background: var(--bg-tertiary);
border-radius: var(--radius-small);
}
// === RESPONSIVE DESIGN ===
@media (max-width: 1200px) {
.list-header {
grid-template-columns: 100px 1fr 1fr 1fr 1fr 120px;
padding: var(--spacing-sm) var(--spacing-md);
}
.header-column {
padding: var(--spacing-xs);
font-size: 11px;
}
}
@media (max-width: 900px) {
.list-header {
grid-template-columns: 80px 1fr 1fr 1fr 0.8fr 100px;
}
.header-column {
.column-title {
font-size: 10px;
}
}
}
@media (max-width: 768px) {
.list-header {
grid-template-columns: 60px 1fr 1fr 1fr 80px;
gap: var(--spacing-xs);
}
// Hide Function2 column on small screens
.header-column:nth-child(5) {
display: none;
}
}
// === ACCESSIBILITY ===
@media (prefers-reduced-motion: reduce) {
.header-column {
transition: none;
&:active {
transform: none;
}
}
.sort-icon {
transition: none;
}
}
// === HIGH CONTRAST MODE ===
@media (prefers-contrast: high) {
.list-header {
border-bottom-width: 2px;
}
.header-column {
&.active {
outline: 2px solid var(--color-primary);
}
}
}
// === FOCUS MANAGEMENT ===
.header-column:not(.actions-column):not(.controls-column) {
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
}

View File

@ -0,0 +1,65 @@
// src/app/view/components/list-header/list-header.component.ts
import { Component, input, output } from '@angular/core';
export type SortField = 'LastName' | 'FirstName' | 'Function1' | 'Function2' | 'LastModified';
export type SortDirection = 'asc' | 'desc';
export interface SortConfig {
field: SortField;
direction: SortDirection;
}
@Component({
selector: 'app-list-header',
standalone: true,
imports: [],
templateUrl: './list-header.component.html',
styleUrl: './list-header.component.scss'
})
export class ListHeaderComponent {
// === INPUT SIGNALS ===
currentSort = input<SortConfig>({ field: 'LastName', direction: 'asc' });
// === OUTPUT SIGNALS ===
sortChange = output<SortConfig>();
// === METHODS ===
onColumnClick(field: SortField): void {
const currentSortConfig = this.currentSort();
let newDirection: SortDirection = 'asc';
// If clicking the same field, toggle direction
if (currentSortConfig.field === field) {
newDirection = currentSortConfig.direction === 'asc' ? 'desc' : 'asc';
}
const newSort: SortConfig = { field, direction: newDirection };
this.sortChange.emit(newSort);
}
getSortIconClass(field: SortField): string {
const currentSortConfig = this.currentSort();
if (currentSortConfig.field !== field) {
return 'fas fa-sort sort-icon inactive';
}
return currentSortConfig.direction === 'asc'
? 'fas fa-sort-up sort-icon active'
: 'fas fa-sort-down sort-icon active';
}
getColumnClass(field: SortField): string {
const currentSortConfig = this.currentSort();
const baseClass = 'header-column';
if (currentSortConfig.field === field) {
return `${baseClass} active`;
}
return baseClass;
}
}

View File

@ -0,0 +1,131 @@
<!-- src/app/view/components/list-item/list-item.component.html -->
<div [class]="rowClass" (click)="onRowClick()">
<!-- Edit Action -->
<div class="item-column actions-column">
<button
class="action-btn edit-btn"
(click)="onEditClick()"
[disabled]="isEditing()"
[title]="'Edit title sheet'"
type="button">
<i class="fas fa-cog" aria-hidden="true"></i>
</button>
</div>
<!-- Last Name -->
<div class="item-column data-column" (click)="onCellClick('LastName', $event)">
@if (isFieldEditing('LastName')) {
<input
#editInput
type="text"
class="edit-input"
[value]="getFieldValue('LastName')"
(input)="onInputChange('LastName', $event)"
(keydown)="onInputKeyDown($event)"
(blur)="onInputBlur()"
placeholder="Last name..."
maxlength="50">
} @else {
<span class="cell-content" [class.empty]="!titleSheet().LastName">
{{ titleSheet().LastName || 'No last name' }}
</span>
}
</div>
<!-- First Name -->
<div class="item-column data-column" (click)="onCellClick('FirstName', $event)">
@if (isFieldEditing('FirstName')) {
<input
#editInput
type="text"
class="edit-input"
[value]="getFieldValue('FirstName')"
(input)="onInputChange('FirstName', $event)"
(keydown)="onInputKeyDown($event)"
(blur)="onInputBlur()"
placeholder="First name..."
maxlength="50">
} @else {
<span class="cell-content" [class.empty]="!titleSheet().FirstName">
{{ titleSheet().FirstName || 'No first name' }}
</span>
}
</div>
<!-- Function 1 -->
<div class="item-column data-column" (click)="onCellClick('Function1', $event)">
@if (isFieldEditing('Function1')) {
<input
#editInput
type="text"
class="edit-input"
[value]="getFieldValue('Function1')"
(input)="onInputChange('Function1', $event)"
(keydown)="onInputKeyDown($event)"
(blur)="onInputBlur()"
placeholder="Primary function..."
maxlength="100">
} @else {
<span class="cell-content" [class.empty]="!titleSheet().Function1">
{{ titleSheet().Function1 || 'No function' }}
</span>
}
</div>
<!-- Function 2 -->
<div class="item-column data-column" (click)="onCellClick('Function2', $event)">
@if (isFieldEditing('Function2')) {
<input
#editInput
type="text"
class="edit-input"
[value]="getFieldValue('Function2')"
(input)="onInputChange('Function2', $event)"
(keydown)="onInputKeyDown($event)"
(blur)="onInputBlur()"
placeholder="Secondary function..."
maxlength="100">
} @else {
<span class="cell-content" [class.empty]="!titleSheet().Function2">
{{ titleSheet().Function2 || 'No secondary function' }}
</span>
}
</div>
<!-- Control Actions -->
<div class="item-column controls-column">
<div class="control-buttons">
<!-- Play Button -->
@if (!isPlaying()) {
<button
class="control-btn play-btn"
(click)="onPlayClick()"
[title]="'Play rundown'"
type="button">
<i class="fas fa-play" aria-hidden="true"></i>
</button>
} @else {
<button
class="control-btn stop-btn"
(click)="onStopClick()"
[title]="'Stop rundown'"
type="button">
<i class="fas fa-stop" aria-hidden="true"></i>
</button>
}
<!-- Delete Button -->
<button
class="control-btn delete-btn"
(click)="onDeleteClick()"
[title]="'Delete title sheet'"
type="button">
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,296 @@
// src/app/view/components/list-item/list-item.component.scss
.list-item {
display: grid;
grid-template-columns: 120px 1fr 1fr 1fr 1fr 140px;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-lg);
background: var(--bg-primary);
border-bottom: 1px solid var(--border-primary);
transition: all 0.2s ease;
cursor: pointer;
min-height: 48px;
align-items: center;
&:hover {
background: var(--bg-hover);
border-color: var(--border-secondary);
}
&.selected {
background: var(--bg-active);
border-color: var(--border-active);
box-shadow: var(--shadow-light);
}
&.playing {
background: linear-gradient(90deg, rgba(76, 175, 80, 0.1) 0%, var(--bg-primary) 20%);
border-left: 3px solid var(--color-success);
.controls-column {
.play-btn {
background: var(--color-success);
color: white;
box-shadow: var(--shadow-medium);
}
}
}
&.editing {
background: var(--bg-secondary);
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary);
cursor: default;
}
&:last-child {
border-bottom: none;
}
}
// === COLUMNS ===
.item-column {
display: flex;
align-items: center;
padding: var(--spacing-xs);
min-height: 32px;
overflow: hidden;
}
.actions-column {
justify-content: flex-start;
}
.data-column {
position: relative;
cursor: text;
.list-item.editing & {
&:hover {
background: var(--bg-hover);
border-radius: var(--radius-small);
}
}
}
.controls-column {
justify-content: flex-end;
}
// === CELL CONTENT ===
.cell-content {
width: 100%;
color: var(--text-primary);
font-size: 13px;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.empty {
color: var(--text-muted);
font-style: italic;
}
}
// === EDIT INPUT ===
.edit-input {
width: 100%;
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--bg-primary);
border: 1px solid var(--border-active);
border-radius: var(--radius-small);
color: var(--text-primary);
font-family: inherit;
font-size: 13px;
line-height: 1.4;
outline: none;
transition: all 0.2s ease;
&:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2);
}
&::placeholder {
color: var(--text-muted);
font-style: italic;
}
}
// === BUTTONS ===
.action-btn,
.control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-small);
background: transparent;
color: var(--text-secondary);
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--bg-hover);
border-color: var(--border-secondary);
color: var(--text-primary);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
&:hover {
background: transparent;
border-color: var(--border-primary);
color: var(--text-secondary);
}
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
// === SPECIFIC BUTTON STYLES ===
.edit-btn {
color: var(--color-info);
border-color: var(--color-info);
&:hover:not(:disabled) {
background: var(--color-info);
color: white;
}
}
.play-btn {
color: var(--color-success);
border-color: var(--color-success);
&:hover {
background: var(--color-success);
color: white;
}
}
.stop-btn {
background: var(--color-warning);
border-color: var(--color-warning);
color: white;
&:hover {
background: #e68900;
border-color: #e68900;
}
}
.delete-btn {
color: var(--color-danger);
border-color: var(--color-danger);
&:hover {
background: var(--color-danger);
color: white;
}
}
// === CONTROL BUTTONS CONTAINER ===
.control-buttons {
display: flex;
gap: var(--spacing-xs);
align-items: center;
}
// === RESPONSIVE DESIGN ===
@media (max-width: 1200px) {
.list-item {
grid-template-columns: 100px 1fr 1fr 1fr 1fr 120px;
padding: var(--spacing-sm) var(--spacing-md);
}
.action-btn,
.control-btn {
width: 24px;
height: 24px;
font-size: 10px;
}
}
@media (max-width: 900px) {
.list-item {
grid-template-columns: 80px 1fr 1fr 1fr 0.8fr 100px;
}
.cell-content {
font-size: 12px;
}
}
@media (max-width: 768px) {
.list-item {
grid-template-columns: 60px 1fr 1fr 1fr 80px;
gap: var(--spacing-xs);
}
// Hide Function2 column on small screens
.item-column:nth-child(5) {
display: none;
}
.action-btn,
.control-btn {
width: 20px;
height: 20px;
font-size: 9px;
}
.control-buttons {
gap: 2px;
}
}
// === ACCESSIBILITY ===
@media (prefers-reduced-motion: reduce) {
.list-item,
.action-btn,
.control-btn,
.edit-input {
transition: none;
}
.action-btn:hover,
.control-btn:hover {
transform: none;
}
}
// === HIGH CONTRAST MODE ===
@media (prefers-contrast: high) {
.list-item {
border-bottom-width: 2px;
&.selected {
outline: 2px solid var(--color-primary);
}
&.editing {
outline: 3px solid var(--color-primary);
}
}
.action-btn,
.control-btn {
border-width: 2px;
}
}

View File

@ -0,0 +1,199 @@
// src/app/view/components/list-item/list-item.component.ts
import { Component, input, output, signal, effect, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TitleSheet } from '../../../models/title-sheet.model';
@Component({
selector: 'app-list-item',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './list-item.component.html',
styleUrl: './list-item.component.scss'
})
export class ListItemComponent {
// === INPUT SIGNALS ===
titleSheet = input.required<TitleSheet>();
isSelected = input<boolean>(false);
isPlaying = input<boolean>(false);
// === OUTPUT SIGNALS ===
select = output<TitleSheet>();
play = output<TitleSheet>();
stop = output<void>();
edit = output<TitleSheet>();
delete = output<string>();
// === INTERNAL SIGNALS ===
isEditing = signal<boolean>(false);
editingField = signal<string | null>(null);
editedValues = signal<Partial<TitleSheet>>({});
// === TEMPLATE REFERENCES ===
@ViewChild('editInput') editInput!: ElementRef<HTMLInputElement>;
constructor() {
// Reset edited values when editing starts
effect(() => {
if (this.isEditing()) {
const title = this.titleSheet();
this.editedValues.set({
LastName: title.LastName,
FirstName: title.FirstName,
Function1: title.Function1,
Function2: title.Function2
});
}
});
}
// === EVENT HANDLERS ===
onRowClick(): void {
if (!this.isEditing()) {
this.select.emit(this.titleSheet());
}
}
onEditClick(): void {
this.isEditing.set(true);
this.editingField.set('LastName'); // Start with first field
this.focusEditInput();
}
onCellClick(field: string, event: Event): void {
if (this.isEditing()) {
event.stopPropagation();
this.editingField.set(field);
this.focusEditInput();
}
}
onInputChange(field: string, event: Event): void {
const target = event.target as HTMLInputElement;
const value = target?.value || '';
this.editedValues.update(current => ({
...current,
[field]: value
}));
}
onInputKeyDown(event: KeyboardEvent): void {
switch (event.key) {
case 'Enter':
this.saveChanges();
break;
case 'Escape':
this.cancelEdit();
break;
case 'Tab':
event.preventDefault();
this.moveToNextField(event.shiftKey);
break;
}
}
onInputBlur(): void {
// Small delay to allow clicking on other fields
setTimeout(() => {
if (this.isEditing() && !this.isInputFocused()) {
this.saveChanges();
}
}, 150);
}
onPlayClick(): void {
this.play.emit(this.titleSheet());
}
onStopClick(): void {
this.stop.emit();
}
onDeleteClick(): void {
if (confirm(`Delete title sheet for ${this.titleSheet().FirstName} ${this.titleSheet().LastName}?`)) {
this.delete.emit(this.titleSheet().Id);
}
}
// === EDIT METHODS ===
saveChanges(): void {
const original = this.titleSheet();
const edited = this.editedValues();
const updatedTitleSheet: TitleSheet = {
...original,
LastName: edited.LastName || '',
FirstName: edited.FirstName || '',
Function1: edited.Function1 || '',
Function2: edited.Function2 || '',
LastModified: new Date()
};
this.edit.emit(updatedTitleSheet);
this.isEditing.set(false);
this.editingField.set(null);
}
cancelEdit(): void {
this.isEditing.set(false);
this.editingField.set(null);
this.editedValues.set({});
}
private moveToNextField(reverse: boolean = false): void {
const fields = ['LastName', 'FirstName', 'Function1', 'Function2'];
const currentField = this.editingField();
const currentIndex = currentField ? fields.indexOf(currentField) : -1;
let nextIndex;
if (reverse) {
nextIndex = currentIndex <= 0 ? fields.length - 1 : currentIndex - 1;
} else {
nextIndex = currentIndex >= fields.length - 1 ? 0 : currentIndex + 1;
}
this.editingField.set(fields[nextIndex]);
this.focusEditInput();
}
private focusEditInput(): void {
setTimeout(() => {
this.editInput?.nativeElement?.focus();
this.editInput?.nativeElement?.select();
}, 0);
}
private isInputFocused(): boolean {
return document.activeElement === this.editInput?.nativeElement;
}
// === COMPUTED GETTERS ===
get rowClass(): string {
const classes = ['list-item'];
if (this.isSelected()) classes.push('selected');
if (this.isPlaying()) classes.push('playing');
if (this.isEditing()) classes.push('editing');
return classes.join(' ');
}
getFieldValue(field: string): string {
if (this.isEditing()) {
const edited = this.editedValues();
return edited[field as keyof TitleSheet] as string || '';
}
const title = this.titleSheet();
return title[field as keyof TitleSheet] as string || '';
}
isFieldEditing(field: string): boolean {
return this.isEditing() && this.editingField() === field;
}
}

View File

@ -0,0 +1,53 @@
<header class="menu-bar">
<div class="menu-container">
<!-- Brand Section -->
<div class="menu-brand">
<i class="fas fa-film brand-icon" aria-hidden="true"></i>
<span class="brand-text">Unreal Title Sheets</span>
</div>
<!-- Navigation Actions -->
<nav class="menu-actions" role="navigation" aria-label="Main navigation">
<!-- Open Config Button -->
<button
type="button"
class="menu-btn"
(click)="configOpen.emit()"
[attr.aria-label]="'Open configuration file'"
[title]="'Open Configuration File (Ctrl+O)'">
<i class="fas fa-folder-open" aria-hidden="true"></i>
<span class="btn-text">Open Config</span>
</button>
<!-- Save Config Button -->
<button
type="button"
class="menu-btn"
(click)="configSave.emit()"
[attr.aria-label]="'Save configuration file'"
[title]="'Save Configuration File (Ctrl+S)'">
<i class="fas fa-save" aria-hidden="true"></i>
<span class="btn-text">Save Config</span>
</button>
<!-- Setup Link -->
<a
routerLink="/setup"
routerLinkActive="active"
class="menu-btn menu-link"
[attr.aria-label]="'Go to application settings'"
[title]="'Application Settings'">
<i class="fas fa-cog" aria-hidden="true"></i>
<span class="btn-text">Setup</span>
</a>
</nav>
<!-- Connection Status -->
<div class="menu-status">
</div>
</div>
</header>

View File

@ -0,0 +1,231 @@
.menu-bar {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
box-shadow: var(--shadow-light);
position: sticky;
top: 0;
z-index: 100;
width: 100%;
}
.menu-container {
max-width: 950px;
margin: 0 auto;
padding: 0 var(--spacing-lg);
display: flex;
justify-content: space-between;
align-items: center;
height: 48px;
gap: var(--spacing-lg);
}
// === BRAND SECTION ===
.menu-brand {
display: flex;
align-items: center;
gap: var(--spacing-md);
flex-shrink: 0;
.brand-icon {
color: var(--text-accent);
font-size: 18px;
transition: color 0.2s ease;
&:hover {
color: var(--color-primary);
}
}
.brand-text {
color: var(--text-primary);
font-weight: 600;
font-size: 16px;
letter-spacing: 0.3px;
white-space: nowrap;
@media (max-width: 600px) {
display: none;
}
}
}
// === NAVIGATION ACTIONS ===
.menu-actions {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
// === CONNECTION STATUS ===
.menu-status {
display: flex;
align-items: center;
flex-shrink: 0;
}
.menu-btn,
.menu-link {
// Reset & Base Styles
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border-primary);
border-radius: var(--radius-medium);
background: transparent;
color: var(--text-secondary);
text-decoration: none;
font-family: inherit;
font-size: 13px;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
min-height: 32px;
// Focus Management
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
// Hover States
&:hover {
background: var(--bg-hover);
border-color: var(--border-secondary);
color: var(--text-primary);
transform: translateY(-1px);
}
// Active States
&:active {
transform: translateY(0);
background: var(--bg-active);
border-color: var(--border-active);
}
// Disabled States
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
&:hover {
background: transparent;
border-color: var(--border-primary);
color: var(--text-secondary);
}
}
// Icon Styling
i {
font-size: 12px;
width: 12px;
text-align: center;
flex-shrink: 0;
}
// Text Styling
.btn-text {
font-family: inherit;
@media (max-width: 768px) {
display: none;
}
}
}
// === ROUTER LINK ACTIVE STATE ===
.menu-link {
&.active {
background: linear-gradient(135deg, var(--color-primary) 0%, var(--border-active) 100%);
border-color: var(--border-active);
color: white;
box-shadow: var(--shadow-medium);
&:hover {
background: linear-gradient(135deg, #005a9e 0%, var(--color-primary) 100%);
color: white;
}
}
}
// === RESPONSIVE DESIGN ===
@media (max-width: 768px) {
.menu-container {
padding: 0 var(--spacing-md);
height: 44px;
}
.menu-actions {
gap: var(--spacing-xs);
}
.menu-btn,
.menu-link {
padding: var(--spacing-sm);
min-width: 32px;
justify-content: center;
&:hover .btn-text {
display: none;
}
}
}
@media (max-width: 480px) {
.menu-container {
padding: 0 var(--spacing-sm);
height: 40px;
}
.menu-brand {
gap: var(--spacing-sm);
.brand-icon {
font-size: 16px;
}
}
.menu-btn,
.menu-link {
padding: var(--spacing-xs);
min-width: 28px;
i {
font-size: 11px;
}
}
}
// === ACCESSIBILITY ===
@media (prefers-reduced-motion: reduce) {
.menu-btn,
.menu-link {
transition: none;
}
}
// === HIGH CONTRAST MODE ===
@media (prefers-contrast: high) {
.menu-bar {
border-bottom-width: 2px;
}
.menu-btn,
.menu-link {
border-width: 2px;
&:focus-visible {
outline-width: 3px;
}
}
}
// === PRINT STYLES ===
@media print {
.menu-bar {
display: none;
}
}

View File

@ -0,0 +1,16 @@
// src/app/components/menu-bar/menu-bar.component.ts
import { Component, output } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-menu-bar',
standalone: true,
imports: [RouterLink, RouterLinkActive],
templateUrl: "./menu-bar.component.html",
styleUrls: ["./menu-bar.component.scss"]
})
export class MenuBarComponent {
// === MODERN ANGULAR 19 OUTPUT SIGNALS ===
configOpen = output<void>();
configSave = output<void>();
}

View File

@ -0,0 +1,157 @@
<!-- src/app/view/components/motion-design-monitor/motion-design-monitor.component.html -->
<div class="motion-design-monitor">
<div class="header">
<h3>
<i class="fas fa-film"></i>
Motion Design Monitor
</h3>
<div class="connection-status">
<i [class]="connectionIcon()"></i>
<span>{{ status()?.isConnected ? 'Connected' : 'Disconnected' }}</span>
</div>
</div>
<div class="actions">
<button
class="btn btn-primary"
[disabled]="isConnecting()"
(click)="testConnection()">
<i class="fas fa-plug" [class.fa-spin]="isConnecting()"></i>
{{ isConnecting() ? 'Connecting...' : 'Test Connection' }}
</button>
<button
class="btn btn-secondary"
[disabled]="!status()?.isConnected"
(click)="refreshRundowns()">
<i class="fas fa-refresh"></i>
Refresh Rundowns
</button>
</div>
<!-- Connection Details -->
@if (status(); as currentStatus) {
<div class="status-details">
<div class="status-item">
<label>Connected:</label>
<span [class]="currentStatus.isConnected ? 'text-success' : 'text-danger'">
{{ currentStatus.isConnected ? 'Yes' : 'No' }}
</span>
</div>
@if (currentStatus.isConnected) {
<div class="status-item">
<label>Handshake Complete:</label>
<span [class]="currentStatus.isHandshakeComplete ? 'text-success' : 'text-warning'">
{{ currentStatus.isHandshakeComplete ? 'Yes' : 'Pending' }}
</span>
</div>
}
@if (currentStatus.lastMessageSent) {
<div class="status-item">
<label>Last Message Sent:</label>
<span>{{ currentStatus.lastMessageSent | date:'medium' }}</span>
</div>
}
@if (currentStatus.lastMessageReceived) {
<div class="status-item">
<label>Last Message Received:</label>
<span>{{ currentStatus.lastMessageReceived | date:'medium' }}</span>
</div>
}
</div>
}
<!-- Rundowns Section -->
@if (state(); as currentState) {
<div class="rundowns-section">
<h4>Available Rundowns ({{ currentState.availableRundowns.length }})</h4>
@if (currentState.availableRundowns.length > 0) {
<div class="rundowns-list">
@for (rundown of currentState.availableRundowns; track trackRundown($index, rundown)) {
<div class="rundown-item"
[class.current]="isCurrentRundown(rundown)"
[class.configured]="isConfiguredRundown(rundown)">
<div class="rundown-info">
<span class="rundown-name">{{ rundown }}</span>
@if (isCurrentRundown(rundown)) {
<span class="badge badge-success">Current</span>
}
@if (isConfiguredRundown(rundown)) {
<span class="badge badge-info">Configured</span>
}
</div>
<div class="rundown-actions">
<button
class="btn btn-sm btn-primary"
[disabled]="!status()?.isConnected || isCurrentRundown(rundown)"
(click)="loadRundown(rundown)">
<i class="fas fa-play"></i>
Load
</button>
</div>
</div>
}
</div>
} @else {
<div class="no-rundowns">
<i class="fas fa-exclamation-triangle text-warning"></i>
<span>No rundowns available. Make sure Motion Design is connected and try refreshing.</span>
</div>
}
</div>
<!-- Pages Section -->
@if (currentState.currentRundown && currentState.currentPages.length > 0) {
<div class="pages-section">
<h4>Pages in "{{ currentState.currentRundown }}" ({{ currentState.currentPages.length }})</h4>
<div class="pages-list">
@for (page of currentState.currentPages; track trackPage($index, page)) {
<div class="page-item" [class.playing]="page.bIsPlaying">
<div class="page-info">
<span class="page-id">{{ page.PageId }}</span>
<span class="page-name">{{ page.FriendlyName || page.PageName }}</span>
@if (page.bIsPlaying) {
<span class="badge badge-success">Playing</span>
}
@if (!page.bIsEnabled) {
<span class="badge badge-warning">Disabled</span>
}
</div>
<div class="page-actions">
@if (!page.bIsPlaying) {
<button
class="btn btn-sm btn-success"
[disabled]="!page.bIsEnabled"
(click)="playPage(page.PageId)">
<i class="fas fa-play"></i>
Play
</button>
} @else {
<button
class="btn btn-sm btn-danger"
(click)="stopPage(page.PageId)">
<i class="fas fa-stop"></i>
Stop
</button>
}
</div>
</div>
}
</div>
</div>
}
}
<!-- Debug Section (only in development) -->
@if (isDevelopment()) {
<div class="debug-section">
<h4>Debug Information</h4>
<pre>{{ getDebugInfo() | json }}</pre>
</div>
}
</div>

View File

@ -0,0 +1,329 @@
/* src/app/view/components/motion-design-monitor/motion-design-monitor.component.scss */
.motion-design-monitor {
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #dee2e6;
h3 {
margin: 0;
color: #495057;
font-size: 1.25rem;
i {
margin-right: 0.5rem;
color: #6c757d;
}
}
.connection-status {
display: flex;
align-items: center;
font-weight: 500;
i {
margin-right: 0.5rem;
font-size: 1.1rem;
}
span {
font-size: 0.9rem;
}
}
}
.actions {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
.btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&.btn-primary {
background-color: #007bff;
color: white;
&:hover:not(:disabled) {
background-color: #0056b3;
}
}
&.btn-secondary {
background-color: #6c757d;
color: white;
&:hover:not(:disabled) {
background-color: #545b62;
}
}
&.btn-success {
background-color: #28a745;
color: white;
&:hover:not(:disabled) {
background-color: #1e7e34;
}
}
&.btn-danger {
background-color: #dc3545;
color: white;
&:hover:not(:disabled) {
background-color: #c82333;
}
}
&.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
}
}
.status-details {
background: white;
padding: 1rem;
border-radius: 6px;
margin-bottom: 1rem;
border: 1px solid #e9ecef;
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0;
&:not(:last-child) {
border-bottom: 1px solid #f8f9fa;
}
label {
font-weight: 500;
color: #495057;
margin: 0;
}
span {
font-size: 0.9rem;
&.text-success {
color: #28a745;
font-weight: 500;
}
&.text-danger {
color: #dc3545;
font-weight: 500;
}
&.text-warning {
color: #ffc107;
font-weight: 500;
}
}
}
}
.rundowns-section,
.pages-section {
background: white;
padding: 1rem;
border-radius: 6px;
margin-bottom: 1rem;
border: 1px solid #e9ecef;
h4 {
margin: 0 0 1rem 0;
color: #495057;
font-size: 1.1rem;
border-bottom: 1px solid #f8f9fa;
padding-bottom: 0.5rem;
}
.rundowns-list,
.pages-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.rundown-item,
.page-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: #f8f9fa;
border-radius: 4px;
border: 1px solid #e9ecef;
transition: all 0.2s ease;
&:hover {
background: #e9ecef;
}
&.current {
border-color: #28a745;
background: #d4edda;
}
&.configured {
border-color: #17a2b8;
background: #d1ecf1;
}
&.playing {
border-color: #28a745;
background: #d4edda;
}
.rundown-info,
.page-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
.rundown-name,
.page-name {
font-weight: 500;
color: #495057;
}
.page-id {
background: #6c757d;
color: white;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
min-width: 2rem;
text-align: center;
}
.badge {
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
&.badge-success {
background: #28a745;
color: white;
}
&.badge-info {
background: #17a2b8;
color: white;
}
&.badge-warning {
background: #ffc107;
color: #212529;
}
}
}
.rundown-actions,
.page-actions {
display: flex;
gap: 0.25rem;
}
}
.no-rundowns {
text-align: center;
padding: 2rem;
color: #6c757d;
i {
font-size: 2rem;
margin-bottom: 0.5rem;
display: block;
}
span {
font-size: 0.9rem;
}
}
}
.debug-section {
background: #343a40;
color: #f8f9fa;
padding: 1rem;
border-radius: 6px;
margin-top: 1rem;
h4 {
margin: 0 0 1rem 0;
color: #f8f9fa;
font-size: 1rem;
}
pre {
background: #495057;
padding: 1rem;
border-radius: 4px;
font-size: 0.8rem;
overflow-x: auto;
margin: 0;
white-space: pre-wrap;
}
}
// Responsive design
@media (max-width: 768px) {
.header {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
.connection-status {
justify-content: center;
}
}
.actions {
flex-direction: column;
}
.rundown-item,
.page-item {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
.rundown-actions,
.page-actions {
justify-content: center;
}
}
}
}

View File

@ -0,0 +1,196 @@
// src/app/view/components/motion-design-monitor/motion-design-monitor.component.ts
import { Component, signal, OnInit, OnDestroy, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Subscription } from 'rxjs';
import { UnrealMotionDesignService } from '../../../services/unreal-motion-design.service';
import { ConfigService } from '../../../services/config.service';
import {
MotionDesignState
} from '../../../models/motion-design-state.model';
import {
MotionDesignConnectionStatus
} from '../../../models/unreal-status.model';
import {
formatRundownAssetName,
AvaRundownPageInfo
} from '../../../models/rundown-messages.model';
import { AppConfig } from '../../../models/app-config.model';
@Component({
selector: 'app-motion-design-monitor',
standalone: true,
imports: [CommonModule],
templateUrl: './motion-design-monitor.component.html',
styleUrl: './motion-design-monitor.component.scss'
})
export class MotionDesignMonitorComponent implements OnInit, OnDestroy {
// === SIGNALS ===
status = signal<MotionDesignConnectionStatus | null>(null);
state = signal<MotionDesignState | null>(null);
config = signal<AppConfig | null>(null);
isConnecting = signal<boolean>(false);
// === COMPUTED SIGNALS ===
configuredRundownAsset = computed(() => {
const cfg = this.config();
return cfg?.UnrealEngine.MotionDesign.RundownAsset || null;
});
connectionIcon = computed(() => {
const status = this.status();
if (!status) return 'fas fa-times-circle text-danger';
if (status.isConnected && status.isHandshakeComplete) {
return 'fas fa-check-circle text-success';
} else if (status.isConnected) {
return 'fas fa-exclamation-circle text-warning';
} else {
return 'fas fa-times-circle text-danger';
}
});
private subscriptions: Subscription[] = [];
constructor(
private motionDesignService: UnrealMotionDesignService,
private configService: ConfigService
) {}
ngOnInit(): void {
this.initializeComponent();
}
ngOnDestroy(): void {
this.subscriptions.forEach(sub => sub.unsubscribe());
}
// === INITIALIZATION ===
private initializeComponent(): void {
// Subscribe to Motion Design service status
const statusSub = this.motionDesignService.status$.subscribe(status => {
this.status.set(status);
});
this.subscriptions.push(statusSub);
// Subscribe to Motion Design service state
const stateSub = this.motionDesignService.state$.subscribe(state => {
this.state.set(state);
});
this.subscriptions.push(stateSub);
// Subscribe to config changes
const configSub = this.configService.config$.subscribe(config => {
this.config.set(config);
// Update service configuration
this.motionDesignService.setConfig(config);
});
this.subscriptions.push(configSub);
}
// === ACTION METHODS ===
async testConnection(): Promise<void> {
this.isConnecting.set(true);
try {
await this.motionDesignService.testConnectivity();
} finally {
this.isConnecting.set(false);
}
}
async refreshRundowns(): Promise<void> {
try {
const rundowns = await this.motionDesignService.getAvailableRundowns();
console.log('🔄 Rundowns refreshed:', rundowns);
} catch (error) {
console.error('❌ Failed to refresh rundowns:', error);
}
}
async loadRundown(rundownPath: string): Promise<void> {
try {
const success = await this.motionDesignService.loadRundown(rundownPath);
if (success) {
console.log('✅ Rundown loaded successfully:', rundownPath);
}
} catch (error) {
console.error('❌ Failed to load rundown:', error);
}
}
async playPage(pageId: number): Promise<void> {
try {
const success = await this.motionDesignService.playPage(pageId);
if (success) {
console.log('✅ Page playing:', pageId);
}
} catch (error) {
console.error('❌ Failed to play page:', error);
}
}
async stopPage(pageId: number): Promise<void> {
try {
const success = await this.motionDesignService.stopPage(pageId);
if (success) {
console.log('✅ Page stopped:', pageId);
}
} catch (error) {
console.error('❌ Failed to stop page:', error);
}
}
// === HELPER METHODS ===
isCurrentRundown(rundownPath: string): boolean {
const state = this.state();
return state?.currentRundown === rundownPath;
}
isConfiguredRundown(rundownPath: string): boolean {
const configuredAsset = this.configuredRundownAsset();
return configuredAsset === rundownPath;
}
isDevelopment(): boolean {
const cfg = this.config();
return cfg?.Development?.EnableDevTools || false;
}
getDebugInfo(): any {
const status = this.status();
const state = this.state();
if (!status || !state) return { error: 'No data available' };
return {
connection: {
isConnected: status.isConnected,
isHandshakeComplete: status.isHandshakeComplete,
lastMessageSent: status.lastMessageSent,
lastMessageReceived: status.lastMessageReceived
},
rundowns: {
available: state.availableRundowns.length,
current: state.currentRundown || 'none',
configured: this.configuredRundownAsset()
},
pages: {
count: state.currentPages.length
}
};
}
// === TRACK BY FUNCTIONS ===
trackRundown(index: number, rundownPath: string): string {
return rundownPath;
}
trackPage(index: number, page: AvaRundownPageInfo): number {
return page.PageId;
}
}

View File

@ -0,0 +1,175 @@
<!-- src/app/view/components/title-dialog/title-dialog.component.html -->
<dialog
#dialogElement
class="title-dialog"
(click)="onDialogClick($event)"
(keydown)="onKeyDown($event)"
[attr.aria-labelledby]="'dialog-title'"
[attr.aria-modal]="'true'">
<div class="dialog-content">
<!-- Dialog Header -->
<header class="dialog-header">
<h2 id="dialog-title" class="dialog-title">
<i [class]="mode() === 'edit' ? 'fas fa-edit' : 'fas fa-plus'" aria-hidden="true"></i>
{{ dialogTitle }}
</h2>
<button
class="close-btn"
(click)="onCancel()"
type="button"
[attr.aria-label]="'Close dialog'"
[title]="'Close (Esc)'">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</header>
<!-- Dialog Body -->
<div class="dialog-body">
<form [formGroup]="formInstance" (ngSubmit)="onSubmit()" class="title-form" *ngIf="formInstance">
<!-- Name Fields Row -->
<div class="form-row">
<!-- First Name -->
<div class="form-field">
<label for="firstName" class="field-label">
First Name <span class="required">*</span>
</label>
<input
#firstNameInput
id="firstName"
type="text"
class="field-input"
formControlName="FirstName"
placeholder="Enter first name..."
maxlength="50"
[class.error]="hasFieldError('FirstName')"
autocomplete="given-name">
@if (hasFieldError('FirstName')) {
<span class="error-message">
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
{{ getErrorMessage('FirstName') }}
</span>
}
</div>
<!-- Last Name -->
<div class="form-field">
<label for="lastName" class="field-label">
Last Name <span class="required">*</span>
</label>
<input
id="lastName"
type="text"
class="field-input"
formControlName="LastName"
placeholder="Enter last name..."
maxlength="50"
[class.error]="hasFieldError('LastName')"
autocomplete="family-name">
@if (hasFieldError('LastName')) {
<span class="error-message">
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
{{ getErrorMessage('LastName') }}
</span>
}
</div>
</div>
<!-- Function Fields -->
<div class="form-field">
<label for="function1" class="field-label">
Primary Function <span class="required">*</span>
</label>
<input
id="function1"
type="text"
class="field-input"
formControlName="Function1"
placeholder="Enter primary function or title..."
maxlength="100"
[class.error]="hasFieldError('Function1')"
autocomplete="organization-title">
@if (hasFieldError('Function1')) {
<span class="error-message">
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
{{ getErrorMessage('Function1') }}
</span>
}
</div>
<div class="form-field">
<label for="function2" class="field-label">
Secondary Function <span class="optional">(Optional)</span>
</label>
<input
id="function2"
type="text"
class="field-input"
formControlName="Function2"
placeholder="Enter secondary function or department..."
maxlength="100"
[class.error]="hasFieldError('Function2')">
@if (hasFieldError('Function2')) {
<span class="error-message">
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
{{ getErrorMessage('Function2') }}
</span>
}
</div>
</form>
</div>
<!-- Dialog Footer -->
<footer class="dialog-footer">
<!-- Form Info -->
<div class="form-info">
@if (mode() === 'edit') {
<span class="info-text">
<i class="fas fa-edit" aria-hidden="true"></i>
Editing existing title sheet
</span>
} @else {
<span class="info-text">
<i class="fas fa-info-circle" aria-hidden="true"></i>
Fields marked with * are required
</span>
}
</div>
<!-- Action Buttons -->
<div class="dialog-actions">
<button
class="btn btn-secondary"
(click)="onCancel()"
type="button"
[disabled]="isSubmitting()">
Cancel
</button>
<button
class="btn btn-primary"
(click)="onSubmit()"
type="submit"
[disabled]="!isFormValid || isSubmitting()"
[class.loading]="isSubmitting()">
@if (isSubmitting()) {
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
} @else {
<i [class]="mode() === 'edit' ? 'fas fa-save' : 'fas fa-plus'" aria-hidden="true"></i>
}
{{ submitButtonText }}
</button>
</div>
</footer>
</div>
</dialog>

View File

@ -0,0 +1,463 @@
// src/app/view/components/title-dialog/title-dialog.component.scss
.title-dialog {
background: transparent;
border: none;
border-radius: var(--radius-large);
padding: 0;
margin: auto;
max-width: 600px;
width: 90vw;
max-height: 90vh;
overflow: visible;
box-shadow: var(--shadow-heavy);
animation: dialogFadeIn 0.2s ease-out;
z-index: 1000; // Ensure dialog is above other elements
&::backdrop {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
animation: backdropFadeIn 0.2s ease-out;
}
&:not([open]) {
display: none;
}
}
@keyframes dialogFadeIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes backdropFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.dialog-content {
background: var(--bg-secondary);
border: 1px solid var(--border-secondary);
border-radius: var(--radius-large);
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 400px;
position: relative;
z-index: 1001; // Ensure content is above backdrop
}
// === DIALOG HEADER ===
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg) var(--spacing-lg) var(--spacing-md) var(--spacing-lg);
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-primary);
}
.dialog-title {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin: 0;
color: var(--text-primary);
font-size: 18px;
font-weight: 600;
i {
color: var(--text-accent);
font-size: 16px;
}
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-medium);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--bg-hover);
border-color: var(--border-secondary);
color: var(--text-primary);
}
&:active {
transform: scale(0.95);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
i {
font-size: 12px;
}
}
// === DIALOG BODY ===
.dialog-body {
flex: 1;
padding: var(--spacing-lg);
overflow-y: auto;
}
.title-form {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
// === FORM ELEMENTS ===
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-lg);
}
.form-field {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.field-label {
display: flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
.required {
color: var(--color-danger);
font-weight: 700;
}
.optional {
color: var(--text-muted);
font-size: 11px;
font-weight: 400;
font-style: italic;
}
}
.field-input {
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-medium);
color: var(--text-primary);
font-family: inherit;
font-size: 14px;
line-height: 1.4;
transition: all 0.2s ease;
min-height: 40px;
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2);
background: var(--bg-secondary);
}
&:hover:not(:focus) {
border-color: var(--border-secondary);
}
&.error {
border-color: var(--color-danger);
box-shadow: 0 0 0 2px rgba(244, 67, 54, 0.2);
}
&::placeholder {
color: var(--text-muted);
font-style: italic;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
background: var(--bg-tertiary);
}
}
.error-message {
display: flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--color-danger);
font-size: 11px;
font-weight: 500;
i {
font-size: 10px;
flex-shrink: 0;
}
}
// === DIALOG FOOTER ===
.dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md) var(--spacing-lg) var(--spacing-lg) var(--spacing-lg);
background: var(--bg-primary);
border-top: 1px solid var(--border-primary);
}
.form-info {
flex: 1;
}
.info-text {
display: flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--text-muted);
font-size: 11px;
i {
font-size: 10px;
color: var(--text-accent);
}
}
.dialog-actions {
display: flex;
gap: var(--spacing-md);
align-items: center;
}
// === BUTTONS ===
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-lg);
border: 1px solid transparent;
border-radius: var(--radius-medium);
font-family: inherit;
font-size: 13px;
font-weight: 600;
line-height: 1.4;
cursor: pointer;
transition: all 0.2s ease;
min-width: 100px;
min-height: 36px;
white-space: nowrap;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
&:not(:disabled):hover {
transform: translateY(-1px);
}
&:not(:disabled):active {
transform: translateY(0);
}
i {
font-size: 12px;
flex-shrink: 0;
}
}
.btn-secondary {
background: transparent;
border-color: var(--border-secondary);
color: var(--text-secondary);
&:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-active);
color: var(--text-primary);
}
&:active:not(:disabled) {
background: var(--bg-active);
}
}
.btn-primary {
background: linear-gradient(135deg, var(--color-primary) 0%, #005a9e 100%);
border-color: var(--color-primary);
color: white;
box-shadow: var(--shadow-light);
&:hover:not(:disabled) {
background: linear-gradient(135deg, #005a9e 0%, var(--color-primary) 100%);
box-shadow: var(--shadow-medium);
}
&:active:not(:disabled) {
background: var(--color-primary);
box-shadow: var(--shadow-light);
}
&.loading {
cursor: wait;
i {
animation: spin 1s linear infinite;
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// === RESPONSIVE DESIGN ===
@media (max-width: 768px) {
.title-dialog {
width: 95vw;
max-width: none;
margin: 0;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.dialog-header,
.dialog-body,
.dialog-footer {
padding-left: var(--spacing-md);
padding-right: var(--spacing-md);
}
.form-row {
grid-template-columns: 1fr;
gap: var(--spacing-md);
}
.dialog-footer {
flex-direction: column;
gap: var(--spacing-md);
align-items: stretch;
}
.form-info {
text-align: center;
}
.dialog-actions {
justify-content: center;
width: 100%;
}
.btn {
flex: 1;
min-width: 0;
}
}
@media (max-width: 480px) {
.dialog-title {
font-size: 16px;
}
.field-input {
font-size: 16px; // Prevent zoom on iOS
}
.dialog-actions {
flex-direction: column;
gap: var(--spacing-sm);
}
}
// === ACCESSIBILITY ===
@media (prefers-reduced-motion: reduce) {
.title-dialog,
.title-dialog::backdrop {
animation: none;
}
.btn,
.close-btn,
.field-input {
transition: none;
}
.btn:not(:disabled):hover,
.btn:not(:disabled):active {
transform: none;
}
.btn-primary.loading i {
animation: none;
}
}
// === HIGH CONTRAST MODE ===
@media (prefers-contrast: high) {
.dialog-content {
border-width: 2px;
}
.field-input {
border-width: 2px;
&:focus {
outline: 3px solid var(--color-primary);
outline-offset: 1px;
}
&.error {
outline: 3px solid var(--color-danger);
outline-offset: 1px;
}
}
.btn {
border-width: 2px;
&:focus-visible {
outline-width: 3px;
}
}
}
// === PRINT STYLES ===
@media print {
.title-dialog {
display: none;
}
}

View File

@ -0,0 +1,233 @@
// src/app/view/components/title-dialog/title-dialog.component.ts
import { Component, input, output, signal, effect, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { TitleSheet, createEmptyTitleSheet } from '../../../models/title-sheet.model';
@Component({
selector: 'app-title-dialog',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './title-dialog.component.html',
styleUrl: './title-dialog.component.scss'
})
export class TitleDialogComponent {
// === INPUT SIGNALS ===
isOpen = input<boolean>(false);
titleSheet = input<TitleSheet | null>(null);
mode = input<'add' | 'edit'>('add');
// === OUTPUT SIGNALS ===
close = output<void>();
save = output<TitleSheet>();
// === INTERNAL SIGNALS ===
private form = signal<FormGroup | null>(null);
isSubmitting = signal<boolean>(false);
// === TEMPLATE REFERENCES ===
@ViewChild('dialogElement') dialogElement!: ElementRef<HTMLDialogElement>;
@ViewChild('firstNameInput') firstNameInput!: ElementRef<HTMLInputElement>;
constructor(private formBuilder: FormBuilder) {
// Initialize form after formBuilder injection
this.form.set(this.createForm());
// Handle dialog open/close
effect(() => {
const isOpen = this.isOpen();
const dialog = this.dialogElement?.nativeElement;
if (dialog) {
if (isOpen && !dialog.open) {
dialog.showModal();
this.focusFirstInput();
} else if (!isOpen && dialog.open) {
dialog.close();
}
}
});
// Load title sheet data when it changes
effect(() => {
const titleSheet = this.titleSheet();
if (titleSheet) {
this.loadTitleSheetToForm(titleSheet);
} else {
this.resetForm();
}
});
}
// === FORM MANAGEMENT ===
private createForm(): FormGroup {
return this.formBuilder.group({
LastName: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(50)]],
FirstName: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(50)]],
Function1: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(100)]],
Function2: ['', [Validators.maxLength(100)]]
});
}
private loadTitleSheetToForm(titleSheet: TitleSheet): void {
const form = this.form();
if (!form) return;
form.patchValue({
LastName: titleSheet.LastName,
FirstName: titleSheet.FirstName,
Function1: titleSheet.Function1,
Function2: titleSheet.Function2
});
}
private resetForm(): void {
const form = this.form();
if (!form) return;
form.reset();
form.markAsUntouched();
this.isSubmitting.set(false);
}
// === EVENT HANDLERS ===
onDialogClick(event: Event): void {
// Close dialog when clicking on backdrop (not on dialog content)
const target = event.target as HTMLElement;
const dialog = this.dialogElement?.nativeElement;
// Only close if the click was directly on the dialog element (backdrop)
if (target === dialog) {
this.onCancel();
}
}
onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
this.onCancel();
}
}
onSubmit(): void {
const form = this.form();
if (!form) return;
if (form.valid && !this.isSubmitting()) {
this.isSubmitting.set(true);
const formValue = form.value;
const currentTitleSheet = this.titleSheet();
const isEditMode = this.mode() === 'edit' && currentTitleSheet;
const titleSheet: TitleSheet = isEditMode ? {
...currentTitleSheet,
LastName: formValue.LastName.trim(),
FirstName: formValue.FirstName.trim(),
Function1: formValue.Function1.trim(),
Function2: formValue.Function2?.trim() || '',
LastModified: new Date()
} : {
...createEmptyTitleSheet(),
LastName: formValue.LastName.trim(),
FirstName: formValue.FirstName.trim(),
Function1: formValue.Function1.trim(),
Function2: formValue.Function2?.trim() || ''
};
this.save.emit(titleSheet);
this.isSubmitting.set(false);
// Close dialog after successful save
setTimeout(() => {
this.close.emit();
}, 100);
} else {
this.markFormGroupTouched();
}
}
onCancel(): void {
this.close.emit();
}
// === UTILITY METHODS ===
private markFormGroupTouched(): void {
const form = this.form();
if (!form) return;
Object.keys(form.controls).forEach(key => {
form.get(key)?.markAsTouched();
});
}
private focusFirstInput(): void {
setTimeout(() => {
this.firstNameInput?.nativeElement?.focus();
}, 100);
}
getErrorMessage(fieldName: string): string {
const form = this.form();
if (!form) return '';
const control = form.get(fieldName);
if (!control?.errors || !control.touched) return '';
if (control.hasError('required')) {
return `${this.getFieldDisplayName(fieldName)} is required`;
}
if (control.hasError('minlength')) {
const minLength = control.errors['minlength'].requiredLength;
return `${this.getFieldDisplayName(fieldName)} must be at least ${minLength} characters`;
}
if (control.hasError('maxlength')) {
const maxLength = control.errors['maxlength'].requiredLength;
return `${this.getFieldDisplayName(fieldName)} cannot exceed ${maxLength} characters`;
}
return '';
}
private getFieldDisplayName(fieldName: string): string {
const names: Record<string, string> = {
LastName: 'Last name',
FirstName: 'First name',
Function1: 'Primary function',
Function2: 'Secondary function'
};
return names[fieldName] || fieldName;
}
hasFieldError(fieldName: string): boolean {
const form = this.form();
if (!form) return false;
const control = form.get(fieldName);
return !!(control?.errors && control.touched);
}
// === COMPUTED GETTERS ===
get dialogTitle(): string {
return this.mode() === 'edit' ? 'Edit Title Sheet' : 'Add New Title Sheet';
}
get submitButtonText(): string {
if (this.isSubmitting()) {
return this.mode() === 'edit' ? 'Updating...' : 'Creating...';
}
return this.mode() === 'edit' ? 'Update' : 'Create';
}
get isFormValid(): boolean {
const form = this.form();
return form?.valid || false;
}
get formInstance(): FormGroup | null {
return this.form();
}
}

View File

@ -0,0 +1,84 @@
<!-- src/app/components/title-sheets-form/title-sheets-form.component.html -->
<mat-card class="form-card">
<mat-card-header>
<mat-card-title>
<mat-icon>{{ isEditing ? 'edit' : 'add' }}</mat-icon>
{{ isEditing ? 'Edit Title Sheet' : 'New Title Sheet' }}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<form [formGroup]="titleSheetForm" (ngSubmit)="onSubmit()" class="title-form">
<!-- First Row: LastName / FirstName -->
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Last Name</mat-label>
<input matInput
formControlName="LastName"
placeholder="Enter last name"
autocomplete="family-name">
<mat-icon matSuffix>person</mat-icon>
<mat-error *ngIf="titleSheetForm.get('LastName')?.invalid && titleSheetForm.get('LastName')?.touched">
{{ getErrorMessage('LastName') }}
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>First Name</mat-label>
<input matInput
formControlName="FirstName"
placeholder="Enter first name"
autocomplete="given-name">
<mat-icon matSuffix>badge</mat-icon>
<mat-error *ngIf="titleSheetForm.get('FirstName')?.invalid && titleSheetForm.get('FirstName')?.touched">
{{ getErrorMessage('FirstName') }}
</mat-error>
</mat-form-field>
</div>
<!-- Second Row: Function1 / Function2 -->
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Function 1</mat-label>
<input matInput
formControlName="Function1"
placeholder="Main Function"
autocomplete="organization-title">
<mat-icon matSuffix>work</mat-icon>
<mat-error *ngIf="titleSheetForm.get('Function1')?.invalid && titleSheetForm.get('Function1')?.touched">
{{ getErrorMessage('Function1') }}
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Function 2</mat-label>
<input matInput
formControlName="Function2"
placeholder="Secondary Function (optional)"
autocomplete="organization-title">
<mat-icon matSuffix>work_outline</mat-icon>
</mat-form-field>
</div>
<!-- Actions -->
<div class="form-actions">
<button mat-raised-button
color="primary"
type="submit"
[disabled]="!isFormValid">
<mat-icon>save</mat-icon>
{{ isEditing ? 'Update' : 'Create' }} Title Sheet
</button>
<button mat-stroked-button
type="button"
(click)="onReset()">
<mat-icon>refresh</mat-icon>
Reset
</button>
</div>
</form>
</mat-card-content>
</mat-card>

View File

@ -0,0 +1,131 @@
// src/app/components/title-sheets-form/title-sheets-form.component.ts
import { Component, Input, Output, EventEmitter, OnInit, OnChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
// Angular Material imports directs
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { TitleSheet } from '../../../models/title-sheet.model';
@Component({
selector: 'app-title-sheets-form',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
// Angular Material modules
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
MatIconModule
],
templateUrl: './title-sheets-form.component.html',
styleUrls: ['./title-sheets-form.component.scss']
})
export class TitleSheetsFormComponent implements OnInit, OnChanges {
// Données en entrée depuis le composant parent
@Input() titleSheet: TitleSheet | null = null;
@Input() isEditing = false;
// Événements envoyés au composant parent
@Output() titleSheetSaved = new EventEmitter<TitleSheet>();
@Output() formReset = new EventEmitter<void>();
// Formulaire réactif
titleSheetForm: FormGroup;
constructor(private formBuilder: FormBuilder) {
this.titleSheetForm = this.createForm();
}
ngOnInit(): void {
this.loadTitleSheetToForm();
}
ngOnChanges(): void {
// Se déclenche quand les @Input changent
this.loadTitleSheetToForm();
}
// Créer la structure du formulaire avec validation
private createForm(): FormGroup {
return this.formBuilder.group({
LastName: ['', [Validators.required, Validators.minLength(2)]],
FirstName: ['', [Validators.required, Validators.minLength(2)]],
Function1: ['', [Validators.required, Validators.minLength(2)]],
Function2: [''] // Optionnel
});
}
// Charger les données du titleSheet dans le formulaire
private loadTitleSheetToForm(): void {
if (this.titleSheet) {
this.titleSheetForm.patchValue({
LastName: this.titleSheet.LastName,
FirstName: this.titleSheet.FirstName,
Function1: this.titleSheet.Function1,
Function2: this.titleSheet.Function2
});
}
}
// Soumission du formulaire
onSubmit(): void {
if (this.titleSheetForm.valid && this.titleSheet) {
const formValue = this.titleSheetForm.value;
// Créer le titleSheet mis à jour
const updatedTitleSheet: TitleSheet = {
...this.titleSheet,
LastName: formValue.LastName,
FirstName: formValue.FirstName,
Function1: formValue.Function1,
Function2: formValue.Function2 || '',
LastModified: new Date()
};
// Envoyer au composant parent
this.titleSheetSaved.emit(updatedTitleSheet);
} else {
// Afficher les erreurs
this.markFormGroupTouched();
}
}
// Reset du formulaire
onReset(): void {
this.titleSheetForm.reset();
this.titleSheetForm.markAsUntouched();
this.formReset.emit();
}
// Marquer tous les champs comme touchés pour afficher les erreurs
private markFormGroupTouched(): void {
Object.keys(this.titleSheetForm.controls).forEach(key => {
this.titleSheetForm.get(key)?.markAsTouched();
});
}
// Récupérer le message d'erreur pour un champ
getErrorMessage(fieldName: string): string {
const control = this.titleSheetForm.get(fieldName);
if (control?.hasError('required')) {
return `${fieldName} is required`;
}
if (control?.hasError('minlength')) {
return `${fieldName} must be at least 2 characters`;
}
return '';
}
// Getter pour vérifier si le formulaire est valide
get isFormValid(): boolean {
return this.titleSheetForm.valid;
}
}

View File

@ -0,0 +1,111 @@
<!-- src/app/view/components/title-sheets-list/title-sheets-list.component.html -->
<div class="title-sheets-list-container">
<!-- Toolbar Section -->
<div class="toolbar-section">
<app-toolbar
placeholder="Search by name or function..."
[resultsCount]="filteredCount()"
[totalCount]="titleSheetsFromService().length"
(searchQueryChange)="onSearchQueryChange($event)"
(addTitleClick)="onAddTitleClick()">
</app-toolbar>
</div>
<!-- Main Content Area -->
<div class="main-content">
<!-- List Section (3/4 width) -->
<div class="list-section">
<!-- List Container -->
<div class="list-container">
<!-- List Header -->
<app-list-header
[currentSort]="currentSort()"
(sortChange)="onSortChange($event)">
</app-list-header>
<!-- List Content -->
<div class="list-content">
@if (filteredAndSortedTitleSheets().length > 0) {
<!-- Title Sheet Items -->
@for (titleSheet of filteredAndSortedTitleSheets(); track trackByTitleSheetId($index, titleSheet)) {
<app-list-item
[titleSheet]="titleSheet"
[isSelected]="getTitleSheetStatus(titleSheet).isSelected"
[isPlaying]="getTitleSheetStatus(titleSheet).isPlaying"
(select)="onTitleSheetSelect($event)"
(edit)="onTitleSheetEdit($event)"
(play)="onTitleSheetPlay($event)"
(stop)="onTitleSheetStop()"
(delete)="onTitleSheetDelete($event)">
</app-list-item>
}
} @else {
<!-- Empty State -->
<div class="empty-state">
<div class="empty-content">
@if (searchQuery()) {
<!-- No Search Results -->
<i class="fas fa-search empty-icon" aria-hidden="true"></i>
<h3 class="empty-title">No Results Found</h3>
<p class="empty-description">
No title sheets match your search for "<strong>{{ searchQuery() }}</strong>".
Try adjusting your search terms.
</p>
<button
class="btn btn-secondary btn-small"
(click)="onSearchQueryChange('')"
type="button">
<i class="fas fa-times" aria-hidden="true"></i>
Clear Search
</button>
} @else {
<!-- No Data -->
<i class="fas fa-video empty-icon" aria-hidden="true"></i>
<h3 class="empty-title">No Title Sheets</h3>
<p class="empty-description">
Get started by creating your first title sheet for Unreal Engine.
</p>
<button
class="btn btn-primary btn-small"
(click)="onAddTitleClick()"
type="button">
<i class="fas fa-plus" aria-hidden="true"></i>
Add Your First Title
</button>
}
</div>
</div>
}
</div>
</div>
</div>
<!-- Details Panel Section (1/4 width) -->
<div class="details-section">
<app-details-panel
[selectedTitleSheet]="selectedTitleSheet()">
</app-details-panel>
</div>
</div>
</div>
<!-- Add/Edit Dialog -->
<app-title-dialog
[isOpen]="isDialogOpen()"
[titleSheet]="dialogTitleSheet()"
[mode]="dialogMode()"
(save)="onDialogSave($event)"
(close)="onDialogClose()">
</app-title-dialog>

View File

@ -0,0 +1,328 @@
// src/app/view/components/title-sheets-list/title-sheets-list.component.scss
.title-sheets-list-container {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-primary);
border-radius: var(--radius-medium);
overflow: hidden;
}
// === TOOLBAR SECTION ===
.toolbar-section {
flex-shrink: 0;
padding: var(--spacing-lg) var(--spacing-lg) 0 var(--spacing-lg);
}
// === MAIN CONTENT ===
.main-content {
flex: 1;
display: grid;
grid-template-columns: 78fr 18fr;
gap: var(--spacing-md);
padding: var(--spacing-lg);
min-height: 0; // Important for proper flexbox behavior
}
// === LIST SECTION ===
.list-section {
display: flex;
flex-direction: column;
min-height: 0;
}
.list-container {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-medium);
overflow: hidden;
display: flex;
flex-direction: column;
height: 100%;
}
.list-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0; // Force proper height calculation
max-height: 100%; // Ensure it doesn't exceed parent
// Custom scrollbar - improved visibility
scrollbar-width: thin;
scrollbar-color: #cbd5e0 #f7fafc;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: #f7fafc;
border-radius: 4px;
margin: 2px;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e0;
border-radius: 4px;
border: 1px solid #f7fafc;
&:hover {
background: #a0aec0;
}
&:active {
background: #718096;
}
}
}
// === DETAILS SECTION ===
.details-section {
display: flex;
flex-direction: column;
min-height: 0;
}
// === EMPTY STATE ===
.empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
padding: var(--spacing-xxl);
}
.empty-content {
text-align: center;
max-width: 300px;
.empty-icon {
font-size: 64px;
color: var(--text-muted);
margin-bottom: var(--spacing-lg);
opacity: 0.5;
}
.empty-title {
margin: 0 0 var(--spacing-md) 0;
color: var(--text-secondary);
font-size: 20px;
font-weight: 600;
}
.empty-description {
margin: 0 0 var(--spacing-lg) 0;
color: var(--text-muted);
font-size: 14px;
line-height: 1.5;
strong {
color: var(--text-accent);
font-weight: 600;
}
}
}
// === BUTTONS ===
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-lg);
border: 1px solid transparent;
border-radius: var(--radius-medium);
font-family: inherit;
font-size: 13px;
font-weight: 600;
line-height: 1.4;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
white-space: nowrap;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
&:not(:disabled):hover {
transform: translateY(-1px);
}
&:not(:disabled):active {
transform: translateY(0);
}
i {
font-size: 12px;
flex-shrink: 0;
}
&.btn-small {
padding: var(--spacing-xs) var(--spacing-md);
font-size: 12px;
min-height: 32px;
i {
font-size: 11px;
}
}
}
.btn-primary {
background: linear-gradient(135deg, var(--color-primary) 0%, #005a9e 100%);
border-color: var(--color-primary);
color: white;
box-shadow: var(--shadow-light);
&:hover:not(:disabled) {
background: linear-gradient(135deg, #005a9e 0%, var(--color-primary) 100%);
box-shadow: var(--shadow-medium);
}
&:active:not(:disabled) {
background: var(--color-primary);
box-shadow: var(--shadow-light);
}
}
.btn-secondary {
background: transparent;
border-color: var(--border-secondary);
color: var(--text-secondary);
&:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-active);
color: var(--text-primary);
}
&:active:not(:disabled) {
background: var(--bg-active);
}
}
// === RESPONSIVE DESIGN ===
@media (max-width: 1200px) {
.main-content {
grid-template-columns: 2fr 1fr;
gap: var(--spacing-md);
padding: var(--spacing-md);
}
.toolbar-section {
padding: var(--spacing-md) var(--spacing-md) 0 var(--spacing-md);
}
}
@media (max-width: 900px) {
.main-content {
grid-template-columns: 1fr;
grid-template-rows: 1fr auto;
gap: var(--spacing-md);
}
.details-section {
order: 2;
max-height: 300px;
}
}
@media (max-width: 768px) {
.main-content {
padding: var(--spacing-sm);
gap: var(--spacing-sm);
}
.toolbar-section {
padding: var(--spacing-sm) var(--spacing-sm) 0 var(--spacing-sm);
}
.empty-content {
.empty-icon {
font-size: 48px;
}
.empty-title {
font-size: 18px;
}
.empty-description {
font-size: 13px;
}
}
}
@media (max-width: 480px) {
.details-section {
max-height: 250px;
}
.empty-state {
min-height: 200px;
padding: var(--spacing-lg);
}
.empty-content {
max-width: 250px;
.empty-icon {
font-size: 40px;
}
.empty-title {
font-size: 16px;
}
.empty-description {
font-size: 12px;
}
}
}
// === ACCESSIBILITY ===
@media (prefers-reduced-motion: reduce) {
.btn {
transition: none;
&:not(:disabled):hover,
&:not(:disabled):active {
transform: none;
}
}
}
// === HIGH CONTRAST MODE ===
@media (prefers-contrast: high) {
.list-container {
border-width: 2px;
}
.btn {
border-width: 2px;
&:focus-visible {
outline-width: 3px;
}
}
}
// === PRINT STYLES ===
@media print {
.empty-state {
display: none;
}
.btn {
display: none;
}
}

View File

@ -0,0 +1,327 @@
// src/app/view/components/title-sheets-list/title-sheets-list.component.ts
import { Component, input, output, signal, computed, effect, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TitleSheet } from '../../../models/title-sheet.model';
import { TitleSheetsService } from '../../../services/title-sheet.service';
// Composants modulaires
import { ToolbarComponent } from '../toolbar/toolbar.component';
import { ListHeaderComponent, SortConfig, SortField } from '../list-header/list-header.component';
import { ListItemComponent } from '../list-item/list-item.component';
import { DetailsPanelComponent } from '../details-panel/details-panel.component';
import { TitleDialogComponent } from '../title-dialog/title-dialog.component';
import { TitleSheetListStatus } from '../../../models/title-sheet.model';
@Component({
selector: 'app-title-sheets-list',
standalone: true,
imports: [
CommonModule,
ToolbarComponent,
ListHeaderComponent,
ListItemComponent,
DetailsPanelComponent,
TitleDialogComponent
],
templateUrl: './title-sheets-list.component.html',
styleUrl: './title-sheets-list.component.scss'
})
export class TitleSheetsListComponent implements OnDestroy {
// === INPUT SIGNALS ===
titleSheets = input<TitleSheet[]>([]); // Keep for compatibility but will use service directly
titleStatus = input<TitleSheetListStatus | null>(null);
// === INTERNAL SIGNALS ===
titleSheetsFromService = signal<TitleSheet[]>([]);
searchQuery = signal<string>('');
currentSort = signal<SortConfig>({ field: 'LastName', direction: 'asc' });
selectedTitleSheet = signal<TitleSheet | null>(null);
// Signal to force computed recalculation when needed
// This is required because the computed doesn't always recalculate automatically
// when titleSheetsFromService changes via manual subscription
private refreshTrigger = signal<number>(0);
// === OUTPUT SIGNALS ===
titleSheetAdd = output<TitleSheet>();
titleSheetEdit = output<TitleSheet>();
titleSheetDelete = output<string>();
titleSheetPlay = output<TitleSheet>();
titleSheetStop = output<void>();
// Dialog state
isDialogOpen = signal<boolean>(false);
dialogMode = signal<'add' | 'edit'>('add');
dialogTitleSheet = signal<TitleSheet | null>(null);
// Keyboard shortcuts
private keydownListener?: (event: KeyboardEvent) => void;
// Manual subscription for testing
private titleSheetsSubscription?: any;
// === COMPUTED SIGNALS ===
filteredAndSortedTitleSheets = computed(() => {
// Include refreshTrigger as dependency to force recalculation
// This ensures the computed updates when title sheets change
this.refreshTrigger();
let sheets = this.titleSheetsFromService();
const query = this.searchQuery().toLowerCase().trim();
const sort = this.currentSort();
// Filter by search query
if (query) {
sheets = sheets.filter(sheet =>
sheet.FirstName.toLowerCase().includes(query) ||
sheet.LastName.toLowerCase().includes(query) ||
sheet.Function1.toLowerCase().includes(query) ||
sheet.Function2.toLowerCase().includes(query)
);
}
// Sort
sheets = [...sheets].sort((a, b) => {
let aValue: string | Date;
let bValue: string | Date;
switch (sort.field) {
case 'LastModified':
aValue = a.LastModified;
bValue = b.LastModified;
break;
default:
aValue = a[sort.field] || '';
bValue = b[sort.field] || '';
}
if (aValue instanceof Date && bValue instanceof Date) {
return sort.direction === 'asc'
? aValue.getTime() - bValue.getTime()
: bValue.getTime() - aValue.getTime();
}
const aStr = String(aValue).toLowerCase();
const bStr = String(bValue).toLowerCase();
if (sort.direction === 'asc') {
return aStr.localeCompare(bStr);
} else {
return bStr.localeCompare(aStr);
}
});
return sheets;
});
filteredCount = computed(() => {
const query = this.searchQuery().trim();
return query ? this.filteredAndSortedTitleSheets().length : null;
});
currentlyPlayingSheet = computed(() => {
const status = this.titleStatus();
return status?.currentTitleSheet || null;
});
constructor(private titleSheetsService: TitleSheetsService) {
// Subscribe to service for title sheets updates
this.titleSheetsSubscription = this.titleSheetsService.titleSheets$.subscribe(
titleSheets => {
this.titleSheetsFromService.set(titleSheets);
// Trigger computed recalculation
this.refreshTrigger.set(this.refreshTrigger() + 1);
}
);
// Setup keyboard shortcuts
this.setupKeyboardShortcuts();
// Auto-select first sheet if none selected
effect(() => {
const sheets = this.filteredAndSortedTitleSheets();
const selected = this.selectedTitleSheet();
if (!selected && sheets.length > 0) {
this.selectedTitleSheet.set(sheets[0]);
} else if (selected && !sheets.find(s => s.Id === selected.Id)) {
// Selected sheet was filtered out, select first available
this.selectedTitleSheet.set(sheets[0] || null);
}
});
}
ngOnDestroy(): void {
this.removeKeyboardShortcuts();
// Unsubscribe from manual subscription
if (this.titleSheetsSubscription) {
this.titleSheetsSubscription.unsubscribe();
}
}
// === TOOLBAR EVENT HANDLERS ===
onSearchQueryChange(query: string): void {
this.searchQuery.set(query);
}
onAddTitleClick(): void {
this.dialogMode.set('add');
this.dialogTitleSheet.set(null);
this.isDialogOpen.set(true);
}
// === LIST HEADER EVENT HANDLERS ===
onSortChange(sort: SortConfig): void {
this.currentSort.set(sort);
}
// === LIST ITEM EVENT HANDLERS ===
onTitleSheetSelect(titleSheet: TitleSheet): void {
this.selectedTitleSheet.set(titleSheet);
}
onTitleSheetEdit(titleSheet: TitleSheet): void {
// This is called when inline editing is completed and changes need to be saved
// Save directly via service instead of opening dialog
this.titleSheetsService.saveTitleSheet(titleSheet);
console.log('✏️ Title sheet updated via inline editing:', titleSheet);
}
onTitleSheetEditDialog(titleSheet: TitleSheet): void {
// This method can be used if we want to open dialog for editing
// (for complex edits that need more than inline editing)
this.dialogMode.set('edit');
this.dialogTitleSheet.set(titleSheet);
this.isDialogOpen.set(true);
}
onTitleSheetPlay(titleSheet: TitleSheet): void {
this.titleSheetPlay.emit(titleSheet);
}
onTitleSheetStop(): void {
this.titleSheetStop.emit();
}
onTitleSheetDelete(id: string): void {
// If deleting selected sheet, clear selection
const selected = this.selectedTitleSheet();
if (selected && selected.Id === id) {
this.selectedTitleSheet.set(null);
}
this.titleSheetDelete.emit(id);
}
// === DIALOG EVENT HANDLERS ===
onDialogSave(titleSheet: TitleSheet): void {
if (this.dialogMode() === 'add') {
// Call service directly for new title sheets
this.titleSheetsService.addTitleSheet(titleSheet);
console.log('✅ New title sheet added:', titleSheet);
} else {
// Call service directly for edited title sheets too
this.titleSheetsService.saveTitleSheet(titleSheet);
console.log('✏️ Title sheet updated via dialog:', titleSheet);
}
// Note: Dialog will close automatically after save
}
onDialogClose(): void {
this.closeDialog();
}
private closeDialog(): void {
this.isDialogOpen.set(false);
this.dialogMode.set('add');
this.dialogTitleSheet.set(null);
}
// === KEYBOARD SHORTCUTS ===
private setupKeyboardShortcuts(): void {
this.keydownListener = (event: KeyboardEvent) => {
// Ctrl+I - Add new title
if (event.ctrlKey && event.key.toLowerCase() === 'i') {
event.preventDefault();
this.onAddTitleClick();
}
// Delete - Delete selected title
if (event.key === 'Delete' && this.selectedTitleSheet()) {
event.preventDefault();
const selected = this.selectedTitleSheet();
if (selected) {
this.onTitleSheetDelete(selected.Id);
}
}
// Ctrl+Enter - Play selected title
if (event.ctrlKey && event.key === 'Enter' && this.selectedTitleSheet()) {
event.preventDefault();
const selected = this.selectedTitleSheet();
if (selected) {
this.onTitleSheetPlay(selected);
}
}
// Space - Stop current rundown
if (event.key === ' ' && this.currentlyPlayingSheet()) {
event.preventDefault();
this.onTitleSheetStop();
}
// Arrow keys - Navigate selection
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
this.navigateSelection(event.key === 'ArrowUp' ? -1 : 1);
}
};
document.addEventListener('keydown', this.keydownListener);
}
private removeKeyboardShortcuts(): void {
if (this.keydownListener) {
document.removeEventListener('keydown', this.keydownListener);
}
}
private navigateSelection(direction: number): void {
const sheets = this.filteredAndSortedTitleSheets();
const selected = this.selectedTitleSheet();
if (sheets.length === 0) return;
let currentIndex = selected ? sheets.findIndex(s => s.Id === selected.Id) : -1;
let newIndex = currentIndex + direction;
// Wrap around
if (newIndex < 0) newIndex = sheets.length - 1;
if (newIndex >= sheets.length) newIndex = 0;
this.selectedTitleSheet.set(sheets[newIndex]);
}
// === UTILITY METHODS ===
getTitleSheetStatus(titleSheet: TitleSheet): { isSelected: boolean; isPlaying: boolean } {
const selected = this.selectedTitleSheet();
const playing = this.currentlyPlayingSheet();
return {
isSelected: selected?.Id === titleSheet.Id,
isPlaying: playing?.Id === titleSheet.Id
};
}
trackByTitleSheetId(index: number, titleSheet: TitleSheet): string {
return titleSheet.Id;
}
}

View File

@ -0,0 +1,38 @@
<!-- src/app/view/components/toast/toast.component.html -->
<div class="toast-container">
@for (toast of toasts; track toast.id) {
<div
class="toast"
[class]="getToastClass(toast.type)">
<div class="toast-content">
<div class="toast-icon">
<i [class]="getToastIcon(toast.type)" aria-hidden="true"></i>
</div>
<div class="toast-body">
<div class="toast-title" [innerHTML]="toast.title"></div>
@if (toast.message) {
<div class="toast-message" [innerHTML]="toast.message"></div>
}
</div>
@if (toast.showClose) {
<button
type="button"
class="toast-close"
(click)="onRemoveToast(toast.id)"
aria-label="Close notification">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
}
</div>
@if (toast.showProgress && toast.duration > 0) {
<div class="toast-progress">
<div class="toast-progress-bar" [style.animation-duration]="toast.duration + 'ms'"></div>
</div>
}
</div>
}
</div>

View File

@ -0,0 +1,303 @@
// src/app/view/components/toast/toast.component.scss
// === ANIMATION VARIABLES ===
// Animations désactivées pour affichage instantané
:host {
--toast-animation-duration: 0s; /* Pas d'animation d'entrée */
--toast-transition-duration: 0s; /* Pas de transitions */
--toast-button-transition: 0.1s; /* Garde une transition légère pour les boutons */
--toast-hover-offset: -3px; /* Effet hover réduit */
}
.toast-container {
position: fixed;
top: 80px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
max-width: 400px;
width: 100%;
pointer-events: none;
}
.toast {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-large);
box-shadow: var(--shadow-large);
overflow: hidden;
pointer-events: auto;
/* Pas d'animation - apparition instantanée */
opacity: 1;
transform: translateX(0);
transition: all var(--toast-transition-duration) ease;
&:hover {
transform: translateX(var(--toast-hover-offset));
box-shadow: var(--shadow-extra-large);
}
}
// Keyframes non utilisées - animations désactivées
// @keyframes slideInRight {
// from {
// transform: translateX(100%);
// opacity: 0;
// }
// to {
// transform: translateX(0);
// opacity: 1;
// }
// }
// @keyframes slideOutRight {
// from {
// transform: translateX(0);
// opacity: 1;
// }
// to {
// transform: translateX(100%);
// opacity: 0;
// }
// }
.toast-content {
display: flex;
align-items: flex-start;
gap: var(--spacing-md);
padding: var(--spacing-lg);
}
.toast-icon {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
i {
font-size: 18px;
}
}
.toast-body {
flex: 1;
min-width: 0;
}
.toast-title {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
margin-bottom: var(--spacing-xs);
color: var(--text-primary);
}
.toast-message {
font-size: 13px;
font-weight: 400;
line-height: 1.4;
color: var(--text-secondary);
// Allow HTML content but limit styles
:deep(strong) {
font-weight: 600;
color: var(--text-primary);
}
:deep(code) {
background: var(--bg-tertiary);
padding: 2px 4px;
border-radius: var(--radius-small);
font-family: monospace;
font-size: 12px;
}
}
.toast-close {
flex-shrink: 0;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 12px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-small);
transition: all var(--toast-button-transition) ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&:active {
background: var(--bg-active);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
.toast-progress {
height: 3px;
background: rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.toast-progress-bar {
height: 100%;
width: 100%;
transform-origin: left;
animation: progressShrink linear;
}
@keyframes progressShrink {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
// === TOAST TYPES ===
.toast-success {
border-left: 4px solid var(--color-success);
.toast-icon {
color: var(--color-success);
}
.toast-progress-bar {
background: var(--color-success);
}
}
.toast-error {
border-left: 4px solid var(--color-danger);
.toast-icon {
color: var(--color-danger);
}
.toast-progress-bar {
background: var(--color-danger);
}
}
.toast-warning {
border-left: 4px solid var(--color-warning);
.toast-icon {
color: var(--color-warning);
}
.toast-progress-bar {
background: var(--color-warning);
}
}
.toast-info {
border-left: 4px solid var(--color-primary);
.toast-icon {
color: var(--color-primary);
}
.toast-progress-bar {
background: var(--color-primary);
}
}
// === RESPONSIVE DESIGN ===
@media (max-width: 768px) {
.toast-container {
top: 60px;
left: 20px;
right: 20px;
max-width: none;
}
.toast-content {
padding: var(--spacing-md);
gap: var(--spacing-sm);
}
.toast-title {
font-size: 13px;
}
.toast-message {
font-size: 12px;
}
}
@media (max-width: 480px) {
.toast-container {
top: 50px;
left: 10px;
right: 10px;
}
.toast-content {
padding: var(--spacing-sm);
}
.toast-icon {
width: 20px;
height: 20px;
i {
font-size: 16px;
}
}
}
// === ACCESSIBILITY ===
@media (prefers-reduced-motion: reduce) {
.toast {
/* Animations déjà désactivées par défaut */
transition: none;
&:hover {
transform: none;
}
}
.toast-progress-bar {
animation: none;
}
}
// === HIGH CONTRAST MODE ===
@media (prefers-contrast: high) {
.toast {
border-width: 2px;
}
.toast-success {
border-left-width: 6px;
}
.toast-error {
border-left-width: 6px;
}
.toast-warning {
border-left-width: 6px;
}
.toast-info {
border-left-width: 6px;
}
}

View File

@ -0,0 +1,47 @@
// src/app/view/components/toast/toast.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ToastService, Toast } from '../../../services/toast.service';
@Component({
selector: 'app-toast',
standalone: true,
imports: [CommonModule],
templateUrl: './toast.component.html',
styleUrl: './toast.component.scss'
})
export class ToastComponent implements OnInit, OnDestroy {
constructor(private toastService: ToastService) {}
ngOnInit(): void {
// Le service utilise des signals, pas besoin de subscription
// Les toasts sont automatiquement mis à jour via le signal
}
ngOnDestroy(): void {
// Pas de subscription à nettoyer avec les signals
}
get toasts() {
return this.toastService.toasts$();
}
onRemoveToast(id: string): void {
this.toastService.remove(id);
}
getToastIcon(type: Toast['type']): string {
switch (type) {
case 'success': return 'fas fa-check-circle';
case 'error': return 'fas fa-exclamation-circle';
case 'warning': return 'fas fa-exclamation-triangle';
case 'info': return 'fas fa-info-circle';
default: return 'fas fa-info-circle';
}
}
getToastClass(type: Toast['type']): string {
return `toast-${type}`;
}
}

View File

@ -0,0 +1,55 @@
<!-- src/app/components/toolbar/toolbar.component.html -->
<div class="toolbar-container">
<!-- Left Section: Search -->
<div class="search-section">
<div class="search-wrapper">
<i class="fas fa-search search-icon" aria-hidden="true"></i>
<input
#searchInput
type="text"
class="search-input"
[placeholder]="placeholder()"
[value]="searchQuery()"
(input)="onSearchInput($event)"
(keydown.escape)="clearSearch()"
autocomplete="off"
spellcheck="false"
[attr.aria-label]="'Search title sheets'">
@if (hasSearchQuery) {
<button
class="clear-search-btn"
(click)="clearSearch()"
type="button"
[attr.aria-label]="'Clear search (Esc)'"
[title]="'Clear search (Esc)'">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
}
</div>
<!-- Results Counter -->
<div class="results-counter">
@if (hasSearchQuery && resultsCount() !== null) {
<span class="results-text filtered">{{ resultsText }}</span>
} @else {
<span class="results-text">{{ resultsText }}</span>
}
</div>
</div>
<!-- Right Section: Add Button -->
<div class="add-section">
<button
class="add-btn"
(click)="onAddClick()"
type="button"
[attr.aria-label]="'Add new title sheet'"
[title]="'Add Title Sheet (Ctrl+I)'">
<i class="fas fa-plus add-icon" aria-hidden="true"></i>
<span class="add-text">Add</span>
</button>
</div>
</div>

View File

@ -0,0 +1,276 @@
/* src/app/components/toolbar/toolbar.component.scss */
.toolbar-container {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-lg);
padding: var(--spacing-lg) var(--spacing-xl);
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-primary);
min-height: 64px;
@media (max-width: 768px) {
flex-direction: column;
gap: var(--spacing-md);
padding: var(--spacing-md) var(--spacing-lg);
min-height: auto;
}
}
// === SEARCH SECTION ===
.search-section {
flex: 1;
display: flex;
align-items: center;
gap: var(--spacing-md);
min-width: 0; // Important pour le flexbox
@media (max-width: 768px) {
width: 100%;
flex-direction: column;
align-items: stretch;
}
}
.search-wrapper {
position: relative;
flex: 1;
max-width: 400px;
min-width: 200px;
@media (max-width: 768px) {
max-width: none;
min-width: 0;
}
.search-icon {
position: absolute;
left: var(--spacing-md);
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
font-size: 14px;
pointer-events: none;
z-index: 1;
}
.search-input {
width: 100%;
height: 40px;
padding: 0 40px 0 40px;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-medium);
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
outline: none;
transition: all 0.2s ease;
&::placeholder {
color: var(--text-muted);
font-style: italic;
}
&:focus {
border-color: var(--border-active);
background: var(--bg-primary);
box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.15);
+ .search-icon {
color: var(--color-primary);
}
}
&:not(:placeholder-shown) {
background: var(--bg-primary);
border-color: var(--border-secondary);
}
}
.clear-search-btn {
position: absolute;
right: var(--spacing-sm);
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
z-index: 1;
&:hover {
background: var(--bg-hover);
color: var(--text-secondary);
transform: translateY(-50%) scale(1.1);
}
&:active {
transform: translateY(-50%) scale(0.95);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
i {
font-size: 11px;
}
}
}
// === RESULTS COUNTER ===
.results-counter {
flex-shrink: 0;
@media (max-width: 768px) {
align-self: flex-start;
}
.results-text {
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
&.filtered {
color: var(--text-accent);
font-weight: 600;
}
}
}
// === ADD SECTION ===
.add-section {
flex-shrink: 0;
@media (max-width: 768px) {
width: 100%;
}
.add-btn {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-lg);
background: var(--color-primary);
border: 1px solid var(--border-active);
border-radius: var(--radius-medium);
color: white;
font-size: 14px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.2s ease;
min-width: 100px;
height: 40px;
justify-content: center;
@media (max-width: 768px) {
width: 100%;
}
&:hover {
background: #005a9e;
transform: translateY(-1px);
box-shadow: var(--shadow-medium);
}
&:active {
transform: translateY(0);
box-shadow: var(--shadow-light);
}
&:focus-visible {
outline: 2px solid white;
outline-offset: 2px;
}
.add-icon {
font-size: 12px;
font-weight: 600;
}
.add-text {
font-family: inherit;
letter-spacing: 0.3px;
@media (max-width: 480px) {
display: none;
}
}
@media (max-width: 480px) {
min-width: 40px;
padding: var(--spacing-sm);
}
}
}
// === STATES & INTERACTIONS ===
.search-wrapper:focus-within {
.search-icon {
color: var(--color-primary);
}
}
// === ACCESSIBILITY ===
@media (prefers-reduced-motion: reduce) {
.search-input,
.clear-search-btn,
.add-btn {
transition: none;
}
.clear-search-btn:hover,
.add-btn:hover {
transform: none;
}
}
// === HIGH CONTRAST ===
@media (prefers-contrast: high) {
.toolbar-container {
border-bottom-width: 2px;
}
.search-input {
border-width: 2px;
&:focus {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
.add-btn {
border-width: 2px;
font-weight: 700;
}
.clear-search-btn {
border: 1px solid var(--border-secondary);
}
}
// === PRINT ===
@media print {
.add-section {
display: none;
}
.search-section {
.clear-search-btn {
display: none;
}
}
}

View File

@ -0,0 +1,73 @@
// src/app/components/toolbar/toolbar.component.ts
import { Component, input, output, signal, ViewChild, ElementRef } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-toolbar',
standalone: true,
imports: [FormsModule],
templateUrl: './toolbar.component.html',
styleUrl: './toolbar.component.scss'
})
export class ToolbarComponent {
// === INPUT SIGNALS (ANGULAR 19) ===
placeholder = input<string>('Search by name or position...');
resultsCount = input<number | null>(null);
totalCount = input<number>(0);
// === OUTPUT SIGNALS ===
searchQueryChange = output<string>();
addTitleClick = output<void>();
// === INTERNAL SIGNALS ===
searchQuery = signal<string>('');
// === TEMPLATE REFERENCES ===
@ViewChild('searchInput') searchInput!: ElementRef<HTMLInputElement>;
// === SEARCH METHODS ===
onSearchInput(event: Event): void {
const target = event.target as HTMLInputElement;
const query = target.value;
this.searchQuery.set(query);
this.searchQueryChange.emit(query);
}
clearSearch(): void {
this.searchQuery.set('');
this.searchQueryChange.emit('');
this.focusSearch();
}
focusSearch(): void {
setTimeout(() => {
this.searchInput?.nativeElement?.focus();
}, 0);
}
// === ADD TITLE METHOD ===
onAddClick(): void {
this.addTitleClick.emit();
}
// === COMPUTED GETTERS ===
get hasSearchQuery(): boolean {
return this.searchQuery().length > 0;
}
get resultsText(): string {
const results = this.resultsCount();
const total = this.totalCount();
if (results === null) {
return `${total} title${total !== 1 ? 's' : ''}`;
}
if (results === total) {
return `${total} title${total !== 1 ? 's' : ''}`;
}
return `${results} of ${total} title${total !== 1 ? 's' : ''}`;
}
}

View File

@ -0,0 +1,180 @@
<!-- src/app/view/pages/dtflux-title/dtflux-title.component.html -->
<div class="main-layout">
<!-- Menu Bar -->
<app-menu-bar
(configOpen)="onConfigOpen()"
(configSave)="onConfigSave()">
</app-menu-bar>
<!-- Error Banner -->
@if (error()) {
<div class="error-banner">
<div class="error-content">
<i class="fas fa-exclamation-triangle" aria-hidden="true"></i>
<span class="error-message">{{ error() }}</span>
<button
class="error-close"
(click)="error.set(null)"
type="button"
[attr.aria-label]="'Close error message'">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
</div>
}
<!-- Main Content Container -->
<div class="main-content">
<!-- Cued Title Section -->
<section class="cued-title-section">
<app-cued-title
[unrealStatus]="unrealStatus()">
</app-cued-title>
</section>
<!-- Main Content Tabs -->
<section class="main-content-tabs">
<!-- Tab Navigation -->
<div class="tab-navigation">
<button
class="tab-btn"
[class.active]="selectedMainTab() === 'titles'"
(click)="selectedMainTab.set('titles')">
<i class="fas fa-clipboard-list"></i> Title Sheets ({{ titleSheets().length }})
</button>
<button
class="tab-btn"
[class.active]="selectedMainTab() === 'discovery'"
(click)="selectedMainTab.set('discovery')">
<i class="fas fa-search"></i> Discovery Dashboard
</button>
</div>
<!-- Tab Content -->
<div class="tab-content-container">
<!-- Title Sheets Tab -->
@if (selectedMainTab() === 'titles') {
<app-big-card>
@if (isLoading()) {
<!-- Loading State -->
<div class="loading-container">
<div class="loading-content">
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
</div>
<h3 class="loading-title">Loading Title Sheets</h3>
<p class="loading-description">Initializing application and connecting to services...</p>
</div>
</div>
} @else {
<!-- Title Sheets List -->
<app-title-sheets-list
[titleSheets]="titleSheets()"
(titleSheetAdd)="onTitleSheetAdd($event)"
(titleSheetDelete)="onTitleSheetDelete($event)"
(titleSheetPlay)="onTitleSheetPlay($event)"
(titleSheetStop)="onTitleSheetStop()">
</app-title-sheets-list>
}
</app-big-card>
}
</div>
</section>
</div>
<!-- Main Footer avec shortcuts -->
<footer class="main-footer">
<div class="shortcuts-help">
<span class="shortcuts-text">
<kbd>Ctrl+I</kbd> Add • <kbd>↑↓</kbd> Navigate • <kbd>Ctrl+Enter</kbd> Play • <kbd>Space</kbd> Stop • <kbd>Del</kbd> Delete
</span>
</div>
</footer>
<!-- Development Tools (only when enabled in settings) -->
@if (devToolsEnabled()) {
<div class="dev-tools">
<div class="dev-tools-content">
<h4 class="dev-tools-title">
<i class="fas fa-wrench" aria-hidden="true"></i>
Development Tools
</h4>
<div class="dev-buttons">
<!-- <button
class="dev-btn"
(click)="testConnection()"
type="button"
[title]="'Test Unreal Engine connection'">
<i class="fas fa-plug" aria-hidden="true"></i>
<span>Test Connection</span>
</button>
<button
class="dev-btn"
(click)="testAddSampleData()"
type="button"
[title]="'Add sample title sheets'">
<i class="fas fa-database" aria-hidden="true"></i>
<span>Add Sample Data</span>
</button>
<button
class="dev-btn"
(click)="testAddLargeDataset()"
type="button"
[title]="'Add large dataset for performance testing'">
<i class="fas fa-chart-line" aria-hidden="true"></i>
<span>Large Dataset</span>
</button>
<button
class="dev-btn"
(click)="testShowStats()"
type="button"
[title]="'Show statistics in console'">
<i class="fas fa-chart-pie" aria-hidden="true"></i>
<span>Show Stats</span>
</button> -->
<button
class="dev-btn danger"
(click)="clearAllData()"
type="button"
[title]="'Clear all title sheets'">
<i class="fas fa-trash" aria-hidden="true"></i>
<span>Clear All</span>
</button>
</div>
<div class="dev-status">
<span class="status-item">
<i [class]="hasConnection() ? 'fas fa-check-circle' : 'fas fa-times-circle'"
[style.color]="hasConnection() ? 'var(--color-success)' : 'var(--color-danger)'"
aria-hidden="true"></i>
Unreal Engine: {{ hasConnection() ? 'Connected' : 'Disconnected' }}
</span>
<span class="status-item">
<i class="fas fa-list" aria-hidden="true"></i>
Title Sheets: {{ titleSheets().length }}
</span>
@if (currentTitle()) {
<span class="status-item">
<i class="fas fa-play" aria-hidden="true"></i>
Playing: {{ currentTitle()?.FirstName }} {{ currentTitle()?.LastName }}
</span>
}
</div>
</div>
</div>
}
</div>

View File

@ -0,0 +1,491 @@
// src/app/view/pages/dtflux-title/dtflux-title.component.scss
.main-layout {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg-primary);
overflow: hidden;
}
// === ERROR BANNER ===
.error-banner {
background: linear-gradient(90deg, var(--color-danger), #d32f2f);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
padding: var(--spacing-sm) 0;
animation: slideDown 0.3s ease-out;
z-index: 1000;
}
@keyframes slideDown {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.error-content {
max-width: 950px;
margin: 0 auto;
padding: 0 var(--spacing-lg);
display: flex;
align-items: center;
gap: var(--spacing-md);
color: white;
}
.error-message {
flex: 1;
font-size: 13px;
font-weight: 500;
}
.error-close {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: var(--radius-small);
background: transparent;
color: white;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
&:active {
transform: scale(0.95);
}
i {
font-size: 10px;
}
}
// === MAIN CONTENT ===
.main-content {
flex: 1;
max-width: none; // Enlever la limite de 950px
margin: 0 auto;
padding: var(--spacing-lg);
width: 100%;
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
min-height: 0; // Important for proper flexbox behavior
overflow-x: auto; // Permettre le scroll horizontal si nécessaire
overflow-y: hidden; // Empêcher le scroll vertical ici (géré par les enfants)
}
// === CUED TITLE SECTION ===
.cued-title-section {
flex-shrink: 0;
}
// === MAIN CONTENT TABS ===
.main-content-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tab-navigation {
display: flex;
border-bottom: 2px solid var(--border-primary);
margin-bottom: var(--spacing-md);
flex-shrink: 0;
}
.tab-btn {
background: none;
border: none;
padding: var(--spacing-md) var(--spacing-lg);
cursor: pointer;
font-weight: 500;
color: var(--text-muted);
border-bottom: 3px solid transparent;
transition: all 0.2s ease;
font-size: 14px;
white-space: nowrap;
&:hover {
background: var(--bg-hover);
color: var(--text-secondary);
}
&.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
background: var(--bg-secondary);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
.tab-content-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
// === MAIN CARD SECTION (legacy - kept for compatibility) ===
.main-card-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden; // Important pour que la BigCard gère son propre overflow
}
// === LOADING STATE ===
.loading-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
padding: var(--spacing-xxl);
}
.loading-content {
text-align: center;
max-width: 300px;
}
.loading-spinner {
margin-bottom: var(--spacing-lg);
i {
font-size: 48px;
color: var(--text-accent);
opacity: 0.8;
}
}
.loading-title {
margin: 0 0 var(--spacing-md) 0;
color: var(--text-primary);
font-size: 20px;
font-weight: 600;
}
.loading-description {
margin: 0;
color: var(--text-muted);
font-size: 14px;
line-height: 1.5;
}
// === MAIN FOOTER ===
.main-footer {
background: var(--bg-secondary);
border-top: 1px solid var(--border-primary);
padding: var(--spacing-sm) 0;
margin-top: auto;
flex-shrink: 0;
}
.shortcuts-help {
max-width: 950px;
margin: 0 auto;
padding: 0 var(--spacing-lg);
text-align: center;
}
.shortcuts-text {
color: var(--text-muted);
font-size: 11px;
font-family: 'Segoe UI', sans-serif;
kbd {
display: inline-block;
padding: 2px 6px;
font-size: 10px;
font-family: 'Consolas', 'Monaco', monospace;
background: var(--bg-tertiary);
border: 1px solid var(--border-secondary);
border-radius: var(--radius-small);
color: var(--text-secondary);
margin: 0 1px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
}
// === DEVELOPMENT TOOLS ===
.dev-tools {
background: var(--bg-tertiary);
border-top: 1px solid var(--border-primary);
padding: var(--spacing-md) 0;
margin-top: auto;
}
.dev-tools-content {
max-width: 950px;
margin: 0 auto;
padding: 0 var(--spacing-lg);
}
.dev-tools-title {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin: 0 0 var(--spacing-md) 0;
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
i {
color: var(--text-accent);
font-size: 11px;
}
}
.dev-buttons {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
flex-wrap: wrap;
}
.dev-btn {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
border: 1px solid var(--border-secondary);
border-radius: var(--radius-small);
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--bg-hover);
border-color: var(--border-active);
color: var(--text-primary);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
&.danger {
border-color: var(--color-danger);
color: var(--color-danger);
&:hover {
background: var(--color-danger);
color: white;
}
}
i {
font-size: 10px;
}
}
.dev-status {
display: flex;
gap: var(--spacing-lg);
flex-wrap: wrap;
border-top: 1px solid var(--border-primary);
padding-top: var(--spacing-sm);
}
.status-item {
display: flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--text-muted);
font-size: 10px;
font-family: 'Consolas', 'Monaco', monospace;
i {
font-size: 9px;
width: 12px;
text-align: center;
}
}
// === RESPONSIVE DESIGN ===
@media (max-width: 1200px) {
.main-content {
max-width: 100%;
padding: var(--spacing-md);
}
.error-content,
.dev-tools-content,
.shortcuts-help {
padding: 0 var(--spacing-md);
}
}
@media (max-width: 768px) {
.main-content {
padding: var(--spacing-sm);
gap: var(--spacing-md);
}
.error-content,
.dev-tools-content,
.shortcuts-help {
padding: 0 var(--spacing-sm);
}
.error-content {
gap: var(--spacing-sm);
}
.error-message {
font-size: 12px;
}
.loading-content {
max-width: 250px;
}
.loading-spinner i {
font-size: 40px;
}
.loading-title {
font-size: 18px;
}
.loading-description {
font-size: 13px;
}
.dev-status {
gap: var(--spacing-md);
}
}
@media (max-width: 480px) {
.main-content {
padding: var(--spacing-xs);
gap: var(--spacing-sm);
}
.error-content,
.dev-tools-content,
.shortcuts-help {
padding: 0 var(--spacing-xs);
}
.shortcuts-text {
font-size: 10px;
kbd {
padding: 1px 4px;
font-size: 9px;
}
}
.dev-buttons {
gap: var(--spacing-xs);
}
.dev-btn {
padding: var(--spacing-xs);
font-size: 10px;
span {
display: none; // Hide text on very small screens
}
}
.dev-status {
flex-direction: column;
gap: var(--spacing-xs);
}
}
// === ACCESSIBILITY ===
@media (prefers-reduced-motion: reduce) {
.error-banner {
animation: none;
}
.dev-btn {
transition: none;
&:hover,
&:active {
transform: none;
}
}
.loading-spinner i {
animation-duration: 3s; // Slower spin for reduced motion
}
}
// === HIGH CONTRAST MODE ===
@media (prefers-contrast: high) {
.error-banner {
border-bottom-width: 2px;
}
.dev-btn {
border-width: 2px;
&:focus-visible {
outline: 3px solid var(--color-primary);
outline-offset: 2px;
}
}
.error-close {
border-width: 2px;
}
}
// === PRINT STYLES ===
@media print {
.error-banner,
.main-footer,
.dev-tools {
display: none;
}
.main-content {
padding: 0;
max-width: none;
}
.loading-container {
display: none;
}
}
// === FOCUS MANAGEMENT ===
.error-close,
.dev-btn {
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}

View File

@ -0,0 +1,299 @@
import { Component, signal, computed, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
// Services
import { TitleSheetsService } from '../../../services/title-sheet.service';
import { ConfigService } from '../../../services/config.service';
// Dev Tools
import { DevTools } from '../../../dev-tools';
// Composants
import { MenuBarComponent } from '../../components/menu-bar/menu-bar.component';
import { CuedTitleComponent } from '../../components/cued-title/cued-title.component';
import { TitleSheetsListComponent } from '../../components/title-sheets-list/title-sheets-list.component';
import { BigCardComponent } from '../../components/big-card/big-card.component';
// Models
import { TitleSheet, TitleSheetListStatus, createTitleSheetListStatus } from '../../../models/title-sheet.model';
import { AppConfig } from '../../../models/app-config.model';
@Component({
selector: 'app-dt-flux-title',
standalone: true,
imports: [
MenuBarComponent,
CuedTitleComponent,
TitleSheetsListComponent,
BigCardComponent,
],
templateUrl: './dtflux-title.component.html',
styleUrl: './dtflux-title.component.scss'
})
export class DTFluxTitleComponent implements OnDestroy {
// === SIGNALS ===
titleSheets = signal<TitleSheet[]>([]);
currentTitleSheet = signal<TitleSheet|null>(null);
status = signal<TitleSheetListStatus>(createTitleSheetListStatus());
appConfig = signal<AppConfig | null>(null);
isLoading = signal<boolean>(true);
error = signal<string | null>(null);
selectedMainTab = signal<'titles' | 'discovery'>('titles');
// === COMPUTED SIGNALS ===
hasConnection = computed(() => {
const status = this.status();
return true;
});
currentTitle = computed(() => {
return this.currentTitleSheet();
});
devToolsEnabled = computed(() => {
const config = this.appConfig();
return config?.Development?.EnableDevTools || false;
});
private subscriptions: Subscription[] = [];
private currentPlayingTitle: TitleSheet | null = null;
constructor(
private titleSheetsService: TitleSheetsService,
private configService: ConfigService
) {
console.log('✅ DTFluxTitle component initialized with UnrealEngineService');
this.initializeServices();
}
ngOnDestroy(): void {
this.subscriptions.forEach(sub => sub.unsubscribe());
}
// === INITIALIZATION ===
private initializeServices(): void {
// Subscribe to title sheets
const titleSheetsSubscription = this.titleSheetsService.titleSheets$.subscribe(
titleSheets => {
this.titleSheets.set(titleSheets);
}
);
this.subscriptions.push(titleSheetsSubscription);
// Subscribe to configuration
const configSubscription = this.configService.config$.subscribe(
config => {
this.appConfig.set(config);
// Update services configuration
console.log('⚙️ Configuration updated for all services');
}
);
this.subscriptions.push(configSubscription);
// Mark as loaded
setTimeout(() => {
this.isLoading.set(false);
}, 500);
}
private updateStatus(updates: Partial<TitleSheetListStatus>): void {
const currentStatus = this.status();
this.status.set({ ...currentStatus, ...updates });
}
// === MENU BAR EVENT HANDLERS ===
async onConfigOpen(): Promise<void> {
try {
console.log('📂 Opening configuration file...');
await this.configService.loadFromFile();
this.showSuccessMessage('Configuration loaded successfully');
} catch (error) {
console.error('❌ Failed to open configuration:', error);
this.error.set('Failed to load configuration file');
setTimeout(() => this.error.set(null), 5000);
}
}
async onConfigSave(): Promise<void> {
try {
console.log('💾 Saving configuration file...');
await this.configService.saveToFile();
this.showSuccessMessage('Configuration saved successfully');
} catch (error) {
console.error('❌ Failed to save configuration:', error);
this.error.set('Failed to save configuration file');
setTimeout(() => this.error.set(null), 5000);
}
}
// === TITLE SHEETS EVENT HANDLERS ===
onTitleSheetAdd(titleSheet: TitleSheet): void {
try {
this.titleSheetsService.addTitleSheet(titleSheet);
this.showSuccessMessage(`Added "${titleSheet.FirstName} ${titleSheet.LastName}"`);
} catch (error) {
console.error('❌ Failed to add title sheet:', error);
this.error.set('Failed to add title sheet');
setTimeout(() => this.error.set(null), 5000);
}
}
onTitleSheetDelete(id: string): void {
try {
const titleSheet = this.titleSheetsService.getTitleSheetById(id);
// Stop current playback if we're deleting the playing title
if (this.currentPlayingTitle?.Id === id) {
this.onTitleSheetStop();
}
this.titleSheetsService.deleteTitleSheet(id);
console.log('🗑️ Title sheet deleted:', id);
if (titleSheet) {
this.showSuccessMessage(`Deleted "${titleSheet.FirstName} ${titleSheet.LastName}"`);
}
} catch (error) {
console.error('❌ Failed to delete title sheet:', error);
this.error.set('Failed to delete title sheet');
setTimeout(() => this.error.set(null), 5000);
}
}
async onTitleSheetPlay(titleSheet: TitleSheet): Promise<void> {
try {
console.log('▶️ Starting title playback for:', titleSheet);
// Check connection status
const status = this.status();
if (!status.motionDesignConnected || !status.remoteControlConnected) {
this.updateStatus({
errorMessage: 'Not connected to Unreal Engine. Check connection status.'
});
this.error.set('Not connected to Unreal Engine. Check connection status.');
setTimeout(() => this.error.set(null), 5000);
return;
}
// For now, we'll simulate the playback since we haven't implemented
// the actual commands in the simplified services
// TODO: Implement actual Remote Control SetProperties and Motion Design StartRundown
this.currentPlayingTitle = titleSheet;
this.updateStatus({
currentTitleSheet: titleSheet,
isPlaying: true,
lastUpdate: new Date(),
errorMessage: undefined
});
this.showSuccessMessage(`Playing "${titleSheet.FirstName} ${titleSheet.LastName}"`);
console.log('🎬 Title playback started (simulated)');
} catch (error) {
console.error('❌ Failed to start title playback:', error);
this.updateStatus({
errorMessage: 'Failed to communicate with Unreal Engine'
});
this.error.set('Failed to communicate with Unreal Engine');
setTimeout(() => this.error.set(null), 5000);
}
}
async onTitleSheetStop(): Promise<void> {
try {
console.log('⏹️ Stopping title playback');
// TODO: Implement actual Motion Design StopRundown
this.currentPlayingTitle = null;
this.updateStatus({
currentTitleSheet: null,
isPlaying: false,
lastUpdate: new Date(),
errorMessage: undefined
});
this.showSuccessMessage('Title playback stopped');
console.log('🎬 Title playback stopped (simulated)');
} catch (error) {
console.error('❌ Failed to stop title playback:', error);
this.updateStatus({
errorMessage: 'Failed to communicate with Unreal Engine'
});
this.error.set('Failed to communicate with Unreal Engine');
setTimeout(() => this.error.set(null), 5000);
}
}
// === UTILITY METHODS ===
private showSuccessMessage(message: string): void {
console.log('✅', message);
// TODO: Implement toast notification system
}
clearAllData(): void {
if (confirm('Are you sure you want to clear all title sheets? This action cannot be undone.')) {
// Stop current playback if any
if (this.currentPlayingTitle) {
this.onTitleSheetStop();
}
const titleSheets = this.titleSheetsService.getAllTitleSheets();
titleSheets.forEach(sheet => {
this.titleSheetsService.deleteTitleSheet(sheet.Id);
});
console.log('🗑️ All title sheets cleared');
this.showSuccessMessage('All data cleared');
}
}
isDevelopment(): boolean {
return !!(window as any)['ng'] || location.hostname === 'localhost' || location.hostname === '127.0.0.1';
}
// === STATUS GETTERS ===
get connectionStatusText(): string {
const status = this.status();
if (status.motionDesignConnected && status.remoteControlConnected) {
return 'All systems connected';
}
if (status.motionDesignConnected || status.remoteControlConnected) {
return 'Partial connection';
}
return 'Not connected';
}
get connectionStatusIcon(): string {
const status = this.status();
if (status.motionDesignConnected && status.remoteControlConnected) {
return 'fas fa-check-circle text-success';
}
if (status.motionDesignConnected || status.remoteControlConnected) {
return 'fas fa-exclamation-circle text-warning';
}
return 'fas fa-times-circle text-error';
}
// Getter for template compatibility
unrealStatus(): TitleSheetListStatus {
return this.status();
}
}

View File

@ -0,0 +1,563 @@
<!-- src/app/view/pages/setup/setup.component.html -->
<div class="setup-layout">
<!-- Menu Bar -->
<app-menu-bar
(configOpen)="onConfigOpen()"
(configSave)="onConfigSave()">
</app-menu-bar>
<!-- Setup Content -->
<div class="setup-content">
<!-- Page Header -->
<header class="setup-header">
<div class="header-content">
<div class="header-info">
<h1 class="page-title">
<i class="fas fa-cog" aria-hidden="true"></i>
Application Setup
</h1>
<p class="page-description">
Configure Unreal Engine WebSocket connections and application settings.
</p>
</div>
<div class="header-actions">
<a
routerLink="/dtflux-title"
class="btn btn-secondary">
<i class="fas fa-arrow-left" aria-hidden="true"></i>
Back to Main
</a>
</div>
</div>
</header>
<!-- Save Message -->
@if (saveMessage()) {
<div class="message-banner" [class]="saveMessage()?.includes('Failed') ? 'error' : 'success'">
<div class="message-content">
<i [class]="saveMessage()?.includes('Failed') ? 'fas fa-exclamation-triangle' : 'fas fa-check-circle'" aria-hidden="true"></i>
<span>{{ saveMessage() }}</span>
</div>
</div>
}
<!-- Setup Form -->
@if (isLoading()) {
<!-- Loading State -->
<div class="loading-container">
<div class="loading-content">
<i class="fas fa-spinner fa-spin loading-icon" aria-hidden="true"></i>
<span class="loading-text">Loading configuration...</span>
</div>
</div>
} @else {
<form [formGroup]="setupForm" (ngSubmit)="onSave()" class="setup-form">
<!-- =====================================
UNREAL ENGINE NETWORK SECTION
===================================== -->
<section class="form-section">
<h2 class="section-title">
<i class="fas fa-network-wired" aria-hidden="true"></i>
Unreal Engine Network
</h2>
<p class="section-description">
Global network settings for connecting to Unreal Engine via WebSocket.
</p>
<div class="form-grid">
<!-- Address Field -->
<div class="form-field">
<label for="unrealAddress" class="field-label">
IP Address <span class="required">*</span>
@if (hasTestResult('motionDesign')) {
<span class="test-result">
<i [class]="getTestStatusIcon('motionDesign')" aria-hidden="true"></i>
<span class="test-text">{{ getTestStatusText('motionDesign') }}</span>
</span>
}
</label>
<input
id="unrealAddress"
type="text"
class="field-input"
formControlName="unrealAddress"
placeholder="127.0.0.1"
[class.error]="hasFieldError('unrealAddress')">
@if (hasFieldError('unrealAddress')) {
<span class="error-message">
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
{{ getErrorMessage('unrealAddress') }}
</span>
}
<p class="field-help">IP address of the Unreal Engine instance (both services).</p>
</div>
<!-- Timeout Field -->
<div class="form-field">
<label for="unrealTimeout" class="field-label">
Connection Timeout (ms) <span class="required">*</span>
</label>
<input
id="unrealTimeout"
type="number"
class="field-input"
formControlName="unrealTimeout"
placeholder="5000"
min="1000"
max="30000"
step="1000"
[class.error]="hasFieldError('unrealTimeout')">
@if (hasFieldError('unrealTimeout')) {
<span class="error-message">
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
{{ getErrorMessage('unrealTimeout') }}
</span>
}
<p class="field-help">Timeout for WebSocket connection attempts.</p>
</div>
</div>
<!-- Connection Test -->
<div class="connection-test">
<div class="test-buttons">
@if (connectionStatus() !== 'testing') {
<button
type="button"
class="btn btn-secondary"
(click)="onTestConnection()"
[disabled]="!isFormValid">
<i [class]="connectionStatusIcon" aria-hidden="true"></i>
Test WebSocket Connections
</button>
} @else {
<button
type="button"
class="btn btn-danger"
disabled>
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
Testing...
</button>
}
</div>
<div class="connection-status" [class]="connectionStatusClass">
<i [class]="connectionStatusIcon" aria-hidden="true"></i>
<span>{{ connectionStatusText }}</span>
</div>
</div>
</section>
<!-- =====================================
REMOTE CONTROL SECTION
===================================== -->
<section class="form-section">
<h2 class="section-title">
<i class="fas fa-satellite-dish" aria-hidden="true"></i>
Remote Control Configuration
</h2>
<p class="section-description">
Configure Unreal Engine Remote Control WebSocket for property control.
</p>
<!-- Network Subsection -->
<div class="subsection">
<h3 class="subsection-title">
<i class="fas fa-wifi" aria-hidden="true"></i>
Network
</h3>
<div class="form-grid">
<!-- WebSocket Port -->
<div class="form-field">
<label for="remoteControlWebSocketPort" class="field-label">
WebSocket Port <span class="required">*</span>
@if (hasTestResult('remoteControl')) {
<span class="test-result">
<i [class]="getTestStatusIcon('remoteControl')" aria-hidden="true"></i>
<span class="test-text">{{ getTestStatusText('remoteControl') }}</span>
</span>
}
</label>
<input
id="remoteControlWebSocketPort"
type="number"
class="field-input"
formControlName="remoteControlWebSocketPort"
placeholder="30020"
min="1"
max="65535"
[class.error]="hasFieldError('remoteControlWebSocketPort')">
@if (hasFieldError('remoteControlWebSocketPort')) {
<span class="error-message">
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
{{ getErrorMessage('remoteControlWebSocketPort') }}
</span>
}
<p class="field-help">WebSocket port for Remote Control property updates.</p>
</div>
</div>
</div>
</section>
<!-- =====================================
MOTION DESIGN SECTION
===================================== -->
<section class="form-section">
<h2 class="section-title">
<i class="fas fa-video" aria-hidden="true"></i>
Motion Design Configuration
</h2>
<p class="section-description">
Configure Motion Design WebSocket for rundown control and data settings.
</p>
<!-- Network Subsection -->
<div class="subsection">
<h3 class="subsection-title">
<i class="fas fa-wifi" aria-hidden="true"></i>
Network
</h3>
<div class="form-grid">
<!-- Motion Design WebSocket Port -->
<div class="form-field">
<label for="motionDesignWebSocketPort" class="field-label">
WebSocket Port <span class="required">*</span>
@if (hasTestResult('motionDesign')) {
<span class="test-result">
<i [class]="getTestStatusIcon('motionDesign')" aria-hidden="true"></i>
<span class="test-text">{{ getTestStatusText('motionDesign') }}</span>
</span>
}
</label>
<input
id="motionDesignWebSocketPort"
type="number"
class="field-input"
formControlName="motionDesignWebSocketPort"
placeholder="30021"
min="1"
max="65535"
[class.error]="hasFieldError('motionDesignWebSocketPort')">
@if (hasFieldError('motionDesignWebSocketPort')) {
<span class="error-message">
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
{{ getErrorMessage('motionDesignWebSocketPort') }}
</span>
}
<p class="field-help">WebSocket port for Motion Design rundowns and animations.</p>
</div>
</div>
</div>
<!-- Data Subsection -->
<div class="subsection">
<h3 class="subsection-title">
<i class="fas fa-database" aria-hidden="true"></i>
Data
</h3>
<div class="form-grid">
<!-- Preset Name -->
<div class="form-field">
<label for="motionDesignPresetName" class="field-label">
Preset Name <span class="required">*</span>
</label>
<input
id="motionDesignPresetName"
type="text"
class="field-input"
formControlName="motionDesignPresetName"
placeholder="Default"
[class.error]="hasFieldError('motionDesignPresetName')">
@if (hasFieldError('motionDesignPresetName')) {
<span class="error-message">
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
{{ getErrorMessage('motionDesignPresetName') }}
</span>
}
<p class="field-help">Motion Design preset configuration name.</p>
</div>
</div>
</div>
</section>
<!-- =====================================
STORAGE SECTION
===================================== -->
<section class="form-section">
<h2 class="section-title">
<i class="fas fa-save" aria-hidden="true"></i>
Storage Configuration
</h2>
<p class="section-description">
Configure data storage and auto-save settings.
</p>
<!-- Data Subsection -->
<div class="subsection">
<h3 class="subsection-title">
<i class="fas fa-database" aria-hidden="true"></i>
Data
</h3>
<div class="form-grid">
<!-- Save Path Field -->
<div class="form-field full-width">
<label for="savePath" class="field-label">
Save Path <span class="required">*</span>
</label>
<input
id="savePath"
type="text"
class="field-input"
formControlName="savePath"
placeholder="./configs"
[class.error]="hasFieldError('savePath')">
@if (hasFieldError('savePath')) {
<span class="error-message">
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
{{ getErrorMessage('savePath') }}
</span>
}
<p class="field-help">Directory path for saving configuration files.</p>
</div>
<!-- Auto Save Toggle -->
<div class="form-field">
<label class="checkbox-label">
<input
type="checkbox"
class="checkbox-input"
formControlName="autoSave">
<span class="checkbox-custom"></span>
<span class="checkbox-text">Enable Auto Save</span>
</label>
<p class="field-help">Automatically save changes to prevent data loss.</p>
</div>
<!-- Auto Save Interval -->
<div class="form-field">
<label for="autoSaveInterval" class="field-label">
Auto Save Interval (seconds) <span class="required">*</span>
</label>
<input
id="autoSaveInterval"
type="number"
class="field-input"
formControlName="autoSaveInterval"
placeholder="5"
min="1"
max="60"
[disabled]="!setupForm.get('autoSave')?.value"
[class.error]="hasFieldError('autoSaveInterval')">
@if (hasFieldError('autoSaveInterval')) {
<span class="error-message">
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
{{ getErrorMessage('autoSaveInterval') }}
</span>
}
<p class="field-help">Frequency of automatic saves (when enabled).</p>
</div>
</div>
</div>
</section>
<!-- =====================================
UI SECTION
===================================== -->
<section class="form-section">
<h2 class="section-title">
<i class="fas fa-desktop" aria-hidden="true"></i>
User Interface Configuration
</h2>
<p class="section-description">
Configure user interface behavior and defaults.
</p>
<!-- Data Subsection -->
<div class="subsection">
<h3 class="subsection-title">
<i class="fas fa-database" aria-hidden="true"></i>
Data
</h3>
<div class="form-grid">
<!-- Default Sort -->
<div class="form-field">
<label for="uiDefaultSort" class="field-label">
Default Sort Field <span class="required">*</span>
</label>
<select
id="uiDefaultSort"
class="field-input"
formControlName="uiDefaultSort"
[class.error]="hasFieldError('uiDefaultSort')">
<option value="LastName">Last Name</option>
<option value="FirstName">First Name</option>
<option value="LastModified">Last Modified</option>
</select>
@if (hasFieldError('uiDefaultSort')) {
<span class="error-message">
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
{{ getErrorMessage('uiDefaultSort') }}
</span>
}
<p class="field-help">Default sorting field for title sheets list.</p>
</div>
<!-- Search Placeholder -->
<div class="form-field full-width">
<label for="uiSearchPlaceholder" class="field-label">
Search Placeholder Text <span class="required">*</span>
</label>
<input
id="uiSearchPlaceholder"
type="text"
class="field-input"
formControlName="uiSearchPlaceholder"
placeholder="Search by name or function or bib..."
[class.error]="hasFieldError('uiSearchPlaceholder')">
@if (hasFieldError('uiSearchPlaceholder')) {
<span class="error-message">
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
{{ getErrorMessage('uiSearchPlaceholder') }}
</span>
}
<p class="field-help">Placeholder text shown in search fields.</p>
</div>
</div>
</div>
</section>
<!-- =====================================
DEVELOPMENT SECTION
===================================== -->
<section class="form-section">
<h2 class="section-title">
<i class="fas fa-code" aria-hidden="true"></i>
Development Configuration
</h2>
<p class="section-description">
Configure development tools and debugging options.
</p>
<!-- Data Subsection -->
<div class="subsection">
<h3 class="subsection-title">
<i class="fas fa-database" aria-hidden="true"></i>
Data
</h3>
<div class="form-grid">
<!-- Enable Dev Tools Toggle -->
<div class="form-field">
<label class="checkbox-label">
<input
type="checkbox"
class="checkbox-input"
formControlName="enableDevTools">
<span class="checkbox-custom"></span>
<span class="checkbox-text">Enable Development Tools</span>
</label>
<p class="field-help">Show development tools in the main interface for testing.</p>
</div>
<!-- Mock Unreal Toggle -->
<div class="form-field">
<label class="checkbox-label">
<input
type="checkbox"
class="checkbox-input"
formControlName="devMockUnreal">
<span class="checkbox-custom"></span>
<span class="checkbox-text">Mock Unreal Engine</span>
</label>
<p class="field-help">Use mock data instead of real Unreal Engine connection.</p>
</div>
<!-- Log Level -->
<div class="form-field">
<label for="devLogLevel" class="field-label">
Log Level <span class="required">*</span>
</label>
<select
id="devLogLevel"
class="field-input"
formControlName="devLogLevel"
[class.error]="hasFieldError('devLogLevel')">
<option value="debug">Debug</option>
<option value="info">Info</option>
<option value="warn">Warning</option>
<option value="error">Error</option>
</select>
@if (hasFieldError('devLogLevel')) {
<span class="error-message">
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
{{ getErrorMessage('devLogLevel') }}
</span>
}
<p class="field-help">Minimum level for console logging.</p>
</div>
</div>
</div>
</section>
<!-- Form Actions -->
<footer class="form-actions">
<div class="actions-left">
<button
type="button"
class="btn btn-danger"
(click)="onResetToDefaults()">
<i class="fas fa-undo" aria-hidden="true"></i>
Reset to Defaults
</button>
</div>
<div class="actions-right">
<button
type="button"
class="btn btn-secondary"
(click)="onReset()"
[disabled]="isSaving()">
<i class="fas fa-undo" aria-hidden="true"></i>
Reset Form
</button>
<button
type="submit"
class="btn btn-primary"
[disabled]="!isFormValid || isSaving()"
[class.loading]="isSaving()">
@if (isSaving()) {
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
} @else {
<i class="fas fa-save" aria-hidden="true"></i>
}
{{ isSaving() ? 'Saving...' : 'Save Configuration' }}
</button>
</div>
</footer>
</form>
}
</div>
</div>

View File

@ -0,0 +1,779 @@
// src/app/view/pages/setup/setup.component.scss
.setup-layout {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg-primary);
overflow: hidden;
}
// === SETUP CONTENT ===
.setup-content {
flex: 1;
max-width: 800px;
margin: 0 auto;
padding: var(--spacing-lg);
width: 100%;
overflow-y: auto;
overflow-x: hidden;
}
// === PAGE HEADER ===
.setup-header {
margin-bottom: var(--spacing-xxl);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing-lg);
padding-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--border-primary);
}
.header-info {
flex: 1;
}
.page-title {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin: 0 0 var(--spacing-sm) 0;
color: var(--text-primary);
font-size: 28px;
font-weight: 600;
line-height: 1.2;
i {
color: var(--text-accent);
font-size: 24px;
}
}
.page-description {
margin: 0;
color: var(--text-secondary);
font-size: 16px;
line-height: 1.5;
}
.header-actions {
flex-shrink: 0;
}
// === MESSAGE BANNER ===
.message-banner {
margin-bottom: var(--spacing-lg);
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius-medium);
animation: slideDown 0.3s ease-out;
&.success {
background: rgba(76, 175, 80, 0.1);
border: 1px solid var(--color-success);
color: var(--color-success);
}
&.error {
background: rgba(244, 67, 54, 0.1);
border: 1px solid var(--color-danger);
color: var(--color-danger);
}
}
@keyframes slideDown {
from {
transform: translateY(-10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.message-content {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: 14px;
font-weight: 500;
i {
font-size: 16px;
flex-shrink: 0;
}
}
// === LOADING STATE ===
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-md);
text-align: center;
}
.loading-icon {
font-size: 32px;
color: var(--text-accent);
}
.loading-text {
color: var(--text-secondary);
font-size: 14px;
}
// === SETUP FORM ===
.setup-form {
display: flex;
flex-direction: column;
gap: var(--spacing-xxl);
}
// === FORM SECTIONS ===
.form-section {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-large);
padding: var(--spacing-xxl);
}
.section-title {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin: 0 0 var(--spacing-sm) 0;
color: var(--text-primary);
font-size: 20px;
font-weight: 600;
i {
color: var(--text-accent);
font-size: 18px;
}
}
.section-description {
margin: 0 0 var(--spacing-xxl) 0;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.5;
}
// === SUBSECTIONS ===
.subsection {
margin-bottom: var(--spacing-xl);
&:last-child {
margin-bottom: 0;
}
}
.subsection-title {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin: 0 0 var(--spacing-lg) 0;
color: var(--text-primary);
font-size: 16px;
font-weight: 600;
opacity: 0.9;
i {
color: var(--text-accent);
font-size: 14px;
opacity: 0.7;
}
}
// === FORM GRID ===
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
// === FORM FIELDS ===
.form-field {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
&.full-width {
grid-column: 1 / -1;
}
}
.field-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-xs);
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
.required {
color: var(--color-danger);
font-weight: 700;
}
.test-result {
display: flex;
align-items: center;
gap: var(--spacing-xs);
margin-left: auto;
font-size: 11px;
font-weight: 500;
.test-text {
font-family: monospace;
font-size: 10px;
padding: 2px 6px;
border-radius: var(--radius-small);
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
}
.text-success {
color: var(--color-success);
}
.text-error {
color: var(--color-danger);
}
}
}
.field-input {
padding: var(--spacing-md);
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-medium);
color: var(--text-primary);
font-family: inherit;
font-size: 14px;
line-height: 1.4;
transition: all 0.2s ease;
min-height: 44px;
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2);
background: var(--bg-secondary);
}
&:hover:not(:focus) {
border-color: var(--border-secondary);
}
&.error {
border-color: var(--color-danger);
box-shadow: 0 0 0 2px rgba(244, 67, 54, 0.2);
}
&::placeholder {
color: var(--text-muted);
font-style: italic;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
background: var(--bg-tertiary);
}
// Number input styling
&[type="number"] {
appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
}
.field-help {
margin: 0;
color: var(--text-muted);
font-size: 12px;
line-height: 1.4;
font-style: italic;
}
.error-message {
display: flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--color-danger);
font-size: 12px;
font-weight: 500;
i {
font-size: 11px;
flex-shrink: 0;
}
}
// === CHECKBOX STYLING ===
.checkbox-label {
display: flex;
align-items: center;
gap: var(--spacing-sm);
cursor: pointer;
user-select: none;
}
.checkbox-input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
&:checked + .checkbox-custom {
background: var(--color-primary);
border-color: var(--color-primary);
&::after {
opacity: 1;
transform: scale(1);
}
}
&:focus + .checkbox-custom {
box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2);
}
}
.checkbox-custom {
position: relative;
width: 20px;
height: 20px;
border: 2px solid var(--border-secondary);
border-radius: var(--radius-small);
background: var(--bg-primary);
transition: all 0.2s ease;
flex-shrink: 0;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 6px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: translate(-50%, -60%) rotate(45deg) scale(0);
opacity: 0;
transition: all 0.2s ease;
}
}
.checkbox-text {
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
}
// === CONNECTION TEST ===
.connection-test {
display: flex;
align-items: center;
gap: var(--spacing-lg);
padding: var(--spacing-lg);
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-medium);
}
.test-buttons {
display: flex;
gap: var(--spacing-sm);
flex-shrink: 0;
}
.connection-status {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: 13px;
font-weight: 500;
flex: 1;
&.unknown {
color: var(--text-muted);
}
&.testing {
color: var(--text-accent);
}
&.success {
color: var(--color-success);
}
&.error {
color: var(--color-danger);
}
i {
font-size: 14px;
flex-shrink: 0;
}
}
.test-details {
margin-left: var(--spacing-xs);
color: var(--text-muted);
font-size: 11px;
font-weight: 400;
font-style: italic;
}
// === FORM ACTIONS ===
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg);
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-medium);
margin-top: var(--spacing-lg);
}
.actions-left,
.actions-right {
display: flex;
gap: var(--spacing-md);
align-items: center;
}
// === BUTTONS ===
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-lg);
border: 1px solid transparent;
border-radius: var(--radius-medium);
font-family: inherit;
font-size: 14px;
font-weight: 600;
line-height: 1.4;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
white-space: nowrap;
min-height: 40px;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
&:not(:disabled):hover {
transform: translateY(-1px);
}
&:not(:disabled):active {
transform: translateY(0);
}
i {
font-size: 13px;
flex-shrink: 0;
}
&.loading {
cursor: wait;
i {
animation: spin 1s linear infinite;
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.btn-primary {
background: linear-gradient(135deg, var(--color-primary) 0%, #005a9e 100%);
border-color: var(--color-primary);
color: white;
box-shadow: var(--shadow-light);
&:hover:not(:disabled) {
background: linear-gradient(135deg, #005a9e 0%, var(--color-primary) 100%);
box-shadow: var(--shadow-medium);
}
&:active:not(:disabled) {
background: var(--color-primary);
box-shadow: var(--shadow-light);
}
}
.btn-secondary {
background: transparent;
border-color: var(--border-secondary);
color: var(--text-secondary);
&:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-active);
color: var(--text-primary);
}
&:active:not(:disabled) {
background: var(--bg-active);
}
}
.btn-danger {
background: transparent;
border-color: var(--color-danger);
color: var(--color-danger);
&:hover:not(:disabled) {
background: var(--color-danger);
color: white;
}
&:active:not(:disabled) {
background: #d32f2f;
}
}
// === RESPONSIVE DESIGN ===
@media (max-width: 768px) {
.setup-content {
padding: var(--spacing-md);
}
.header-content {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-md);
}
.page-title {
font-size: 24px;
i {
font-size: 20px;
}
}
.page-description {
font-size: 14px;
}
.form-section {
padding: var(--spacing-lg);
}
.section-title {
font-size: 18px;
i {
font-size: 16px;
}
}
.form-grid {
grid-template-columns: 1fr;
gap: var(--spacing-md);
}
.connection-test {
flex-direction: column;
align-items: stretch;
gap: var(--spacing-md);
}
.test-buttons {
justify-content: center;
}
.connection-status {
justify-content: center;
text-align: center;
}
.form-actions {
flex-direction: column;
gap: var(--spacing-md);
align-items: stretch;
}
.actions-left,
.actions-right {
justify-content: center;
width: 100%;
}
}
@media (max-width: 480px) {
.setup-content {
padding: var(--spacing-sm);
}
.page-title {
font-size: 20px;
flex-direction: column;
gap: var(--spacing-sm);
text-align: center;
i {
font-size: 18px;
}
}
.form-section {
padding: var(--spacing-md);
}
.section-title {
font-size: 16px;
flex-direction: column;
gap: var(--spacing-sm);
text-align: center;
}
.field-input {
font-size: 16px; // Prevent zoom on iOS
}
.actions-left,
.actions-right {
flex-direction: column;
gap: var(--spacing-sm);
}
.btn {
width: 100%;
justify-content: center;
}
}
// === ACCESSIBILITY ===
@media (prefers-reduced-motion: reduce) {
.message-banner {
animation: none;
}
.btn,
.field-input,
.checkbox-custom {
transition: none;
}
.btn:not(:disabled):hover,
.btn:not(:disabled):active {
transform: none;
}
.btn.loading i {
animation: none;
}
.checkbox-custom::after {
transition: none;
}
}
// === HIGH CONTRAST MODE ===
@media (prefers-contrast: high) {
.form-section {
border-width: 2px;
}
.field-input {
border-width: 2px;
&:focus {
outline: 3px solid var(--color-primary);
outline-offset: 1px;
}
&.error {
outline: 3px solid var(--color-danger);
outline-offset: 1px;
}
}
.btn {
border-width: 2px;
&:focus-visible {
outline-width: 3px;
}
}
.checkbox-custom {
border-width: 3px;
}
.connection-test {
border-width: 2px;
}
}
// === PRINT STYLES ===
@media print {
.setup-layout {
height: auto;
overflow: visible;
}
.setup-content {
max-width: none;
padding: 0;
overflow: visible;
}
.header-actions,
.form-actions,
.connection-test {
display: none;
}
.form-section {
break-inside: avoid;
page-break-inside: avoid;
}
.btn {
display: none;
}
}

View File

@ -0,0 +1,397 @@
// src/app/view/pages/setup/setup.component.ts
import { Component, signal, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { Subscription } from 'rxjs';
// Services
import { ConfigService } from '../../../services/config.service';
import { ToastService } from '../../../services/toast.service';
// Components
import { MenuBarComponent } from '../../components/menu-bar/menu-bar.component';
// Models
import { AppConfig } from '../../../models/app-config.model';
interface ConnectionTestResult {
service: 'motionDesign' | 'remoteControl';
success: boolean;
duration: number;
error?: string;
port?: number;
}
@Component({
selector: 'app-setup',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
RouterLink,
MenuBarComponent
],
templateUrl: './setup.component.html',
styleUrl: './setup.component.scss'
})
export class SetupComponent implements OnDestroy {
// === SIGNALS ===
appConfig = signal<AppConfig | null>(null);
isLoading = signal<boolean>(true);
isSaving = signal<boolean>(false);
saveMessage = signal<string | null>(null);
connectionStatus = signal<'unknown' | 'testing' | 'connected' | 'failed'>('unknown');
testResults = signal<ConnectionTestResult[]>([]);
// === FORMS ===
setupForm: FormGroup;
private subscriptions: Subscription[] = [];
constructor(
private formBuilder: FormBuilder,
private configService: ConfigService,
private toastService: ToastService
) {
this.setupForm = this.createForm();
this.initializeComponent();
}
ngOnDestroy(): void {
this.subscriptions.forEach(sub => sub.unsubscribe());
}
// === INITIALIZATION ===
private initializeComponent(): void {
// Subscribe to configuration changes
const configSubscription = this.configService.config$.subscribe(
config => {
this.appConfig.set(config);
this.loadConfigToForm(config);
this.isLoading.set(false);
}
);
this.subscriptions.push(configSubscription);
}
private createForm(): FormGroup {
return this.formBuilder.group({
// === UNREAL ENGINE NETWORK ===
unrealAddress: ['127.0.0.1', [Validators.required, Validators.pattern(/^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/)]],
unrealTimeout: [5000, [Validators.required, Validators.min(1000), Validators.max(30000)]],
// === REMOTE CONTROL ===
// Network
remoteControlWebSocketPort: [30020, [Validators.required, Validators.min(1), Validators.max(65535)]],
// === MOTION DESIGN ===
// Network
motionDesignWebSocketPort: [30021, [Validators.required, Validators.min(1), Validators.max(65535)]],
// Data
motionDesignPresetName: ['Default', [Validators.required]],
// === STORAGE ===
// Data
savePath: ['./configs', [Validators.required]],
autoSave: [true],
autoSaveInterval: [5, [Validators.required, Validators.min(1), Validators.max(60)]],
// === UI ===
// Data
uiDefaultSort: ['LastName', [Validators.required]],
uiSearchPlaceholder: ['Search by name or function or bib...', [Validators.required]],
// === DEVELOPMENT ===
// Data
enableDevTools: [false],
devLogLevel: ['info', [Validators.required]],
devMockUnreal: [false]
});
}
private loadConfigToForm(config: AppConfig): void {
this.setupForm.patchValue({
// === UNREAL ENGINE NETWORK ===
unrealAddress: config.UnrealEngine.Address,
unrealTimeout: config.UnrealEngine.Timeout,
// === REMOTE CONTROL ===
// Network
remoteControlWebSocketPort: config.UnrealEngine.RemoteControl.WebSocketPort,
// === MOTION DESIGN ===
// Network
motionDesignWebSocketPort: config.UnrealEngine.MotionDesign.WebSocketPort,
// Data
motionDesignPresetName: config.UnrealEngine.MotionDesign.PresetName,
// === STORAGE ===
// Data
savePath: config.Storage.SavePath,
autoSave: config.Storage.AutoSave,
autoSaveInterval: config.Storage.AutoSaveInterval,
// === UI ===
// Data
uiDefaultSort: config.UI.DefaultSort,
uiSearchPlaceholder: config.UI.SearchPlaceholder,
// === DEVELOPMENT ===
// Data
enableDevTools: config.Development.EnableDevTools,
devLogLevel: config.Development.LogLevel,
devMockUnreal: config.Development.MockUnreal
});
}
// === EVENT HANDLERS ===
async onSave(): Promise<void> {
if (this.setupForm.valid && !this.isSaving()) {
this.isSaving.set(true);
this.saveMessage.set(null);
try {
const formValue = this.setupForm.value;
const currentConfig = this.configService.getCurrentConfig();
const updatedConfig: AppConfig = {
...currentConfig,
UnrealEngine: {
...currentConfig.UnrealEngine,
Address: formValue.unrealAddress,
Timeout: formValue.unrealTimeout,
RemoteControl: {
...currentConfig.UnrealEngine.RemoteControl,
HttpPort: 30010, // Keep for compatibility but not used
WebSocketPort: formValue.remoteControlWebSocketPort,
UseWebSocket: true, // Always WebSocket
ApiVersion: 'v1', // Fixed default value
KeepAlive: true, // Fixed default value
MaxRetries: 3 // Fixed default value
},
MotionDesign: {
...currentConfig.UnrealEngine.MotionDesign,
BlueprintPath: '/Game/MotionDesign/TitleSheet_BP.TitleSheet_BP_C', // Fixed default value
PresetName: formValue.motionDesignPresetName,
WebSocketPort: formValue.motionDesignWebSocketPort,
DefaultTransition: {
In: 'FadeIn', // Fixed default value
Out: 'FadeOut', // Fixed default value
Duration: 1000 // Fixed default value
}
}
},
Storage: {
...currentConfig.Storage,
SavePath: formValue.savePath,
AutoSave: formValue.autoSave,
AutoSaveInterval: formValue.autoSaveInterval
},
UI: {
...currentConfig.UI,
DefaultSort: formValue.uiDefaultSort,
SearchPlaceholder: formValue.uiSearchPlaceholder
},
Development: {
...currentConfig.Development,
EnableDevTools: formValue.enableDevTools,
LogLevel: formValue.devLogLevel,
MockUnreal: formValue.devMockUnreal
}
};
this.configService.updateConfig(updatedConfig);
this.saveMessage.set('Configuration saved successfully!');
console.log('⚙️ Setup configuration saved');
// Clear success message after 3 seconds
setTimeout(() => {
this.saveMessage.set(null);
}, 3000);
} catch (error) {
console.error('❌ Failed to save setup configuration:', error);
this.saveMessage.set('Failed to save configuration. Please try again.');
}
this.isSaving.set(false);
} else {
this.markFormGroupTouched();
}
}
onReset(): void {
const config = this.appConfig();
if (config) {
this.loadConfigToForm(config);
this.setupForm.markAsUntouched();
this.saveMessage.set('Form reset to current configuration.');
setTimeout(() => this.saveMessage.set(null), 2000);
}
}
onResetToDefaults(): void {
if (confirm('Reset all settings to default values? This will overwrite your current configuration.')) {
this.configService.resetToDefault();
this.saveMessage.set('Configuration reset to defaults.');
setTimeout(() => this.saveMessage.set(null), 3000);
}
}
// === MENU BAR HANDLERS ===
async onConfigOpen(): Promise<void> {
try {
await this.configService.loadFromFile();
this.saveMessage.set('Configuration loaded from file.');
setTimeout(() => this.saveMessage.set(null), 3000);
} catch (error) {
this.saveMessage.set('Failed to load configuration file.');
setTimeout(() => this.saveMessage.set(null), 3000);
}
}
async onConfigSave(): Promise<void> {
try {
await this.configService.saveToFile();
this.saveMessage.set('Configuration exported to file.');
setTimeout(() => this.saveMessage.set(null), 3000);
} catch (error) {
this.saveMessage.set('Failed to export configuration file.');
setTimeout(() => this.saveMessage.set(null), 3000);
}
}
// === UTILITY METHODS ===
private markFormGroupTouched(): void {
Object.keys(this.setupForm.controls).forEach(key => {
this.setupForm.get(key)?.markAsTouched();
});
}
getErrorMessage(fieldName: string): string {
const control = this.setupForm.get(fieldName);
if (!control?.errors || !control.touched) return '';
if (control.hasError('required')) {
return `${this.getFieldDisplayName(fieldName)} is required`;
}
if (control.hasError('pattern')) {
return `Please enter a valid IP address`;
}
if (control.hasError('min')) {
const min = control.errors['min'].min;
return `Value must be at least ${min}`;
}
if (control.hasError('max')) {
const max = control.errors['max'].max;
return `Value cannot exceed ${max}`;
}
return '';
}
private getFieldDisplayName(fieldName: string): string {
const names: Record<string, string> = {
// === UNREAL ENGINE NETWORK ===
unrealAddress: 'Unreal Engine address',
unrealTimeout: 'Connection timeout',
// === REMOTE CONTROL - Network ===
remoteControlWebSocketPort: 'Remote Control WebSocket port',
// === MOTION DESIGN - Network ===
motionDesignWebSocketPort: 'Motion Design WebSocket port',
// === MOTION DESIGN - Data ===
motionDesignPresetName: 'Motion Design preset name',
// === STORAGE - Data ===
savePath: 'Save path',
autoSaveInterval: 'Auto save interval',
// === UI - Data ===
uiDefaultSort: 'Default sort field',
uiSearchPlaceholder: 'Search placeholder text',
// === DEVELOPMENT - Data ===
devLogLevel: 'Development log level'
};
return names[fieldName] || fieldName;
}
hasFieldError(fieldName: string): boolean {
const control = this.setupForm.get(fieldName);
return !!(control?.errors && control.touched);
}
get isFormValid(): boolean {
return this.setupForm.valid;
}
get connectionStatusClass(): string {
switch (this.connectionStatus()) {
case 'testing': return 'testing';
case 'connected': return 'success';
case 'failed': return 'error';
default: return 'unknown';
}
}
get connectionStatusText(): string {
switch (this.connectionStatus()) {
case 'testing': return 'Testing WebSocket connections...';
case 'connected': return 'Both WebSocket connections successful!';
case 'failed': return 'WebSocket connection tests failed';
default: return 'Not tested';
}
}
get connectionStatusIcon(): string {
switch (this.connectionStatus()) {
case 'testing': return 'fas fa-spinner fa-spin';
case 'connected': return 'fas fa-check-circle';
case 'failed': return 'fas fa-times-circle';
default: return 'fas fa-question-circle';
}
}
// === TEST RESULT HELPERS ===
getTestResult(service: 'motionDesign' | 'remoteControl'): ConnectionTestResult | null {
return this.testResults().find(result => result.service === service) || null;
}
getTestStatusIcon(service: 'motionDesign' | 'remoteControl'): string {
const result = this.getTestResult(service);
if (!result) return '';
return result.success
? 'fas fa-check-circle text-success'
: 'fas fa-times-circle text-error';
}
getTestStatusText(service: 'motionDesign' | 'remoteControl'): string {
const result = this.getTestResult(service);
if (!result) return '';
if (result.success) {
return `${result.duration.toFixed(0)}ms`;
} else {
return `${result.error || 'Failed'}`;
}
}
hasTestResult(service: 'motionDesign' | 'remoteControl'): boolean {
return this.testResults().some(result => result.service === service);
}
onTestConnection() : void{
console.log("Testing Connections");
}
}

View File

@ -0,0 +1,485 @@
// src/app/view/pages/test-page/test-page.component.ts
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UnrealEngineService } from '../../../services/unreal-engine.service';
import { ConfigService } from '../../../services/config.service';
import { ConnectionStatusComponent } from '../../components/connection-status/connection-status.component';
import { MotionDesignMonitorComponent } from '../../components/motion-design-monitor/motion-design-monitor.component';
import { AppConfig } from '../../../models/app-config.model';
@Component({
selector: 'app-test-page',
standalone: true,
imports: [
CommonModule,
ConnectionStatusComponent,
MotionDesignMonitorComponent
],
template: `
<div class="test-page">
<div class="page-header">
<h1>
<i class="fas fa-flask"></i>
DTFlux Test Page
</h1>
<p class="subtitle">Test et validation de la communication avec Unreal Engine</p>
</div>
<div class="test-grid">
<!-- Connection Status -->
<div class="test-section">
<app-connection-status></app-connection-status>
</div>
<!-- Motion Design Monitor -->
<div class="test-section full-width">
<app-motion-design-monitor></app-motion-design-monitor>
</div>
<!-- Quick Actions -->
<div class="test-section">
<div class="quick-actions">
<h4>Quick Actions</h4>
<div class="actions-grid">
<button class="btn btn-primary" (click)="testAllConnections()">
<i class="fas fa-plug"></i>
Test All Connections
</button>
<button class="btn btn-secondary" (click)="loadDefaultConfig()">
<i class="fas fa-cog"></i>
Load Default Config
</button>
<button class="btn btn-info" (click)="showDebugInfo()">
<i class="fas fa-bug"></i>
Show Debug Info
</button>
<button class="btn btn-warning" (click)="clearLogs()">
<i class="fas fa-trash"></i>
Clear Console
</button>
</div>
</div>
</div>
<!-- Test Results -->
<div class="test-section">
<div class="test-results">
<h4>Test Results</h4>
<div class="results-list">
@if (testResults.length === 0) {
<div class="no-results">
<i class="fas fa-info-circle"></i>
<span>No tests run yet. Click "Test All Connections" to start.</span>
</div>
} @else {
@for (result of testResults; track result.timestamp) {
<div class="result-item" [class]="result.success ? 'success' : 'failure'">
<div class="result-header">
<span class="result-name">{{ result.name }}</span>
<span class="result-time">{{ result.timestamp | date:'HH:mm:ss' }}</span>
</div>
<div class="result-status">
<i [class]="result.success ? 'fas fa-check' : 'fas fa-times'"></i>
<span>{{ result.message }}</span>
</div>
@if (result.duration) {
<div class="result-duration">{{ result.duration }}ms</div>
}
</div>
}
}
</div>
</div>
</div>
</div>
<!-- Debug Panel (collapsible) -->
@if (showDebug) {
<div class="debug-panel">
<div class="debug-header">
<h4>Debug Information</h4>
<button class="btn btn-sm btn-secondary" (click)="showDebug = false">
<i class="fas fa-times"></i>
Close
</button>
</div>
<div class="debug-content">
<div class="debug-section">
<h5>Current Configuration</h5>
<pre>{{ getCurrentConfig() | json }}</pre>
</div>
<div class="debug-section">
<h5>Service Status</h5>
<pre>{{ getServiceStatus() | json }}</pre>
</div>
</div>
</div>
}
</div>
`,
styles: [`
.test-page {
padding: 1rem;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
text-align: center;
margin-bottom: 2rem;
padding: 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.page-header h1 {
margin: 0 0 0.5rem 0;
font-size: 2.5rem;
font-weight: 300;
}
.page-header .subtitle {
margin: 0;
opacity: 0.9;
font-size: 1.1rem;
}
.test-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 2rem;
}
.test-section {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border: 1px solid #e9ecef;
}
.test-section.full-width {
grid-column: 1 / -1;
}
.quick-actions,
.test-results {
padding: 1rem;
}
.quick-actions h4,
.test-results h4 {
margin: 0 0 1rem 0;
color: #495057;
border-bottom: 1px solid #f8f9fa;
padding-bottom: 0.5rem;
}
.actions-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s ease;
text-decoration: none;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-info {
background: #17a2b8;
color: white;
}
.btn-warning {
background: #ffc107;
color: #212529;
}
.btn-sm {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
}
.results-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.no-results {
text-align: center;
padding: 2rem;
color: #6c757d;
}
.no-results i {
font-size: 2rem;
margin-bottom: 0.5rem;
display: block;
}
.result-item {
padding: 0.75rem;
border-radius: 6px;
border-left: 4px solid;
}
.result-item.success {
background: #d4edda;
border-left-color: #28a745;
}
.result-item.failure {
background: #f8d7da;
border-left-color: #dc3545;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.result-name {
font-weight: 500;
color: #495057;
}
.result-time {
font-size: 0.8rem;
color: #6c757d;
}
.result-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.result-duration {
font-size: 0.8rem;
color: #6c757d;
text-align: right;
margin-top: 0.25rem;
}
.debug-panel {
background: #343a40;
color: #f8f9fa;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.debug-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #495057;
border-bottom: 1px solid #6c757d;
}
.debug-header h4 {
margin: 0;
color: #f8f9fa;
}
.debug-content {
padding: 1rem;
}
.debug-section {
margin-bottom: 1.5rem;
}
.debug-section:last-child {
margin-bottom: 0;
}
.debug-section h5 {
margin: 0 0 0.5rem 0;
color: #adb5bd;
font-size: 1rem;
}
.debug-section pre {
background: #495057;
padding: 1rem;
border-radius: 4px;
font-size: 0.8rem;
overflow-x: auto;
margin: 0;
}
@media (max-width: 768px) {
.test-grid {
grid-template-columns: 1fr;
}
.actions-grid {
grid-template-columns: 1fr;
}
.page-header h1 {
font-size: 2rem;
}
}
`]
})
export class TestPageComponent implements OnInit {
showDebug = false;
testResults: Array<{
name: string;
success: boolean;
message: string;
timestamp: Date;
duration?: number;
}> = [];
constructor(
private unrealService: UnrealEngineService,
private configService: ConfigService
) {}
ngOnInit(): void {
console.log('🧪 Test Page initialized');
this.loadDefaultConfig();
}
async testAllConnections(): Promise<void> {
this.addTestResult('Connection Test Started', true, 'Starting connectivity tests...');
const startTime = Date.now();
try {
const results = await this.unrealService.testConnectivity();
const duration = Date.now() - startTime;
this.addTestResult(
'Motion Design Test',
results.motionDesign,
results.motionDesign ? 'Connected successfully' : 'Connection failed',
duration
);
this.addTestResult(
'Remote Control Test',
results.remoteControl,
results.remoteControl ? 'Connected successfully' : 'Connection failed',
duration
);
const overallSuccess = results.motionDesign && results.remoteControl;
this.addTestResult(
'Overall Test Result',
overallSuccess,
overallSuccess ? 'All connections successful' : 'Some connections failed'
);
} catch (error) {
this.addTestResult(
'Connection Test Error',
false,
error instanceof Error ? error.message : 'Unknown error'
);
}
}
loadDefaultConfig(): void {
// Load a default configuration for testing
const defaultConfig: AppConfig = {
UnrealEngine: {
Address: 'localhost',
Timeout: 5000,
MotionDesign: {
WebSocketPort: 8080,
RundownAsset: '/Game/Rundowns/TestRundown.TestRundown'
},
RemoteControl: {
WebSocketPort: 30010
}
},
Development: {
EnableDevTools: true
}
};
this.configService.updateConfig(defaultConfig);
this.addTestResult('Config Loaded', true, 'Default configuration loaded');
}
showDebugInfo(): void {
this.showDebug = !this.showDebug;
this.addTestResult('Debug Panel', true, `Debug panel ${this.showDebug ? 'opened' : 'closed'}`);
}
clearLogs(): void {
console.clear();
this.testResults = [];
this.addTestResult('Logs Cleared', true, 'Console and test results cleared');
}
getCurrentConfig(): any {
return this.configService.getConfig();
}
getServiceStatus(): any {
return {
unrealEngine: this.unrealService.getStatus(),
motionDesign: this.unrealService.getMotionDesignService().getState(),
remoteControl: this.unrealService.getRemoteControlService().getStatus(),
connectionSummary: this.unrealService.getConnectionSummary(),
isFullyConnected: this.unrealService.isFullyConnected()
};
}
private addTestResult(name: string, success: boolean, message: string, duration?: number): void {
this.testResults.unshift({
name,
success,
message,
timestamp: new Date(),
duration
});
// Keep only last 10 results
if (this.testResults.length > 10) {
this.testResults = this.testResults.slice(0, 10);
}
}
}

4
src/proxy.conf.json Normal file
View File

@ -0,0 +1,4 @@
{
"// PROXY DÉSACTIVÉ": "Communication directe WebSocket uniquement",
"// Ancien proxy HTTP vers port 30010 supprimé": "Utilisation WebSocket ports 30020 et 30021"
}