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