diff --git a/src/AvaRundownMessages.h b/src/AvaRundownMessages.h new file mode 100644 index 0000000..1520b53 --- /dev/null +++ b/src/AvaRundownMessages.h @@ -0,0 +1,1801 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "Broadcast/Channel/AvaBroadcastMediaOutputInfo.h" +#include "Rundown/AvaRundownPage.h" +#include "Viewport/AvaViewportQualitySettings.h" +#include "AvaRundownMessages.generated.h" + +namespace EAvaRundownApiVersion +{ + /** + * Defines the protocol version of the Rundown Server API. + * + * API versioning is used to provide legacy support either on + * the client side or server side for non compatible changes. + * Clients can request a version of the API that they where implemented against, + * if the server can still honor the request it will accept. + */ + enum Type + { + Unspecified = -1, + + Initial = 1, + /** + * The rundown server has been moved to the runtime module. + * All message scripts paths moved from AvalancheMediaEditor to AvalancheMedia. + * However, all server requests messages have been added to core redirect, so + * previous path will still get through, but all response messages will be the new path. + * Clients can still issue a ping with the old path and will get a response. + */ + MoveToRuntime = 2, + + // ------------------------------------------------------ + // - this needs to be the last line (see note below) + VersionPlusOne, + LatestVersion = VersionPlusOne - 1 + }; +} + +/** + * Build targets. + * This will help determine the set of features that are available. + */ +UENUM() +enum class EAvaRundownServerBuildTargetType : uint8 +{ + Unknown = 0, + Editor, + Game, + Server, + Client, + Program +}; + +/** + * An editor build can be launched in different modes but it could also be + * a dedicated build target. The engine mode combined with the build target + * will determine the set of functionalities available. + */ +UENUM() +enum class EAvaRundownServerEngineMode : uint8 +{ + Unknown = 0, + Editor, + Game, + Server, + Commandlet, + Other +}; + +/** Base class for all rundown server messages. */ +USTRUCT() +struct FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** + * Request Identifier (client assigned) for matching server responses with their corresponding requests. + */ + UPROPERTY() + int32 RequestId = INDEX_NONE; +}; + +/** + * This message is the default response message for all requests, unless a specific response message type + * is specified for the request. + * On success, the message will have a Verbosity of "Log" and the text may contain response payload related data. + * On failure, a message with Verbosity "Error" will be sent. + * This message's RequestId mirrors that of the corresponding request from the client. + */ +USTRUCT() +struct FAvaRundownServerMsg : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** + * Debug, Log, Warning, Error, etc. + */ + UPROPERTY() + FString Verbosity; + + /** + * Message Text. + */ + UPROPERTY() + FString Text; +}; + +/** + * Request published by client to discover servers on the message bus. + * The available servers will respond with a FAvaRundownPong. + */ +USTRUCT() +struct FAvaRundownPing : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** True if the request originates from an automatic timer. False if request originates from user interaction. */ + UPROPERTY() + bool bAuto = true; + + /** + * API Version the client has been implemented against. + * If unspecified the server will consider the latest version is requested. + */ + UPROPERTY() + int32 RequestedApiVersion = EAvaRundownApiVersion::Unspecified; +}; + +/** + * The server will send this message to the client in response to FAvaRundownPing. + * This is used to discover the server's entry point on the message bus. + */ +USTRUCT() +struct FAvaRundownPong : public FAvaRundownMsgBase +{ + GENERATED_BODY() + +public: + /** True if it is a reply to an auto ping. Mirrors the bAuto flag from Ping message. */ + UPROPERTY() + bool bAuto = true; + + /** + * API Version the server will communicate with for this client. + * The server may honor the requested version if possible. + * Versions newer than server implementation will obviously not be honored either. + * Clients should expect an older server to reply with an older version. + */ + UPROPERTY() + int32 ApiVersion = EAvaRundownApiVersion::Unspecified; + + /** Minimum API Version the server implements. */ + UPROPERTY() + int32 MinimumApiVersion = EAvaRundownApiVersion::Unspecified; + + /** Latest API Version the server support. */ + UPROPERTY() + int32 LatestApiVersion = EAvaRundownApiVersion::Unspecified; + + /** Server Host Name */ + UPROPERTY() + FString HostName; +}; + +/** + * Requests the extended server information. + * Response is FAvaRundownServerInfo. + */ +USTRUCT() +struct FAvaRundownGetServerInfo : public FAvaRundownMsgBase +{ + GENERATED_BODY() +}; + +/** + * Extended server information. + */ +USTRUCT() +struct FAvaRundownServerInfo : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** API Version the server will communicate with for this client. */ + UPROPERTY() + int32 ApiVersion = EAvaRundownApiVersion::Unspecified; + + /** Minimum API Version the server implements. */ + UPROPERTY() + int32 MinimumApiVersion = EAvaRundownApiVersion::Unspecified; + + /** Latest API Version the server support. */ + UPROPERTY() + int32 LatestApiVersion = EAvaRundownApiVersion::Unspecified; + + /** Server Host Name */ + UPROPERTY() + FString HostName; + + /** Holds the engine version checksum */ + UPROPERTY() + uint32 EngineVersion = 0; + + /** Application Instance Identifier. */ + UPROPERTY() + FGuid InstanceId; + + UPROPERTY() + EAvaRundownServerBuildTargetType InstanceBuild = EAvaRundownServerBuildTargetType::Unknown; + + UPROPERTY() + EAvaRundownServerEngineMode InstanceMode = EAvaRundownServerEngineMode::Unknown; + + /** Holds the identifier of the session that the application belongs to. */ + UPROPERTY() + FGuid SessionId; + + /** The unreal project name this server is running from. */ + UPROPERTY() + FString ProjectName; + + /** The unreal project directory this server is running from. */ + UPROPERTY() + FString ProjectDir; + + /** Http Server Port of the remote control service. */ + UPROPERTY() + uint32 RemoteControlHttpServerPort = 0; + + /** WebSocket Server Port of the remote control service. */ + UPROPERTY() + uint32 RemoteControlWebSocketServerPort = 0; +}; + +/** + * Requests a list of playable assets that can be added to a + * rundown template. + * Response is FAvaRundownPlayableAssets. + */ +USTRUCT() +struct FAvaRundownGetPlayableAssets : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** + * The search query which will be compared with the asset names. + */ + UPROPERTY() + FString Query; + + /** + * The maximum number of search results returned. + */ + UPROPERTY() + int32 Limit = 0; +}; + +/** + * List of all available playable assets on the server. + * Expected Response from FAvaRundownGetPlayableAssets. + */ +USTRUCT() +struct FAvaRundownPlayableAssets : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + UPROPERTY() + TArray Assets; +}; + +/** + * Requests the list of rundowns that can be opened on the current server. + * Response is FAvaRundownRundowns. + */ +USTRUCT() +struct FAvaRundownGetRundowns : public FAvaRundownMsgBase +{ + GENERATED_BODY() +}; + +/** + * List of all rundowns. + * Expected Response from FAvaRundownGetRundowns. + */ +USTRUCT() +struct FAvaRundownRundowns : public FAvaRundownMsgBase +{ + GENERATED_BODY() + +public: + /** + * List of Rundown asset paths in format: [PackagePath]/[AssetName].[AssetName] + */ + UPROPERTY() + TArray Rundowns; +}; + +/** + * Loads the given rundown for playback operations. + * This will also open an associated playback context. + * Only one rundown can be opened for playback at a time by the rundown server. + * If another rundown is opened, the previous one will be closed and all currently playing pages stopped, + * unless the rundown editor is opened. The rundown editor will keep the playback context alive. + * + * If the path is empty, nothing will be done and the server will reply with + * a FAvaRundownServerMsg message indicating which rundown is currently loaded. + */ +USTRUCT() +struct FAvaRundownLoadRundown : public FAvaRundownMsgBase +{ + GENERATED_BODY() + +public: + /** + * Rundown asset path: [PackagePath]/[AssetName].[AssetName] + */ + UPROPERTY() + FString Rundown; +}; + +/** + * Creates a new rundown asset. + * + * The full package name is going to be: [PackagePath]/[AssetName] + * The full asset path is going to be: [PackagePath]/[AssetName].[AssetName] + * For all other requests, the rundown reference is the full asset path. + * + * Response is FAvaRundownServerMsg. + */ +USTRUCT() +struct FAvaRundownCreateRundown : public FAvaRundownMsgBase +{ + GENERATED_BODY() + +public: + /** Package path (excluding the package name) */ + UPROPERTY() + FString PackagePath; + + /** Asset Name. */ + UPROPERTY() + FString AssetName; + + /** + * Create the rundown as a transient object. + * @remark For game builds, the created rundown will always be transient, regardless of this flag. + */ + UPROPERTY() + bool bTransient = true; +}; + +/** + * Deletes an existing rundown. + * + * Response is FAvaRundownServerMsg. + */ +USTRUCT() +struct FAvaRundownDeleteRundown : public FAvaRundownMsgBase +{ + GENERATED_BODY() + +public: + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; +}; + +/** + * Imports rundown from json data or file. + */ +USTRUCT() +struct FAvaRundownImportRundown : public FAvaRundownMsgBase +{ + GENERATED_BODY() + +public: + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** + * If specified, this is a server local path to a json file from which the rundown will be imported. + */ + UPROPERTY() + FString RundownFile; + + /** + * If specified, json data containing the rundown to import. + */ + UPROPERTY() + FString RundownData; +}; + +/** + * Exports a rundown to json data or file. + * This command is supported in game build. + */ +USTRUCT() +struct FAvaRundownExportRundown : public FAvaRundownMsgBase +{ + GENERATED_BODY() + +public: + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** Optional path to a server local file where the rundown will be saved. */ + UPROPERTY() + FString RundownFile; +}; + +/** + * Server reply to FAvaRundownExportRundown containing the exported rundown. + */ +USTRUCT() +struct FAvaRundownExportedRundown : public FAvaRundownMsgBase +{ + GENERATED_BODY() + +public: + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** Exported rundown in json format. */ + UPROPERTY() + FString RundownData; +}; + +/** + * Requests that the given rundown be saved to disk. + * The rundown asset must have been loaded, either by an edit command + * or playback, prior to this command. + * Unloaded assets will not be loaded by this command. + * This command is not supported in game builds. + */ +USTRUCT() +struct FAvaRundownSaveRundown : public FAvaRundownMsgBase +{ + GENERATED_BODY() + +public: + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** The save command will be executed only if the asset package is dirty. */ + UPROPERTY() + bool bOnlyIfIsDirty = false; +}; + +/** + * Rundown specific events broadcast by the server to help status display or related contexts in control applications. + */ +USTRUCT() +struct FAvaRundownPlaybackContextChanged : public FAvaRundownMsgBase +{ + GENERATED_BODY() + +public: + /** + * Previous rundown (can be empty). + */ + UPROPERTY() + FString PreviousRundown; + + /** + * New current rundown (can be empty). + */ + UPROPERTY() + FString NewRundown; +}; + +/** + * Requests the list of pages from the given rundown. + */ +USTRUCT() +struct FAvaRundownGetPages : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; +}; + +/** + * Defines the parameters for the page id generator algorithm. + * The Id generator uses a sequence strategy to search for an unused id. + * It is defined by a starting id and a search direction. + */ +USTRUCT() +struct FAvaRundownCreatePageIdGeneratorParams +{ + GENERATED_BODY() + + /** Starting Id for the search. */ + UPROPERTY() + int32 ReferenceId = FAvaRundownPage::InvalidPageId; + + /** + * @brief (Initial) Search increment. + * @remark For negative increment search, the limit of the search space can be reached. If no unique id is found, + * the search will continue in the positive direction instead. + */ + UPROPERTY() + int32 Increment = 1; +}; + +/** + * Requests a new page be created from the specified template in the given rundown. + */ +USTRUCT() +struct FAvaRundownCreatePage : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** Defines the parameters for the page id generator algorithm. */ + UPROPERTY() + FAvaRundownCreatePageIdGeneratorParams IdGeneratorParams; + + /** Specifies the template for the newly created page. */ + UPROPERTY() + int32 TemplateId = FAvaRundownPage::InvalidPageId; +}; + +/** + * Requests the page be deleted from the given rundown. + */ +USTRUCT() +struct FAvaRundownDeletePage : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** Id of the page to be deleted. */ + UPROPERTY() + int32 PageId = FAvaRundownPage::InvalidPageId; +}; + +/** + * Requests the creation of a new template. + * If successful, the response is FAvaRundownServerMsg with a "Template [Id] Created" text. + * The id of the created template can be parsed from that message's text. + * Also a secondary FAvaRundownPageListChanged event with added template id will be sent. + */ +USTRUCT() +struct FAvaRundownCreateTemplate : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** Defines the parameters for the page id generator algorithm. */ + UPROPERTY() + FAvaRundownCreatePageIdGeneratorParams IdGeneratorParams; + + /** Specifies the asset path to assign to the template. */ + UPROPERTY() + FString AssetPath; +}; + +/** + * Requests the creation of a new combo template. + * If successful, the response is FAvaRundownServerMsg with a "Template [Id] Created" text. + * The id of the created template can be parsed from that message's text. + * Also a secondary FAvaRundownPageListChanged event with added template id will be sent. + * + * @remark A combination template can only be created using transition logic templates that are in different transition layers. + */ +USTRUCT() +struct FAvaRundownCreateComboTemplate : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** Defines the parameters for the page id generator algorithm. */ + UPROPERTY() + FAvaRundownCreatePageIdGeneratorParams IdGeneratorParams; + + /** Specifies the template ids that are combined. */ + UPROPERTY() + TArray CombinedTemplateIds; +}; + +/** Requests deletion of the given template. */ +USTRUCT() +struct FAvaRundownDeleteTemplate : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** Specifies the *template* id to delete. */ + UPROPERTY() + int32 PageId = FAvaRundownPage::InvalidPageId; +}; + +/** + * Sets the Page's template asset. This applies to template pages only. + */ +USTRUCT() +struct FAvaRundownChangeTemplateBP : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** Specifies the template id to modify. */ + UPROPERTY() + int32 TemplateId = FAvaRundownPage::InvalidPageId; + + /** Specifies the asset path to assign. */ + UPROPERTY() + FString AssetPath; + + /** If true, the asset will be re-imported and the template information will be refresh from the source asset. */ + UPROPERTY() + bool bReimport = false; +}; + +/** Page Information */ +USTRUCT() +struct FAvaRundownPageInfo +{ + GENERATED_BODY() +public: + /** Unique identifier for the page within the rundown. */ + UPROPERTY() + int32 PageId = FAvaRundownPage::InvalidPageId; + + /** + * Short page name, usually the asset name for templates. + * It is displayed as the page description if there is no page summary or user friendly name specified. + */ + UPROPERTY() + FString PageName; + + /** + * Summary is generated from the remote control values for this page. + * It is displayed as the page description if there is no user friendly name specified. + */ + UPROPERTY() + FString PageSummary; + + /** User editable page description. If not empty, this should be used as the page description. */ + UPROPERTY() + FString FriendlyName; + + /** Indicates if the page is a template (true) or an instance (false). */ + UPROPERTY() + bool IsTemplate = false; + + /** Page Instance property: Template Id for this page. */ + UPROPERTY() + int32 TemplateId = FAvaRundownPage::InvalidPageId; + + /** Template property: For combination template, lists the templates that are combined. */ + UPROPERTY() + TArray CombinedTemplateIds; + + /** Template property: playable asset path for this template. */ + UPROPERTY() + FSoftObjectPath AssetPath; + + /** + * List of page channel statuses. + * There will be an entry for each channel the page is playing/previewing in. + */ + UPROPERTY() + TArray Statuses; + + /** Transition Layer Name (indicates the page has transition logic). */ + UPROPERTY() + FString TransitionLayerName; + + /** Indicate if the template asset has transition logic. */ + UPROPERTY() + bool bTransitionLogicEnabled = false; + + /** Page Commands that can be executed when playing this page. */ + UPROPERTY() + TArray Commands; + + UPROPERTY() + FString OutputChannel; + + /** Specifies if the page is enabled (i.e. can be played). */ + UPROPERTY() + bool bIsEnabled = false; + + /** + * Indicates if the page is currently playing in it's program channel. + */ + UPROPERTY() + bool bIsPlaying = false; +}; + +/* + * List of pages from the current rundown. + */ +USTRUCT() +struct FAvaRundownPages : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** List of page descriptors */ + UPROPERTY() + TArray Pages; +}; + +/** + * Requests the page details from the given rundown. + * Response is FAvaRundownPageDetails. + */ +USTRUCT() +struct FAvaRundownGetPageDetails : public FAvaRundownMsgBase +{ + GENERATED_BODY() + +public: + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** Specified the requested page id. */ + UPROPERTY() + int32 PageId = FAvaRundownPage::InvalidPageId; + + /** This will request that a managed asset instance gets loaded to be + * accessible through WebRC. */ + UPROPERTY() + bool bLoadRemoteControlPreset = false; +}; + +/** + * Server response to FAvaRundownGetPageDetails request. + */ +USTRUCT() +struct FAvaRundownPageDetails : public FAvaRundownMsgBase +{ + GENERATED_BODY() + +public: + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** Page Information. */ + UPROPERTY() + FAvaRundownPageInfo PageInfo; + + /** Remote Control Values for this page. */ + UPROPERTY() + FAvaPlayableRemoteControlValues RemoteControlValues; + + /** Name of the remote control preset to resolve through WebRC API. */ + UPROPERTY() + FString RemoteControlPresetName; + + /** Uuid of the remote control preset to resolve through WebRC API. */ + UPROPERTY() + FString RemoteControlPresetId; +}; + +/** + * Event sent when a page status changes. + */ +USTRUCT() +struct FAvaRundownPagesStatuses : public FAvaRundownMsgBase +{ + GENERATED_BODY() + +public: + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** Page Information. */ + UPROPERTY() + FAvaRundownPageInfo PageInfo; +}; + +/** + * Event sent when a page list (can be templates, pages or page views) has been modified. + */ +USTRUCT() +struct FAvaRundownPageListChanged : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** Specifies which page list has been modified. */ + UPROPERTY() + EAvaRundownPageListType ListType = EAvaRundownPageListType::Instance; + + /** Specifies the uuid of the page view, in case the event concerns a page view. */ + UPROPERTY() + FGuid SubListId; + + /** + * Bitfield value indicating what has changed: + * - bit 0: Added Pages + * - bit 1: Remove Pages + * - bit 2: Page Id Renumbered + * - bit 3: Sublist added or removed + * - bit 4: Sublist renamed + * - bit 5: Page View reordered + * + * See EAvaPageListChange flags. + */ + UPROPERTY() + uint8 ChangeType = 0; + + /** List of page Ids affected by this event. */ + UPROPERTY(); + TArray AffectedPages; +}; + +/** + * Event sent when a page's asset is modified. + * Note: this applies to templates only. + */ +USTRUCT() +struct FAvaRundownPageBlueprintChanged : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** Specified the modified page id. */ + UPROPERTY() + int32 PageId = FAvaRundownPage::InvalidPageId; + + /** Asset the page is currently assigned to (post modification). */ + UPROPERTY() + FString BlueprintPath; +}; + +/** + * Event sent when a page's channel is modified. + */ +USTRUCT() +struct FAvaRundownPageChannelChanged : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** Specified the modified page id. */ + UPROPERTY() + int32 PageId = FAvaRundownPage::InvalidPageId; + + /** Channel the page is currently assigned to (post modification). */ + UPROPERTY() + FString ChannelName; +}; + +/** + * Event sent when a page's name is modified. + */ +USTRUCT() +struct FAvaRundownPageNameChanged : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** Specified the modified page id. */ + UPROPERTY() + int32 PageId = FAvaRundownPage::InvalidPageId; + + /** new page name is currently assigned to (post modification). */ + UPROPERTY() + FString PageName; + + /** Indicate whether the name or friendly name that changed. */ + UPROPERTY() + bool bFriendlyName = true; +}; + +/** + * Event sent when a page's animation settings is modified. + */ +USTRUCT() +struct FAvaRundownPageAnimSettingsChanged : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** Specified the modified page id. */ + UPROPERTY() + int32 PageId = FAvaRundownPage::InvalidPageId; +}; + +/** + * Sets the channel of the given page. + * The page must be valid (and not a template) and the channel must exist in the current profile. + * Along with the corresponding response, this will also trigger a FAvaRundownPageChannelChanged event. + */ +USTRUCT() +struct FAvaRundownPageChangeChannel : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** Specifies the page that will be modified. */ + UPROPERTY() + int32 PageId = FAvaRundownPage::InvalidPageId; + + /** Specifies a valid channel to set for the specified page. */ + UPROPERTY() + FString ChannelName; +}; + +/** + * Sets page name. Works for template or instance pages. + * By default, the command will set the page's "friendly" name as it is the one used for + * display purposes. The page name is reserved for native code uses. + * Along with the corresponding response, this will also trigger a FAvaRundownPageNameChanged event. + */ +USTRUCT() +struct FAvaRundownChangePageName : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** Rundown asset path: [PackagePath]/[AssetName].[AssetName] */ + UPROPERTY() + FString Rundown; + + /** Specifies the page or template that will be modified. */ + UPROPERTY() + int32 PageId = FAvaRundownPage::InvalidPageId; + + /** Specifies the new page name. */ + UPROPERTY() + FString PageName; + + /** + * If true, the page's friendly name will be set. + * The page name is usually set by the native code. + * For display purposes, it is preferable to use the "friendly" name. + */ + UPROPERTY() + bool bSetFriendlyName = true; +}; + + +/** This is a request to save the managed Remote Control Preset (RCP) back to the corresponding page values. */ +USTRUCT() +struct FAvaRundownUpdatePageFromRCP : public FAvaRundownMsgBase +{ + GENERATED_BODY() + +public: + /** Unregister the Remote Control Preset from the WebRC. */ + UPROPERTY() + bool bUnregister = false; +}; + +/** Supported Page actions for playback. */ +UENUM() +enum class EAvaRundownPageActions +{ + None UMETA(Hidden), + Load, + Unload, + Play, + PlayNext, + Stop, + ForceStop, + Continue, + UpdateValues, + TakeToProgram +}; + +/** + * Request for a program page command on the current playback rundown. + */ +USTRUCT() +struct FAvaRundownPageAction : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Specifies the Page Id that is the target of this action command. */ + UPROPERTY() + int32 PageId = FAvaRundownPage::InvalidPageId; + + /** Specifies the page action to execute. */ + UPROPERTY() + EAvaRundownPageActions Action = EAvaRundownPageActions::None; +}; + +/** + * Request for a preview page command on the current playback rundown. + */ +USTRUCT() +struct FAvaRundownPagePreviewAction : public FAvaRundownPageAction +{ + GENERATED_BODY() +public: + /** Specifies which preview channel to use. If left empty, the rundown's default preview channel is used. */ + UPROPERTY() + FString PreviewChannelName; +}; + +/** + * Command to execute a program action on multiple pages at the same time. + * This is necessary for pages to be part of the same transition. + */ +USTRUCT() +struct FAvaRundownPageActions : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Specifies a list of page Ids that are the target of this action command. */ + UPROPERTY() + TArray PageIds; + + /** Specifies the page action to execute. */ + UPROPERTY() + EAvaRundownPageActions Action = EAvaRundownPageActions::None; +}; + +/** + * Command to execute a preview action on multiple pages at the same time. + * This is necessary for pages to be part of the same transition. + */ +USTRUCT() +struct FAvaRundownPagePreviewActions : public FAvaRundownPageActions +{ + GENERATED_BODY() +public: + /** Specifies which preview channel to use. If left empty, the rundown's default preview channel is used. */ + UPROPERTY() + FString PreviewChannelName; +}; + +/** Supported Transition actions for playback. */ +UENUM() +enum class EAvaRundownTransitionActions +{ + None UMETA(Hidden), + /** This action will forcefully stop specified transitions. */ + ForceStop +}; + +/** + * Command to override transition logic directly. + * As it currently stands, we can only have 1 transition per channel. + * If there is an issue with it, it may block further playback. + */ +USTRUCT() +struct FAvaRundownTransitionAction : public FAvaRundownMsgBase +{ + GENERATED_BODY() + +public: + /** Specifies the channel that is the target of this action command. */ + UPROPERTY() + FString ChannelName; + + /** Specifies the page transition action to execute. */ + UPROPERTY() + EAvaRundownTransitionActions Action = EAvaRundownTransitionActions::None; +}; + +/** Supported Page Logic Layer actions for playback. */ +UENUM() +enum class EAvaRundownTransitionLayerActions +{ + None UMETA(Hidden), + /** Trigger the out transition for the specified layer. */ + Stop, + /** Forcefully stop, without transition, pages on the specified layer. */ + ForceStop +}; + +/** + * Command to override transition logic. + */ +USTRUCT() +struct FAvaRundownTransitionLayerAction : public FAvaRundownMsgBase +{ + GENERATED_BODY() + +public: + /** Specifies the channel that is the target of this action command. */ + UPROPERTY() + FString ChannelName; + + /** Specifies the transition logic layers for this action command. */ + UPROPERTY() + TArray LayerNames; + + /** Specifies the page layer action to execute. */ + UPROPERTY() + EAvaRundownTransitionLayerActions Action = EAvaRundownTransitionLayerActions::None; +}; + +/** + * This message is sent by the server when a page sequence event occurs. + */ +USTRUCT() +struct FAvaRundownPageSequenceEvent : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** Specifies the broadcast channel the event occurred in. */ + UPROPERTY() + FString Channel; + + /** Page Id associated with this event. */ + UPROPERTY() + int32 PageId = FAvaRundownPage::InvalidPageId; + + /** Playable Instance uuid. */ + UPROPERTY() + FGuid InstanceId; + + /** Full asset path: /PackagePath/PackageName.AssetName */ + UPROPERTY() + FString AssetPath; + + /** Specifies the label used to identify the sequence. */ + UPROPERTY() + FString SequenceLabel; + + /** Started, Paused, Finished */ + UPROPERTY() + EAvaPlayableSequenceEventType Event = EAvaPlayableSequenceEventType::None; +}; + +UENUM() +enum class EAvaRundownPageTransitionEvents +{ + None UMETA(Hidden), + Started, + Finished +}; + +/** + * This message is sent by the server when a page transition event occurs. + */ +USTRUCT() +struct FAvaRundownPageTransitionEvent : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** Specifies the broadcast channel the event occurred in. */ + UPROPERTY() + FString Channel; + + /** UUID of the transition. */ + UPROPERTY() + FGuid TransitionId; + + /** Pages that are entering the scene during this transition. */ + UPROPERTY() + TArray EnteringPageIds; + + /** Pages that are already in the scene. May get kicked out or change during this transition. */ + UPROPERTY() + TArray PlayingPageIds; + + /** Pages that are requested to exit the scene during this transition. Typically part of a "Take Out" transition. */ + UPROPERTY() + TArray ExitingPageIds; + + /** Started, Finished */ + UPROPERTY() + EAvaRundownPageTransitionEvents Event = EAvaRundownPageTransitionEvents::None; +}; + +/** + * Requests a list of all profiles loaded for the current broadcast configuration. + * Response is FAvaRundownProfiles. + */ +USTRUCT() +struct FAvaRundownGetProfiles : public FAvaRundownMsgBase +{ + GENERATED_BODY() +}; + +/** + * Response to FAvaRundownGetProfiles. + * Contains the list of all profiles in the broadcast configuration. + */ +USTRUCT() +struct FAvaRundownProfiles : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** List of all profiles. */ + UPROPERTY() + TArray Profiles; + + /** Current Active Profile. */ + UPROPERTY() + FString CurrentProfile; +}; + +/** + * Creates a new empty profile with the given name. + * Fails if the profile already exist. + */ +USTRUCT() +struct FAvaRundownCreateProfile : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Name to be given to the newly created profile. */ + UPROPERTY() + FString ProfileName; + + /** + * If true the created profile is also made "current". + * Equivalent to FAvaRundownSetCurrentProfile. + */ + UPROPERTY() + bool bMakeCurrent = true; +}; + +/** + * Duplicates an existing profile. + * Fails if the new profile name already exist. + * Fails if the source profile does not exist. + */ +USTRUCT() +struct FAvaRundownDuplicateProfile : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Specifies the existing profile to be duplicated. */ + UPROPERTY() + FString SourceProfileName; + + /** Specifies the name of the new profile to be created. */ + UPROPERTY() + FString NewProfileName; + + /** + * If true the created profile is also made "current". + * Equivalent to FAvaRundownSetCurrentProfile. + */ + UPROPERTY() + bool bMakeCurrent = true; +}; + +/** + * Renames an existing profile. + */ +USTRUCT() +struct FAvaRundownRenameProfile : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Specifies the name of the existing profile to be renamed. */ + UPROPERTY() + FString OldProfileName; + + /** Specifies the new name. */ + UPROPERTY() + FString NewProfileName; +}; + +/** + * Deletes the specified profile. + * Fails if profile to be deleted is the current profile. + */ +USTRUCT() +struct FAvaRundownDeleteProfile : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Specifies the target profile. */ + UPROPERTY() + FString ProfileName; +}; + +/** + * Specified profile is made "current". + * The current profile becomes the context for all other broadcasts commands. + * Fails if some channels are currently broadcasting. + */ +USTRUCT() +struct FAvaRundownSetCurrentProfile : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Specifies the requested profile. */ + UPROPERTY() + FString ProfileName; +}; + +/** + * Output Device information + */ +USTRUCT() +struct FAvaRundownOutputDeviceItem +{ + GENERATED_BODY() +public: + /** + * Specifies the device name. + * This is used as "MediaOutputName" in FAvaRundownAddChannelDevice and FAvaRundownEditChannelDevice. + */ + UPROPERTY() + FString Name; + + /** Extra information about the device. */ + UPROPERTY() + FAvaBroadcastMediaOutputInfo OutputInfo; + + /** Specifies the status of the output device. */ + UPROPERTY() + EAvaBroadcastOutputState OutputState = EAvaBroadcastOutputState::Invalid; + + /** In case the device is live, this extra status indicates if the device is operating normally. */ + UPROPERTY() + EAvaBroadcastIssueSeverity IssueSeverity = EAvaBroadcastIssueSeverity::None; + + /** List of errors or warnings. */ + UPROPERTY() + TArray IssueMessages; + + /** + * Raw Json string representing a serialized UMediaOutput. + * This data can be edited, then used in FAvaRundownEditChannelDevice. + */ + UPROPERTY() + FString Data; +}; + +/** + * Output Device Class Information + */ +USTRUCT() +struct FAvaRundownOutputClassItem +{ + GENERATED_BODY() +public: + /** Class name */ + UPROPERTY() + FString Name; + + /** + * Name of the playback server this class was seen on. + * The name will be empty for the "local process" device. + */ + UPROPERTY() + FString Server; + + /** + * Enumeration of the available devices of this class on the given host. + * Note that not all classes can be enumerated. + */ + UPROPERTY() + TArray Devices; +}; + +/** + * Response to FAvaRundownGetDevices. + */ +USTRUCT() +struct FAvaRundownDevicesList : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** + * List of Output Device Classes + */ + UPROPERTY() + TArray DeviceClasses; +}; + +/** + * Requests information (devices, status, etc) on a specified channel. + * + * Response is FAvaRundownChannelResponse. + */ +USTRUCT() +struct FAvaRundownGetChannel : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Specifies the requested channel. */ + UPROPERTY() + FString ChannelName; +}; + +/** + * Requests information (devices, status, etc) on all channels of the current profile. + * + * Response is FAvaRundownChannels. + */ +USTRUCT() +struct FAvaRundownGetChannels : public FAvaRundownMsgBase +{ + GENERATED_BODY() +}; + +/** Channel Information */ +USTRUCT() +struct FAvaRundownChannel +{ + GENERATED_BODY() +public: + /** Specifies the Channel Name. */ + UPROPERTY() + FString Name; + + UPROPERTY() + EAvaBroadcastChannelType Type = EAvaBroadcastChannelType::Program; + + UPROPERTY() + EAvaBroadcastChannelState State = EAvaBroadcastChannelState::Offline; + + UPROPERTY() + EAvaBroadcastIssueSeverity IssueSeverity = EAvaBroadcastIssueSeverity::None; + + /** List of devices. */ + UPROPERTY() + TArray Devices; +}; + +/** + * This message is sent by the server if the list of channels is modified + * in the current profile. Channel added, removed, pinned or type (preview vs program) changed. + */ +USTRUCT() +struct FAvaRundownChannelListChanged : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** List of channel information. */ + UPROPERTY() + TArray Channels; +}; + +/** + * This message is sent by the server in response to FAvaRundownGetChannel or + * as an event if a channel's states, render target, devices or settings is changed. + */ +USTRUCT() +struct FAvaRundownChannelResponse : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Channel Information */ + UPROPERTY() + FAvaRundownChannel Channel; +}; + +/** Response to FAvaRundownGetChannels */ +USTRUCT() +struct FAvaRundownChannels : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** List of channel information. */ + UPROPERTY() + TArray Channels; +}; + +/** + * Generic asset event + */ +UENUM() +enum class EAvaRundownAssetEvent : uint8 +{ + Unknown = 0 UMETA(Hidden), + Added, + Removed, + //Saved, // todo + //Modified // todo +}; + +/** + * Event broadcast when an asset event occurs on the server. + */ +USTRUCT() +struct FAvaRundownAssetsChanged : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** Asset name only, without the package path. (Keeping for legacy) */ + UPROPERTY() + FString AssetName; + + /** Full asset path: /PackagePath/PackageName.AssetName */ + UPROPERTY() + FString AssetPath; + + /** Full asset class path. */ + UPROPERTY() + FString AssetClass; + + /** true if the asset is a "playable" asset, i.e. an asset that can be set in a page's asset. */ + UPROPERTY() + bool bIsPlayable = false; + + /** Specifies the event type, i.e. Added, Remove, etc. */ + UPROPERTY() + EAvaRundownAssetEvent EventType = EAvaRundownAssetEvent::Unknown; +}; + +/** + * Channel broadcast actions + */ +UENUM() +enum class EAvaRundownChannelActions +{ + None UMETA(Hidden), + /** Start broadcast of the specified channel(s). */ + Start, + /** Stops broadcast of the specified channel(s). */ + Stop +}; + +/** + * Requests a broadcast action on the specified channel(s). + */ +USTRUCT() +struct FAvaRundownChannelAction : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** + * Specifies the target channel for the action. + * If left empty, the action will apply to all channels of the current profile. + */ + UPROPERTY() + FString ChannelName; + + /** Specifies the broadcast action to perform on the target channel(s). */ + UPROPERTY() + EAvaRundownChannelActions Action = EAvaRundownChannelActions::None; +}; + +UENUM() +enum class EAvaRundownChannelEditActions +{ + None UMETA(Hidden), + /** Add new channel with given name. */ + Add, + /** Removes channel with given name. */ + Remove +}; + +/** Requests an edit action on the specified channel. */ +USTRUCT() +struct FAvaRundownChannelEditAction : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Specifies the target channel for the action. */ + UPROPERTY() + FString ChannelName; + + /** Specifies the edit action to perform on the target channel. */ + UPROPERTY() + EAvaRundownChannelEditActions Action = EAvaRundownChannelEditActions::None; +}; + +/** Requests a channel to be renamed. */ +USTRUCT() +struct FAvaRundownRenameChannel : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Existing channel to be renamed. */ + UPROPERTY() + FString OldChannelName; + + /** Specifies the new channel name. */ + UPROPERTY() + FString NewChannelName; +}; + +/** + * Requests a list of devices from the rundown server. + * The server will reply with FAvaRundownDevicesList containing + * the devices that can be enumerated from the local host and all connected hosts + * through the motion design playback service. + */ +USTRUCT() +struct FAvaRundownGetDevices : public FAvaRundownMsgBase +{ + GENERATED_BODY() + + /** + * If true, listing all media output classes on the server, even if they don't have a device provider. + */ + UPROPERTY() + bool bShowAllMediaOutputClasses = false; +}; + +/** + * Add an enumerated device to the given channel. + * This command will fail if the channel is live. + */ +USTRUCT() +struct FAvaRundownAddChannelDevice : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Specifies the target channel. */ + UPROPERTY() + FString ChannelName; + + /** + * The specified name is one of the enumerated device from FAvaRundownDevicesList, + * FAvaRundownOutputDeviceItem::Name. + */ + UPROPERTY() + FString MediaOutputName; + + /** Save broadcast configuration after this operation (true by default). */ + UPROPERTY() + bool bSaveBroadcast = true; +}; + +/** + * Modify an existing device in the given channel. + * This command will fail if the channel is live. + */ +USTRUCT() +struct FAvaRundownEditChannelDevice : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Specifies the target channel. */ + UPROPERTY() + FString ChannelName; + + /** + * The specified name is one of the enumerated device from FAvaRundownChannel::Devices, + * FAvaRundownOutputDeviceItem::Name field. + * Must be the instanced devices from either FAvaRundownChannels, FAvaRundownChannelResponse + * or FAvaRundownChannelListChanged. These names are not the same as when adding a device. + */ + UPROPERTY() + FString MediaOutputName; + + /** + * (Modified) Device Data in the same format as FAvaRundownOutputDeviceItem::Data. + * See: FAvaRundownChannel, FAvaRundownDevicesList + */ + UPROPERTY() + FString Data; + + /** Save broadcast configuration after this operation (true by default). */ + UPROPERTY() + bool bSaveBroadcast = true; +}; + +/** + * Remove an existing device from the given channel. + * This command will fail if the channel is live. + */ +USTRUCT() +struct FAvaRundownRemoveChannelDevice : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Specifies the target channel. */ + UPROPERTY() + FString ChannelName; + + /** + * The specified name is one of the enumerated device from FAvaRundownChannel::Devices, + * FAvaRundownOutputDeviceItem::Name field. + * Must be the instanced devices from either FAvaRundownChannels, FAvaRundownChannelResponse + * or FAvaRundownChannelListChanged. These names are not the same as when adding a device. + */ + UPROPERTY() + FString MediaOutputName; + + /** Save broadcast configuration after this operation (true by default). */ + UPROPERTY() + bool bSaveBroadcast = true; +}; + +/** + * Captures an image from the specified channel. + * The captured image is 25% of the channel's resolution. + * Intended for preview. + * Response is FAvaRundownChannelImage. + */ +USTRUCT() +struct FAvaRundownGetChannelImage : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Specifies the target channel. */ + UPROPERTY() + FString ChannelName; +}; + +/** + * Response to FAvaRundownGetChannelImage. + */ +USTRUCT() +struct FAvaRundownChannelImage : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** + * Byte array containing the image data. + * Expected format is compressed jpeg. + */ + UPROPERTY() + TArray ImageData; +}; + +/** + * Queries the given channel's quality settings. + * Response is FAvaRundownChannelQualitySettings. + */ +USTRUCT() +struct FAvaRundownGetChannelQualitySettings : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Specifies the target channel. */ + UPROPERTY() + FString ChannelName; +}; + +/** Response to FAvaRundownGetChannelQualitySettings. */ +USTRUCT() +struct FAvaRundownChannelQualitySettings : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Specifies the target channel. */ + UPROPERTY() + FString ChannelName; + + /** Advanced viewport client engine features indexed by FEngineShowFlags names. */ + UPROPERTY() + TArray Features; +}; + +/** Sets the given channel's quality settings. */ +USTRUCT() +struct FAvaRundownSetChannelQualitySettings : public FAvaRundownMsgBase +{ + GENERATED_BODY() +public: + /** Specifies the target channel. */ + UPROPERTY() + FString ChannelName; + + /** Advanced viewport client engine features indexed by FEngineShowFlags names. */ + UPROPERTY() + TArray Features; +}; + +/** + * Save current broadcast configuration to a json file in the Config folder on the server. + */ +USTRUCT() +struct FAvaRundownSaveBroadcast : public FAvaRundownMsgBase +{ + GENERATED_BODY() +}; \ No newline at end of file diff --git a/src/app/dev-tools.ts b/src/app/dev-tools.ts new file mode 100644 index 0000000..e58cd61 --- /dev/null +++ b/src/app/dev-tools.ts @@ -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 { + 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 { + 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'); +} diff --git a/src/app/models/app-config.model.ts b/src/app/models/app-config.model.ts new file mode 100644 index 0000000..237c9c9 --- /dev/null +++ b/src/app/models/app-config.model.ts @@ -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 + } + }; +} diff --git a/src/app/models/title-sheet.model.ts b/src/app/models/title-sheet.model.ts new file mode 100644 index 0000000..f5ed843 --- /dev/null +++ b/src/app/models/title-sheet.model.ts @@ -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, + } +}; diff --git a/src/app/services/config.service.ts b/src/app/services/config.service.ts new file mode 100644 index 0000000..4c80e70 --- /dev/null +++ b/src/app/services/config.service.ts @@ -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(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): 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 { + 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/src/app/services/title-sheet.service.ts b/src/app/services/title-sheet.service.ts new file mode 100644 index 0000000..8bd73fa --- /dev/null +++ b/src/app/services/title-sheet.service.ts @@ -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([]); + 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); + } +} diff --git a/src/app/services/toast.service.ts b/src/app/services/toast.service.ts new file mode 100644 index 0000000..67a71d3 --- /dev/null +++ b/src/app/services/toast.service.ts @@ -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([]); + 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 ? `Details: ${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); + } +} diff --git a/src/app/services/utils/cbor.service.ts.deleted b/src/app/services/utils/cbor.service.ts.deleted new file mode 100644 index 0000000..7d6d375 --- /dev/null +++ b/src/app/services/utils/cbor.service.ts.deleted @@ -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 { + 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 { + 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' + ] + }; + } +} diff --git a/src/app/view/components/big-card/big-card.component.html b/src/app/view/components/big-card/big-card.component.html new file mode 100644 index 0000000..0b0f4f6 --- /dev/null +++ b/src/app/view/components/big-card/big-card.component.html @@ -0,0 +1,9 @@ + +
+ + +
+ +
+ +
diff --git a/src/app/view/components/big-card/big-card.component.scss b/src/app/view/components/big-card/big-card.component.scss new file mode 100644 index 0000000..6490611 --- /dev/null +++ b/src/app/view/components/big-card/big-card.component.scss @@ -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; + } + } +} diff --git a/src/app/view/components/big-card/big-card.component.ts b/src/app/view/components/big-card/big-card.component.ts new file mode 100644 index 0000000..35136ca --- /dev/null +++ b/src/app/view/components/big-card/big-card.component.ts @@ -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(true); + +} diff --git a/src/app/view/components/cued-title/cued-title.component.html b/src/app/view/components/cued-title/cued-title.component.html new file mode 100644 index 0000000..1ecf061 --- /dev/null +++ b/src/app/view/components/cued-title/cued-title.component.html @@ -0,0 +1,58 @@ + +
+
+ + +
+
+ {{ statusClass() | titlecase }} +
+ + + + @if (hasCurrentTitle()) { +
+
+

+ {{ displayedTitle()?.FirstName }} + {{ displayedTitle()?.LastName }} +

+

+ {{ displayedTitle()?.Function1 }} + @if (displayedTitle()?.Function2) { + + • {{ displayedTitle()?.Function2 }} + + } +

+
+ + +
+ + {{ formattedTimer() }} + + @if (isPlaying()) { + Playing + } @else { + Stopped + } +
+
+ } @else { +
+ + No title currently cued +
+ } + + + @if (unrealStatus()?.errorMessage) { +
+ + {{ unrealStatus()?.errorMessage }} +
+ } + +
+
diff --git a/src/app/view/components/cued-title/cued-title.component.scss b/src/app/view/components/cued-title/cued-title.component.scss new file mode 100644 index 0000000..9e23009 --- /dev/null +++ b/src/app/view/components/cued-title/cued-title.component.scss @@ -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; + } + } diff --git a/src/app/view/components/cued-title/cued-title.component.ts b/src/app/view/components/cued-title/cued-title.component.ts new file mode 100644 index 0000000..83376ec --- /dev/null +++ b/src/app/view/components/cued-title/cued-title.component.ts @@ -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(null); + + // === INTERNAL SIGNALS === + private playStartTime = signal(null); + private elapsedSeconds = signal(0); + private intervalId : any = signal(null); + + // Keep track of the last attempted title (even if it failed) + private lastAttemptedTitle = signal(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); + } +} diff --git a/src/app/view/components/details-panel/details-panel.component.html b/src/app/view/components/details-panel/details-panel.component.html new file mode 100644 index 0000000..6d04027 --- /dev/null +++ b/src/app/view/components/details-panel/details-panel.component.html @@ -0,0 +1,143 @@ + +
+ + @if (hasSelection()) { +
+ + +
+
+

{{ fullName() }}

+
+ + {{ statusInfo()?.status }} +
+
+
+ ID: + {{ formattedId() }} +
+
+ + +
+

+ + Functions +

+
+
+ +
+ {{ selectedTitleSheet()?.Function1 || 'Not specified' }} +
+
+
+ +
+ {{ selectedTitleSheet()?.Function2 || 'Not specified' }} +
+
+
+
+ + +
+

+ + Statistics +

+
+
+ Completeness +
+ {{ fieldsInfo()?.completeness }}% +
+
+
+
+
+
+ Total Characters + {{ fieldsInfo()?.totalChars }} +
+
+ Filled Fields + {{ fieldsInfo()?.filledFields }}/4 +
+
+ Empty Fields + {{ fieldsInfo()?.emptyFields }} +
+
+
+ + +
+

+ + Timeline +

+
+
+ +
+ Created + {{ createdDateFormatted() }} +
+
+
+ +
+ Last Modified + {{ modifiedDateFormatted() }} + ({{ timeSinceModified() }}) +
+
+
+
+ + +
+

+ + Field Details +

+
+ @for (field of fieldsInfo()?.fields; track field.label) { +
+
+ {{ field.label }} + @if (field.value) { + {{ field.length }} chars + } @else { + Empty + } +
+
+ @if (field.value) { + {{ field.value }} + } @else { + No value set + } +
+
+ } +
+
+ +
+ } @else { + +
+
+ +

No Selection

+

+ Select a title sheet from the list to view detailed information here. +

+
+
+ } + +
diff --git a/src/app/view/components/details-panel/details-panel.component.scss b/src/app/view/components/details-panel/details-panel.component.scss new file mode 100644 index 0000000..a804d58 --- /dev/null +++ b/src/app/view/components/details-panel/details-panel.component.scss @@ -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; + } +} diff --git a/src/app/view/components/details-panel/details-panel.component.ts b/src/app/view/components/details-panel/details-panel.component.ts new file mode 100644 index 0000000..e8e69f3 --- /dev/null +++ b/src/app/view/components/details-panel/details-panel.component.ts @@ -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(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) + }; + }); +} diff --git a/src/app/view/components/list-header/list-header.component.html b/src/app/view/components/list-header/list-header.component.html new file mode 100644 index 0000000..0be3be0 --- /dev/null +++ b/src/app/view/components/list-header/list-header.component.html @@ -0,0 +1,38 @@ + +
+ + +
+ Actions +
+ + +
+ Last Name + +
+ + +
+ First Name + +
+ + +
+ Function 1 + +
+ + +
+ Function 2 + +
+ + +
+ Controls +
+ +
diff --git a/src/app/view/components/list-header/list-header.component.scss b/src/app/view/components/list-header/list-header.component.scss new file mode 100644 index 0000000..ef22bbd --- /dev/null +++ b/src/app/view/components/list-header/list-header.component.scss @@ -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; + } +} diff --git a/src/app/view/components/list-header/list-header.component.ts b/src/app/view/components/list-header/list-header.component.ts new file mode 100644 index 0000000..39aad17 --- /dev/null +++ b/src/app/view/components/list-header/list-header.component.ts @@ -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({ field: 'LastName', direction: 'asc' }); + + // === OUTPUT SIGNALS === + sortChange = output(); + + // === 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; + } +} diff --git a/src/app/view/components/list-item/list-item.component.html b/src/app/view/components/list-item/list-item.component.html new file mode 100644 index 0000000..5cff66a --- /dev/null +++ b/src/app/view/components/list-item/list-item.component.html @@ -0,0 +1,131 @@ + +
+ + +
+ +
+ + +
+ @if (isFieldEditing('LastName')) { + + } @else { + + {{ titleSheet().LastName || 'No last name' }} + + } +
+ + +
+ @if (isFieldEditing('FirstName')) { + + } @else { + + {{ titleSheet().FirstName || 'No first name' }} + + } +
+ + +
+ @if (isFieldEditing('Function1')) { + + } @else { + + {{ titleSheet().Function1 || 'No function' }} + + } +
+ + +
+ @if (isFieldEditing('Function2')) { + + } @else { + + {{ titleSheet().Function2 || 'No secondary function' }} + + } +
+ + +
+
+ + + @if (!isPlaying()) { + + } @else { + + } + + + + +
+
+ +
diff --git a/src/app/view/components/list-item/list-item.component.scss b/src/app/view/components/list-item/list-item.component.scss new file mode 100644 index 0000000..93c9b5c --- /dev/null +++ b/src/app/view/components/list-item/list-item.component.scss @@ -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; + } +} diff --git a/src/app/view/components/list-item/list-item.component.ts b/src/app/view/components/list-item/list-item.component.ts new file mode 100644 index 0000000..302a9d0 --- /dev/null +++ b/src/app/view/components/list-item/list-item.component.ts @@ -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(); + isSelected = input(false); + isPlaying = input(false); + + // === OUTPUT SIGNALS === + select = output(); + play = output(); + stop = output(); + edit = output(); + delete = output(); + + // === INTERNAL SIGNALS === + isEditing = signal(false); + editingField = signal(null); + editedValues = signal>({}); + + // === TEMPLATE REFERENCES === + @ViewChild('editInput') editInput!: ElementRef; + + 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; + } +} diff --git a/src/app/view/components/menu-bar/menu-bar.component.html b/src/app/view/components/menu-bar/menu-bar.component.html new file mode 100644 index 0000000..bed4172 --- /dev/null +++ b/src/app/view/components/menu-bar/menu-bar.component.html @@ -0,0 +1,53 @@ + diff --git a/src/app/view/components/menu-bar/menu-bar.component.scss b/src/app/view/components/menu-bar/menu-bar.component.scss new file mode 100644 index 0000000..95af8b9 --- /dev/null +++ b/src/app/view/components/menu-bar/menu-bar.component.scss @@ -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; + } + } diff --git a/src/app/view/components/menu-bar/menu-bar.component.ts b/src/app/view/components/menu-bar/menu-bar.component.ts new file mode 100644 index 0000000..4b3a0b6 --- /dev/null +++ b/src/app/view/components/menu-bar/menu-bar.component.ts @@ -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(); + configSave = output(); +} diff --git a/src/app/view/components/motion-design-monitor/motion-design-monitor.component.html b/src/app/view/components/motion-design-monitor/motion-design-monitor.component.html new file mode 100644 index 0000000..b480df7 --- /dev/null +++ b/src/app/view/components/motion-design-monitor/motion-design-monitor.component.html @@ -0,0 +1,157 @@ + +
+
+

+ + Motion Design Monitor +

+
+ + {{ status()?.isConnected ? 'Connected' : 'Disconnected' }} +
+
+ +
+ + + +
+ + + @if (status(); as currentStatus) { +
+
+ + + {{ currentStatus.isConnected ? 'Yes' : 'No' }} + +
+ + @if (currentStatus.isConnected) { +
+ + + {{ currentStatus.isHandshakeComplete ? 'Yes' : 'Pending' }} + +
+ } + + @if (currentStatus.lastMessageSent) { +
+ + {{ currentStatus.lastMessageSent | date:'medium' }} +
+ } + + @if (currentStatus.lastMessageReceived) { +
+ + {{ currentStatus.lastMessageReceived | date:'medium' }} +
+ } +
+ } + + + @if (state(); as currentState) { +
+

Available Rundowns ({{ currentState.availableRundowns.length }})

+ + @if (currentState.availableRundowns.length > 0) { +
+ @for (rundown of currentState.availableRundowns; track trackRundown($index, rundown)) { +
+
+ {{ rundown }} + @if (isCurrentRundown(rundown)) { + Current + } + @if (isConfiguredRundown(rundown)) { + Configured + } +
+
+ +
+
+ } +
+ } @else { +
+ + No rundowns available. Make sure Motion Design is connected and try refreshing. +
+ } +
+ + + @if (currentState.currentRundown && currentState.currentPages.length > 0) { +
+

Pages in "{{ currentState.currentRundown }}" ({{ currentState.currentPages.length }})

+ +
+ @for (page of currentState.currentPages; track trackPage($index, page)) { +
+
+ {{ page.PageId }} + {{ page.FriendlyName || page.PageName }} + @if (page.bIsPlaying) { + Playing + } + @if (!page.bIsEnabled) { + Disabled + } +
+
+ @if (!page.bIsPlaying) { + + } @else { + + } +
+
+ } +
+
+ } + } + + + @if (isDevelopment()) { +
+

Debug Information

+
{{ getDebugInfo() | json }}
+
+ } +
diff --git a/src/app/view/components/motion-design-monitor/motion-design-monitor.component.scss b/src/app/view/components/motion-design-monitor/motion-design-monitor.component.scss new file mode 100644 index 0000000..1a7514b --- /dev/null +++ b/src/app/view/components/motion-design-monitor/motion-design-monitor.component.scss @@ -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; + } + } + } +} diff --git a/src/app/view/components/motion-design-monitor/motion-design-monitor.component.ts b/src/app/view/components/motion-design-monitor/motion-design-monitor.component.ts new file mode 100644 index 0000000..74e2835 --- /dev/null +++ b/src/app/view/components/motion-design-monitor/motion-design-monitor.component.ts @@ -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(null); + state = signal(null); + config = signal(null); + isConnecting = signal(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 { + this.isConnecting.set(true); + try { + await this.motionDesignService.testConnectivity(); + } finally { + this.isConnecting.set(false); + } + } + + async refreshRundowns(): Promise { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/app/view/components/title-dialog/title-dialog.component.html b/src/app/view/components/title-dialog/title-dialog.component.html new file mode 100644 index 0000000..e4d5fe9 --- /dev/null +++ b/src/app/view/components/title-dialog/title-dialog.component.html @@ -0,0 +1,175 @@ + + + +
+ + +
+

+ + {{ dialogTitle }} +

+ +
+ + +
+ +
+ + +
+ + +
+ + + @if (hasFieldError('FirstName')) { + + + {{ getErrorMessage('FirstName') }} + + } +
+ + +
+ + + @if (hasFieldError('LastName')) { + + + {{ getErrorMessage('LastName') }} + + } +
+ +
+ + +
+ + + @if (hasFieldError('Function1')) { + + + {{ getErrorMessage('Function1') }} + + } +
+ +
+ + + @if (hasFieldError('Function2')) { + + + {{ getErrorMessage('Function2') }} + + } +
+ +
+ +
+ + +
+ + +
+ @if (mode() === 'edit') { + + + Editing existing title sheet + + } @else { + + + Fields marked with * are required + + } +
+ + +
+ + +
+ +
+ +
+ +
diff --git a/src/app/view/components/title-dialog/title-dialog.component.scss b/src/app/view/components/title-dialog/title-dialog.component.scss new file mode 100644 index 0000000..ed49cfd --- /dev/null +++ b/src/app/view/components/title-dialog/title-dialog.component.scss @@ -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; + } +} diff --git a/src/app/view/components/title-dialog/title-dialog.component.ts b/src/app/view/components/title-dialog/title-dialog.component.ts new file mode 100644 index 0000000..01fad8d --- /dev/null +++ b/src/app/view/components/title-dialog/title-dialog.component.ts @@ -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(false); + titleSheet = input(null); + mode = input<'add' | 'edit'>('add'); + + // === OUTPUT SIGNALS === + close = output(); + save = output(); + + // === INTERNAL SIGNALS === + private form = signal(null); + isSubmitting = signal(false); + + // === TEMPLATE REFERENCES === + @ViewChild('dialogElement') dialogElement!: ElementRef; + @ViewChild('firstNameInput') firstNameInput!: ElementRef; + + 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 = { + 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(); + } +} diff --git a/src/app/view/components/title-sheets-form/title-sheets-form.component.html b/src/app/view/components/title-sheets-form/title-sheets-form.component.html new file mode 100644 index 0000000..8799707 --- /dev/null +++ b/src/app/view/components/title-sheets-form/title-sheets-form.component.html @@ -0,0 +1,84 @@ + + + + + {{ isEditing ? 'edit' : 'add' }} + {{ isEditing ? 'Edit Title Sheet' : 'New Title Sheet' }} + + + + +
+ + +
+ + Last Name + + person + + {{ getErrorMessage('LastName') }} + + + + + First Name + + badge + + {{ getErrorMessage('FirstName') }} + + +
+ + +
+ + Function 1 + + work + + {{ getErrorMessage('Function1') }} + + + + + Function 2 + + work_outline + +
+ + +
+ + + +
+ +
+
+
diff --git a/src/app/view/components/title-sheets-form/title-sheets-form.component.scss b/src/app/view/components/title-sheets-form/title-sheets-form.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/view/components/title-sheets-form/title-sheets-form.component.ts b/src/app/view/components/title-sheets-form/title-sheets-form.component.ts new file mode 100644 index 0000000..1a4e595 --- /dev/null +++ b/src/app/view/components/title-sheets-form/title-sheets-form.component.ts @@ -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(); + @Output() formReset = new EventEmitter(); + + // 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; + } +} diff --git a/src/app/view/components/title-sheets-list/title-sheets-list.component.html b/src/app/view/components/title-sheets-list/title-sheets-list.component.html new file mode 100644 index 0000000..60aa013 --- /dev/null +++ b/src/app/view/components/title-sheets-list/title-sheets-list.component.html @@ -0,0 +1,111 @@ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + + + + +
+ @if (filteredAndSortedTitleSheets().length > 0) { + + + @for (titleSheet of filteredAndSortedTitleSheets(); track trackByTitleSheetId($index, titleSheet)) { + + + } + + } @else { + + +
+
+ @if (searchQuery()) { + + +

No Results Found

+

+ No title sheets match your search for "{{ searchQuery() }}". + Try adjusting your search terms. +

+ + } @else { + + +

No Title Sheets

+

+ Get started by creating your first title sheet for Unreal Engine. +

+ + } +
+
+ + } +
+ +
+ +
+ + +
+ + +
+ +
+ +
+ + + + diff --git a/src/app/view/components/title-sheets-list/title-sheets-list.component.scss b/src/app/view/components/title-sheets-list/title-sheets-list.component.scss new file mode 100644 index 0000000..ac86cc0 --- /dev/null +++ b/src/app/view/components/title-sheets-list/title-sheets-list.component.scss @@ -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; + } +} diff --git a/src/app/view/components/title-sheets-list/title-sheets-list.component.ts b/src/app/view/components/title-sheets-list/title-sheets-list.component.ts new file mode 100644 index 0000000..ed79cf5 --- /dev/null +++ b/src/app/view/components/title-sheets-list/title-sheets-list.component.ts @@ -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([]); // Keep for compatibility but will use service directly + titleStatus = input(null); + + // === INTERNAL SIGNALS === + titleSheetsFromService = signal([]); + searchQuery = signal(''); + currentSort = signal({ field: 'LastName', direction: 'asc' }); + selectedTitleSheet = signal(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(0); + + // === OUTPUT SIGNALS === + titleSheetAdd = output(); + titleSheetEdit = output(); + titleSheetDelete = output(); + titleSheetPlay = output(); + titleSheetStop = output(); + + // Dialog state + isDialogOpen = signal(false); + dialogMode = signal<'add' | 'edit'>('add'); + dialogTitleSheet = signal(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; + } +} diff --git a/src/app/view/components/toast/toast.component.html b/src/app/view/components/toast/toast.component.html new file mode 100644 index 0000000..5634d86 --- /dev/null +++ b/src/app/view/components/toast/toast.component.html @@ -0,0 +1,38 @@ + +
+ @for (toast of toasts; track toast.id) { +
+ +
+
+ +
+ +
+
+ @if (toast.message) { +
+ } +
+ + @if (toast.showClose) { + + } +
+ + @if (toast.showProgress && toast.duration > 0) { +
+
+
+ } +
+ } +
diff --git a/src/app/view/components/toast/toast.component.scss b/src/app/view/components/toast/toast.component.scss new file mode 100644 index 0000000..fb549a0 --- /dev/null +++ b/src/app/view/components/toast/toast.component.scss @@ -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; + } +} diff --git a/src/app/view/components/toast/toast.component.ts b/src/app/view/components/toast/toast.component.ts new file mode 100644 index 0000000..eb954d2 --- /dev/null +++ b/src/app/view/components/toast/toast.component.ts @@ -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}`; + } +} diff --git a/src/app/view/components/toolbar/toolbar.component.html b/src/app/view/components/toolbar/toolbar.component.html new file mode 100644 index 0000000..e5a8350 --- /dev/null +++ b/src/app/view/components/toolbar/toolbar.component.html @@ -0,0 +1,55 @@ + +
+ + +
+
+ + + + @if (hasSearchQuery) { + + } +
+ + +
+ @if (hasSearchQuery && resultsCount() !== null) { + {{ resultsText }} + } @else { + {{ resultsText }} + } +
+
+ + +
+ +
+ +
diff --git a/src/app/view/components/toolbar/toolbar.component.scss b/src/app/view/components/toolbar/toolbar.component.scss new file mode 100644 index 0000000..7e81f11 --- /dev/null +++ b/src/app/view/components/toolbar/toolbar.component.scss @@ -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; + } + } +} diff --git a/src/app/view/components/toolbar/toolbar.component.ts b/src/app/view/components/toolbar/toolbar.component.ts new file mode 100644 index 0000000..9404d81 --- /dev/null +++ b/src/app/view/components/toolbar/toolbar.component.ts @@ -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('Search by name or position...'); + resultsCount = input(null); + totalCount = input(0); + + // === OUTPUT SIGNALS === + searchQueryChange = output(); + addTitleClick = output(); + + // === INTERNAL SIGNALS === + searchQuery = signal(''); + + // === TEMPLATE REFERENCES === + @ViewChild('searchInput') searchInput!: ElementRef; + + // === 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' : ''}`; + } +} diff --git a/src/app/view/pages/dtflux-title/dtflux-title.component.html b/src/app/view/pages/dtflux-title/dtflux-title.component.html new file mode 100644 index 0000000..40efe5c --- /dev/null +++ b/src/app/view/pages/dtflux-title/dtflux-title.component.html @@ -0,0 +1,180 @@ + +
+ + + + + + + @if (error()) { +
+
+ + {{ error() }} + +
+ +
+ } + + +
+ + +
+ + +
+ + +
+ +
+ + +
+ + +
+ + + @if (selectedMainTab() === 'titles') { + + + @if (isLoading()) { + + +
+
+
+ +
+

Loading Title Sheets

+

Initializing application and connecting to services...

+
+
+ + } @else { + + + + + + } + +
+ } + + +
+ +
+ +
+ + +
+
+ + Ctrl+I Add • ↑↓ Navigate • Ctrl+Enter Play • Space Stop • Del Delete + +
+
+ + + @if (devToolsEnabled()) { +
+
+

+ + Development Tools +

+
+ + +
+
+ + + Unreal Engine: {{ hasConnection() ? 'Connected' : 'Disconnected' }} + + + + Title Sheets: {{ titleSheets().length }} + + @if (currentTitle()) { + + + Playing: {{ currentTitle()?.FirstName }} {{ currentTitle()?.LastName }} + + } +
+
+
+ } + +
diff --git a/src/app/view/pages/dtflux-title/dtflux-title.component.scss b/src/app/view/pages/dtflux-title/dtflux-title.component.scss new file mode 100644 index 0000000..4ed3486 --- /dev/null +++ b/src/app/view/pages/dtflux-title/dtflux-title.component.scss @@ -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; + } +} diff --git a/src/app/view/pages/dtflux-title/dtflux-title.component.ts b/src/app/view/pages/dtflux-title/dtflux-title.component.ts new file mode 100644 index 0000000..8167acc --- /dev/null +++ b/src/app/view/pages/dtflux-title/dtflux-title.component.ts @@ -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([]); + currentTitleSheet = signal(null); + status = signal(createTitleSheetListStatus()); + appConfig = signal(null); + isLoading = signal(true); + error = signal(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): void { + const currentStatus = this.status(); + this.status.set({ ...currentStatus, ...updates }); + } + + // === MENU BAR EVENT HANDLERS === + + async onConfigOpen(): Promise { + 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 { + 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 { + 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 { + 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(); + } +} diff --git a/src/app/view/pages/setup/setup.component.html b/src/app/view/pages/setup/setup.component.html new file mode 100644 index 0000000..7ae5e1a --- /dev/null +++ b/src/app/view/pages/setup/setup.component.html @@ -0,0 +1,563 @@ + +
+ + + + + + +
+ + +
+
+
+

+ + Application Setup +

+

+ Configure Unreal Engine WebSocket connections and application settings. +

+
+ +
+
+ + + @if (saveMessage()) { +
+
+ + {{ saveMessage() }} +
+
+ } + + + @if (isLoading()) { + + +
+
+ + Loading configuration... +
+
+ + } @else { + +
+ + +
+

+ + Unreal Engine Network +

+

+ Global network settings for connecting to Unreal Engine via WebSocket. +

+ +
+ + +
+ + + @if (hasFieldError('unrealAddress')) { + + + {{ getErrorMessage('unrealAddress') }} + + } +

IP address of the Unreal Engine instance (both services).

+
+ + +
+ + + @if (hasFieldError('unrealTimeout')) { + + + {{ getErrorMessage('unrealTimeout') }} + + } +

Timeout for WebSocket connection attempts.

+
+ +
+ + +
+
+ @if (connectionStatus() !== 'testing') { + + } @else { + + } +
+
+ + {{ connectionStatusText }} +
+
+ +
+ + +
+

+ + Remote Control Configuration +

+

+ Configure Unreal Engine Remote Control WebSocket for property control. +

+ + +
+

+ + Network +

+
+ +
+ + + @if (hasFieldError('remoteControlWebSocketPort')) { + + + {{ getErrorMessage('remoteControlWebSocketPort') }} + + } +

WebSocket port for Remote Control property updates.

+
+
+
+ +
+ + +
+

+ + Motion Design Configuration +

+

+ Configure Motion Design WebSocket for rundown control and data settings. +

+ + +
+

+ + Network +

+
+ +
+ + + @if (hasFieldError('motionDesignWebSocketPort')) { + + + {{ getErrorMessage('motionDesignWebSocketPort') }} + + } +

WebSocket port for Motion Design rundowns and animations.

+
+
+
+ + +
+

+ + Data +

+
+ +
+ + + @if (hasFieldError('motionDesignPresetName')) { + + + {{ getErrorMessage('motionDesignPresetName') }} + + } +

Motion Design preset configuration name.

+
+
+
+ +
+ + +
+

+ + Storage Configuration +

+

+ Configure data storage and auto-save settings. +

+ + +
+

+ + Data +

+
+ + +
+ + + @if (hasFieldError('savePath')) { + + + {{ getErrorMessage('savePath') }} + + } +

Directory path for saving configuration files.

+
+ + +
+ +

Automatically save changes to prevent data loss.

+
+ + +
+ + + @if (hasFieldError('autoSaveInterval')) { + + + {{ getErrorMessage('autoSaveInterval') }} + + } +

Frequency of automatic saves (when enabled).

+
+ +
+
+ +
+ + +
+

+ + User Interface Configuration +

+

+ Configure user interface behavior and defaults. +

+ + +
+

+ + Data +

+
+ + +
+ + + @if (hasFieldError('uiDefaultSort')) { + + + {{ getErrorMessage('uiDefaultSort') }} + + } +

Default sorting field for title sheets list.

+
+ + +
+ + + @if (hasFieldError('uiSearchPlaceholder')) { + + + {{ getErrorMessage('uiSearchPlaceholder') }} + + } +

Placeholder text shown in search fields.

+
+ +
+
+ +
+ + +
+

+ + Development Configuration +

+

+ Configure development tools and debugging options. +

+ + +
+

+ + Data +

+
+ + +
+ +

Show development tools in the main interface for testing.

+
+ + +
+ +

Use mock data instead of real Unreal Engine connection.

+
+ + +
+ + + @if (hasFieldError('devLogLevel')) { + + + {{ getErrorMessage('devLogLevel') }} + + } +

Minimum level for console logging.

+
+ +
+
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ +
+ + } + +
+ +
diff --git a/src/app/view/pages/setup/setup.component.scss b/src/app/view/pages/setup/setup.component.scss new file mode 100644 index 0000000..b7a5f1f --- /dev/null +++ b/src/app/view/pages/setup/setup.component.scss @@ -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; + } +} diff --git a/src/app/view/pages/setup/setup.component.ts b/src/app/view/pages/setup/setup.component.ts new file mode 100644 index 0000000..2e532e3 --- /dev/null +++ b/src/app/view/pages/setup/setup.component.ts @@ -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(null); + isLoading = signal(true); + isSaving = signal(false); + saveMessage = signal(null); + connectionStatus = signal<'unknown' | 'testing' | 'connected' | 'failed'>('unknown'); + testResults = signal([]); + + // === 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 { + 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 { + 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 { + 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 = { + // === 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"); + } +} + diff --git a/src/app/view/pages/test-page/test-page.component.ts b/src/app/view/pages/test-page/test-page.component.ts new file mode 100644 index 0000000..ebf7590 --- /dev/null +++ b/src/app/view/pages/test-page/test-page.component.ts @@ -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: ` +
+ + +
+ +
+ +
+ + +
+ +
+ + +
+
+

Quick Actions

+
+ + + + + + + +
+
+
+ + +
+
+

Test Results

+
+ @if (testResults.length === 0) { +
+ + No tests run yet. Click "Test All Connections" to start. +
+ } @else { + @for (result of testResults; track result.timestamp) { +
+
+ {{ result.name }} + {{ result.timestamp | date:'HH:mm:ss' }} +
+
+ + {{ result.message }} +
+ @if (result.duration) { +
{{ result.duration }}ms
+ } +
+ } + } +
+
+
+
+ + + @if (showDebug) { +
+
+

Debug Information

+ +
+
+
+
Current Configuration
+
{{ getCurrentConfig() | json }}
+
+
+
Service Status
+
{{ getServiceStatus() | json }}
+
+
+
+ } +
+ `, + 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 { + 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); + } + } +} diff --git a/src/proxy.conf.json b/src/proxy.conf.json new file mode 100644 index 0000000..3d6be23 --- /dev/null +++ b/src/proxy.conf.json @@ -0,0 +1,4 @@ +{ + "// PROXY DÉSACTIVÉ": "Communication directe WebSocket uniquement", + "// Ancien proxy HTTP vers port 30010 supprimé": "Utilisation WebSocket ports 30020 et 30021" +}