Functionnal UI with All Files
This commit is contained in:
1801
src/AvaRundownMessages.h
Normal file
1801
src/AvaRundownMessages.h
Normal file
File diff suppressed because it is too large
Load Diff
268
src/app/dev-tools.ts
Normal file
268
src/app/dev-tools.ts
Normal 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');
|
||||
}
|
||||
106
src/app/models/app-config.model.ts
Normal file
106
src/app/models/app-config.model.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
49
src/app/models/title-sheet.model.ts
Normal file
49
src/app/models/title-sheet.model.ts
Normal 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,
|
||||
}
|
||||
};
|
||||
299
src/app/services/config.service.ts
Normal file
299
src/app/services/config.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
124
src/app/services/title-sheet.service.ts
Normal file
124
src/app/services/title-sheet.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
167
src/app/services/toast.service.ts
Normal file
167
src/app/services/toast.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
338
src/app/services/utils/cbor.service.ts.deleted
Normal file
338
src/app/services/utils/cbor.service.ts.deleted
Normal 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'
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
9
src/app/view/components/big-card/big-card.component.html
Normal file
9
src/app/view/components/big-card/big-card.component.html
Normal 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>
|
||||
91
src/app/view/components/big-card/big-card.component.scss
Normal file
91
src/app/view/components/big-card/big-card.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/app/view/components/big-card/big-card.component.ts
Normal file
15
src/app/view/components/big-card/big-card.component.ts
Normal 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);
|
||||
|
||||
}
|
||||
58
src/app/view/components/cued-title/cued-title.component.html
Normal file
58
src/app/view/components/cued-title/cued-title.component.html
Normal 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>
|
||||
145
src/app/view/components/cued-title/cued-title.component.scss
Normal file
145
src/app/view/components/cued-title/cued-title.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
132
src/app/view/components/cued-title/cued-title.component.ts
Normal file
132
src/app/view/components/cued-title/cued-title.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
111
src/app/view/components/details-panel/details-panel.component.ts
Normal file
111
src/app/view/components/details-panel/details-panel.component.ts
Normal 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)
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -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>
|
||||
158
src/app/view/components/list-header/list-header.component.scss
Normal file
158
src/app/view/components/list-header/list-header.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
65
src/app/view/components/list-header/list-header.component.ts
Normal file
65
src/app/view/components/list-header/list-header.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
131
src/app/view/components/list-item/list-item.component.html
Normal file
131
src/app/view/components/list-item/list-item.component.html
Normal 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>
|
||||
296
src/app/view/components/list-item/list-item.component.scss
Normal file
296
src/app/view/components/list-item/list-item.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
199
src/app/view/components/list-item/list-item.component.ts
Normal file
199
src/app/view/components/list-item/list-item.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
53
src/app/view/components/menu-bar/menu-bar.component.html
Normal file
53
src/app/view/components/menu-bar/menu-bar.component.html
Normal 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>
|
||||
231
src/app/view/components/menu-bar/menu-bar.component.scss
Normal file
231
src/app/view/components/menu-bar/menu-bar.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
16
src/app/view/components/menu-bar/menu-bar.component.ts
Normal file
16
src/app/view/components/menu-bar/menu-bar.component.ts
Normal 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>();
|
||||
}
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
175
src/app/view/components/title-dialog/title-dialog.component.html
Normal file
175
src/app/view/components/title-dialog/title-dialog.component.html
Normal 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>
|
||||
463
src/app/view/components/title-dialog/title-dialog.component.scss
Normal file
463
src/app/view/components/title-dialog/title-dialog.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
233
src/app/view/components/title-dialog/title-dialog.component.ts
Normal file
233
src/app/view/components/title-dialog/title-dialog.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
38
src/app/view/components/toast/toast.component.html
Normal file
38
src/app/view/components/toast/toast.component.html
Normal 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>
|
||||
303
src/app/view/components/toast/toast.component.scss
Normal file
303
src/app/view/components/toast/toast.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
47
src/app/view/components/toast/toast.component.ts
Normal file
47
src/app/view/components/toast/toast.component.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
55
src/app/view/components/toolbar/toolbar.component.html
Normal file
55
src/app/view/components/toolbar/toolbar.component.html
Normal 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>
|
||||
276
src/app/view/components/toolbar/toolbar.component.scss
Normal file
276
src/app/view/components/toolbar/toolbar.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/app/view/components/toolbar/toolbar.component.ts
Normal file
73
src/app/view/components/toolbar/toolbar.component.ts
Normal 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' : ''}`;
|
||||
}
|
||||
}
|
||||
180
src/app/view/pages/dtflux-title/dtflux-title.component.html
Normal file
180
src/app/view/pages/dtflux-title/dtflux-title.component.html
Normal 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>
|
||||
491
src/app/view/pages/dtflux-title/dtflux-title.component.scss
Normal file
491
src/app/view/pages/dtflux-title/dtflux-title.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
299
src/app/view/pages/dtflux-title/dtflux-title.component.ts
Normal file
299
src/app/view/pages/dtflux-title/dtflux-title.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
563
src/app/view/pages/setup/setup.component.html
Normal file
563
src/app/view/pages/setup/setup.component.html
Normal 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>
|
||||
779
src/app/view/pages/setup/setup.component.scss
Normal file
779
src/app/view/pages/setup/setup.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
397
src/app/view/pages/setup/setup.component.ts
Normal file
397
src/app/view/pages/setup/setup.component.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
|
||||
485
src/app/view/pages/test-page/test-page.component.ts
Normal file
485
src/app/view/pages/test-page/test-page.component.ts
Normal 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
4
src/proxy.conf.json
Normal 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"
|
||||
}
|
||||
Reference in New Issue
Block a user