Add phone addon and wire UI events

- Introduce client phone addon, UI, and XEH handlers
- Route actor phone interaction to the new phone UI
- Add initial phone state, event handling, and persistence
This commit is contained in:
Jacob Schmidt 2026-04-06 19:07:18 -05:00
parent d8812df381
commit a8415eb1fd
186 changed files with 24664 additions and 7 deletions

View File

@ -4,7 +4,7 @@
* File: fnc_handleUIEvents.sqf
* Author: IDSolutions
* Date: 2026-01-28
* Last Update: 2026-03-28
* Last Update: 2026-04-06
* Public: No
*
* Description:
@ -41,7 +41,7 @@ switch (_event) do {
case "actor::open::vgarage": { [] spawn EFUNC(garage,openVG); };
case "actor::open::org": { [] spawn EFUNC(org,openUI); };
case "actor::open::vlocker": { [FORGE_Locker_Box, player, false] spawn AFUNC(arsenal,openBox) };
case "actor::open::phone": { hint "Phone interaction is not yet implemented."; };
case "actor::open::phone": { [] spawn EFUNC(phone,openUI); };
case "actor::open::iplayer": { hint "Player interaction is not yet implemented." };
case "actor::open::store": { [] spawn EFUNC(store,openUI); };
default { hint format ["Unhandled UI event: %1", _event]; };

View File

@ -0,0 +1 @@
forge\forge_client\addons\phone

View File

@ -0,0 +1,19 @@
class Extended_PreStart_EventHandlers {
class ADDON {
init = QUOTE(call COMPILE_SCRIPT(XEH_preStart));
};
};
class Extended_PreInit_EventHandlers {
class ADDON {
init = QUOTE(call COMPILE_SCRIPT(XEH_preInit));
clientInit = QUOTE(call COMPILE_SCRIPT(XEH_preInitClient));
};
};
class Extended_PostInit_EventHandlers {
class ADDON {
init = QUOTE(call COMPILE_SCRIPT(XEH_postInit));
clientInit = QUOTE(call COMPILE_SCRIPT(XEH_postInitClient));
};
};

View File

@ -0,0 +1,4 @@
forge_client_phone
===================
This addon provides the phone user interface and functionality for the in-game phone system. It handles all phone-related features including the UI display, interactions, and core phone operations.

View File

@ -0,0 +1,3 @@
PREP(handleUIEvents);
PREP(initClass);
PREP(openUI);

View File

@ -0,0 +1 @@
#include "script_component.hpp"

View File

@ -0,0 +1,302 @@
#include "script_component.hpp"
[{
GETVAR(player,FORGE_isLoaded,false)
}, {
[QGVAR(initPhone), []] call CFUNC(localEvent);
}] call CFUNC(waitUntilAndExecute);
if (isNil QGVAR(PhoneClass)) then { [] call FUNC(initClass); };
[QGVAR(initPhone), {
GVAR(PhoneClass) call ["init", []];
["forge_server_phone_requestInitPhone", [getPlayerUID player, createHashMap]] call CFUNC(serverEvent);
["forge_server_phone_requestRefreshContacts", [getPlayerUID player, player]] call CFUNC(serverEvent);
}] call CFUNC(addEventHandler);
[QGVAR(responseSyncPhone), {
params [["_data", createHashMap, [createHashMap]]];
GVAR(PhoneClass) call ["sync", [_data]];
}] call CFUNC(addEventHandler);
// Contact Management Response Events
[QGVAR(responseAddContact), {
params [["_success", false, [false]]];
if (_success) then {
[QEGVAR(notifications,recieveNotification), ["success", "Contact Added", "Contact added successfully", 3000]] call CFUNC(localEvent);
[QGVAR(refreshUI), []] call CFUNC(localEvent);
} else {
[QEGVAR(notifications,recieveNotification), ["danger", "Contact Error", "Failed to add contact", 4000]] call CFUNC(localEvent);
};
}] call CFUNC(addEventHandler);
[QGVAR(responseAddContactByPhone), {
params [["_success", false, [false]], ["_phoneNumber", "", [""]]];
if (_success) then {
[QEGVAR(notifications,recieveNotification), ["success", "Contact Added", format ["Contact with phone %1 added successfully", _phoneNumber], 3000]] call CFUNC(localEvent);
[QGVAR(refreshUI), []] call CFUNC(localEvent);
} else {
[QEGVAR(notifications,recieveNotification), ["warning", "Contact Not Found", format ["Player with phone %1 not found", _phoneNumber], 4000]] call CFUNC(localEvent);
};
}] call CFUNC(addEventHandler);
[QGVAR(responseAddContactByEmail), {
params [["_success", false, [false]], ["_email", "", [""]]];
if (_success) then {
[QEGVAR(notifications,recieveNotification), ["success", "Contact Added", format ["Contact with email %1 added successfully", _email], 3000]] call CFUNC(localEvent);
[QGVAR(refreshUI), []] call CFUNC(localEvent);
} else {
[QEGVAR(notifications,recieveNotification), ["warning", "Contact Not Found", format ["Player with email %1 not found", _email], 4000]] call CFUNC(localEvent);
};
}] call CFUNC(addEventHandler);
[QGVAR(responseRemoveContact), {
params [["_success", false, [false]], ["_contactUid", "", [""]]];
if (_success) then {
[QEGVAR(notifications,recieveNotification), ["success", "Contact Removed", "Contact removed successfully", 3000]] call CFUNC(localEvent);
[QGVAR(refreshUI), []] call CFUNC(localEvent);
} else {
[QEGVAR(notifications,recieveNotification), ["danger", "Contact Error", "Failed to remove contact", 4000]] call CFUNC(localEvent);
};
}] call CFUNC(addEventHandler);
[QGVAR(responseRefreshContacts), {
params [["_contacts", [], [[]]]];
diag_log format ["[FORGE:Client:Phone] Contacts refreshed: %1 contacts", count _contacts];
[QGVAR(updateContacts), [_contacts]] call CFUNC(localEvent);
}] call CFUNC(addEventHandler);
[QGVAR(responseGetContacts), {
params [["_contactUids", [], [[]]]];
diag_log format ["[FORGE:Client:Phone] Got contact UIDs: %1", _contactUids];
}] call CFUNC(addEventHandler);
// Messaging Response Events
[QGVAR(responseMessageSent), {
params [["_messageObj", createHashMap, [createHashMap]]];
diag_log format ["[FORGE:Client:Phone] Message sent: %1", _messageObj];
[QGVAR(updateMessageSent), [_messageObj]] call CFUNC(localEvent);
}] call CFUNC(addEventHandler);
[QGVAR(responseMessageReceived), {
params [["_messageObj", createHashMap, [createHashMap]]];
private _fromUid = _messageObj get "from";
private _message = _messageObj get "message";
private _contacts = player getVariable ["FORGE_Contacts", []];
private _senderName = "Unknown";
{
if ((_x get "uid") isEqualTo _fromUid) exitWith {
_senderName = _x get "name";
};
} forEach _contacts;
[QEGVAR(notifications,recieveNotification), ["info", "New Message", format ["From %1", _senderName], 4000]] call CFUNC(localEvent);
diag_log format ["[FORGE:Client:Phone] Message received from %1: %2", _fromUid, _message];
[QGVAR(updateMessageReceived), [_messageObj]] call CFUNC(localEvent);
}] call CFUNC(addEventHandler);
[QGVAR(responseSendMessage), {
params [["_success", false, [false]]];
if (_success) then {
[QEGVAR(notifications,recieveNotification), ["success", "Message Sent", "Message sent successfully", 2000]] call CFUNC(localEvent);
} else {
[QEGVAR(notifications,recieveNotification), ["danger", "Message Failed", "Failed to send message", 4000]] call CFUNC(localEvent);
};
}] call CFUNC(addEventHandler);
[QGVAR(responseGetMessages), {
params [["_messages", [], [[]]]];
diag_log format ["[FORGE:Client:Phone] Got %1 messages", count _messages];
[QGVAR(updateMessages), [_messages]] call CFUNC(localEvent);
}] call CFUNC(addEventHandler);
[QGVAR(responseGetMessageThread), {
params [["_messages", [], [[]]], ["_otherUid", "", [""]]];
diag_log format ["[FORGE:Client:Phone] Got message thread with %1: %2 messages", _otherUid, count _messages];
[QGVAR(updateMessageThread), [_messages, _otherUid]] call CFUNC(localEvent);
}] call CFUNC(addEventHandler);
[QGVAR(responseMarkMessageRead), {
params [["_success", false, [false]], ["_messageId", "", [""]]];
if (_success) then { diag_log format ["[FORGE:Client:Phone] Message %1 marked as read", _messageId]; };
}] call CFUNC(addEventHandler);
[QGVAR(responseMessageRead), {
params [["_messageId", "", [""]]];
diag_log format ["[FORGE:Client:Phone] Message %1 marked as read", _messageId];
[QGVAR(updateMessageRead), [_messageId]] call CFUNC(localEvent);
}] call CFUNC(addEventHandler);
// Email Response Events
[QGVAR(responseEmailSent), {
params [["_emailObj", createHashMap, [createHashMap]]];
diag_log format ["[FORGE:Client:Phone] Email sent: %1", _emailObj];
[QGVAR(updateEmailSent), [_emailObj]] call CFUNC(localEvent);
}] call CFUNC(addEventHandler);
[QGVAR(responseEmailReceived), {
params [["_emailObj", createHashMap, [createHashMap]]];
private _fromUid = _emailObj get "from";
private _subject = _emailObj get "subject";
private _contacts = player getVariable ["FORGE_Contacts", []];
private _senderName = "Unknown";
{
if ((_x get "uid") isEqualTo _fromUid) exitWith {
_senderName = _x get "name";
};
} forEach _contacts;
[QEGVAR(notifications,recieveNotification), ["info", "New Email", format ["From %1: %2", _senderName, _subject], 4000]] call CFUNC(localEvent);
diag_log format ["[FORGE:Client:Phone] Email received from %1: %2", _fromUid, _subject];
[QGVAR(updateEmailReceived), [_emailObj]] call CFUNC(localEvent);
}] call CFUNC(addEventHandler);
[QGVAR(responseSendEmail), {
params [["_success", false, [false]]];
if (_success) then {
[QEGVAR(notifications,recieveNotification), ["success", "Email Sent", "Email sent successfully", 2000]] call CFUNC(localEvent);
} else {
[QEGVAR(notifications,recieveNotification), ["danger", "Email Failed", "Failed to send email", 4000]] call CFUNC(localEvent);
};
}] call CFUNC(addEventHandler);
[QGVAR(responseGetEmails), {
params [["_emails", [], [[]]]];
diag_log format ["[FORGE:Client:Phone] Got %1 emails", count _emails];
[QGVAR(updateEmails), [_emails]] call CFUNC(localEvent);
}] call CFUNC(addEventHandler);
[QGVAR(responseMarkEmailRead), {
params [["_success", false, [false]], ["_emailId", "", [""]]];
if (_success) then {
diag_log format ["[FORGE:Client:Phone] Email %1 marked as read", _emailId];
[QGVAR(updateEmailRead), [_emailId]] call CFUNC(localEvent);
};
}] call CFUNC(addEventHandler);
[QGVAR(responseEmailRead), {
params [["_emailId", "", [""]]];
diag_log format ["[FORGE:Client:Phone] Email %1 marked as read", _emailId];
[QGVAR(updateEmailRead), [_emailId]] call CFUNC(localEvent);
}] call CFUNC(addEventHandler);
// Cleanup Response Events
[QGVAR(responseRemovePhone), {
params [["_success", false, [false]]];
if (_success) then { diag_log "[FORGE:Client:Phone] Phone data removed successfully"; };
}] call CFUNC(addEventHandler);
// UI Update Events (for internal use)
[QGVAR(refreshUI), {
private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", "refreshContacts()"]; };
}] call CFUNC(addEventHandler);
[QGVAR(updateContacts), {
params [["_contacts", [], [[]]]];
private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateContacts(%1)", (toJSON _contacts)]]; };
}] call CFUNC(addEventHandler);
[QGVAR(updateMessageSent), {
params [["_messageObj", createHashMap, [createHashMap]]];
private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateMessageSent(%1)", (toJSON _messageObj)]]; };
}] call CFUNC(addEventHandler);
[QGVAR(updateMessageReceived), {
params [["_messageObj", createHashMap, [createHashMap]]];
private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateMessageReceived(%1)", (toJSON _messageObj)]]; };
}] call CFUNC(addEventHandler);
[QGVAR(updateMessages), {
params [["_messages", [], [[]]]];
private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateMessages(%1)", (toJSON _messages)]]; };
}] call CFUNC(addEventHandler);
[QGVAR(updateMessageThread), {
params [["_messages", [], [[]]], ["_otherUid", "", [""]]];
private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateMessageThread(%1, %2)", (toJSON _messages), (toJSON _otherUid)]]; };
}] call CFUNC(addEventHandler);
[QGVAR(updateEmailSent), {
params [["_emailObj", createHashMap, [createHashMap]]];
private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateEmailSent(%1)", (toJSON _emailObj)]]; };
}] call CFUNC(addEventHandler);
[QGVAR(updateEmailReceived), {
params [["_emailObj", createHashMap, [createHashMap]]];
private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateEmailReceived(%1)", (toJSON _emailObj)]]; };
}] call CFUNC(addEventHandler);
[QGVAR(updateEmails), {
params [["_emails", [], [[]]]];
private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateEmails(%1)", (toJSON _emails)]]; };
}] call CFUNC(addEventHandler);
[QGVAR(updateEmailRead), {
params [["_emailId", "", [""]]];
private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateEmailRead(%1)", (toJSON _emailId)]]; };
}] call CFUNC(addEventHandler);

View File

@ -0,0 +1,9 @@
#include "script_component.hpp"
PREP_RECOMPILE_START;
#include "XEH_PREP.hpp"
PREP_RECOMPILE_END;
private _category = [QUOTE(MOD_NAME), LLSTRING(displayName)];
#include "initKeybinds.inc.sqf"

View File

@ -0,0 +1 @@
#include "script_component.hpp"

View File

@ -0,0 +1,2 @@
#include "script_component.hpp"
#include "XEH_PREP.hpp"

View File

@ -0,0 +1,21 @@
#include "script_component.hpp"
class CfgPatches {
class ADDON {
author = AUTHOR;
authors[] = {"J. Schmidt"};
url = ECSTRING(main,url);
name = COMPONENT_NAME;
requiredVersion = REQUIRED_VERSION;
requiredAddons[] = {
"forge_client_main"
};
units[] = {};
weapons[] = {};
VERSION_CONFIG;
};
};
#include "CfgEventHandlers.hpp"
#include "ui\RscCommon.hpp"
#include "ui\RscPhone.hpp"

View File

@ -0,0 +1,331 @@
#include "..\script_component.hpp"
/*
* Author: IDSolutions
* Handles UI events.
*
* Arguments:
* None
*
* Return Value:
* None
*
* Example:
* [] call forge_client_phone_fnc_handleUIEvents;
*
* Public: No
*/
params ["_control", "_isConfirmDialog", "_message"];
private _alert = fromJSON _message;
private _event = _alert get "event";
private _data = _alert get "data";
// diag_log format ["[FORGE:Client:Phone] Handling UI event: %1 with data: %2", _event, _data];
switch (_event) do {
case "phone::get::player": {
private _uid = getPlayerUID player;
_control ctrlWebBrowserAction ["ExecJS", format ["setPlayerUid(%1)", (toJSON _uid)]];
};
case "phone::get::theme": {
private _isDark = profileNamespace getVariable ["FORGE_Phone_isDark", true];
private _theme = ["light", "dark"] select (_isDark);
_control ctrlWebBrowserAction ["ExecJS", format ["setTheme(%1)", (toJSON _theme)]];
};
case "phone::get::contacts": {
private _contacts = player getVariable ["FORGE_Contacts", []];
_control ctrlWebBrowserAction ["ExecJS", format ["loadContacts(%1)", (toJSON _contacts)]];
["forge_server_phone_requestRefreshContacts", [getPlayerUID player, player]] call CFUNC(serverEvent);
};
case "phone::set::theme": {
private _isDark = _data get "isDark";
profileNamespace setVariable ["FORGE_Phone_isDark", _isDark];
};
case "phone::add::contact": {
private _contactPhone = _data get "phone";
if (_contactPhone isNotEqualTo "") then {
["forge_server_phone_requestAddContactByPhone", [getPlayerUID player, _contactPhone, player]] call CFUNC(serverEvent);
} else {
diag_log "[FORGE:Client:Phone] No phone number provided for contact addition";
};
};
case "phone::add::contact::by::phone": {
private _phoneNumber = _data get "phone";
if (_phoneNumber isNotEqualTo "") then {
["forge_server_phone_requestAddContactByPhone", [getPlayerUID player, _phoneNumber, player]] call CFUNC(serverEvent);
} else {
diag_log "[FORGE:Client:Phone] No phone number provided";
};
};
case "phone::add::contact::by::email": {
private _email = _data get "email";
if (_email isNotEqualTo "") then {
["forge_server_phone_requestAddContactByEmail", [getPlayerUID player, _email, player]] call CFUNC(serverEvent);
} else {
diag_log "[FORGE:Client:Phone] No email provided";
};
};
case "phone::remove::contact": {
private _contactUid = _data get "uid";
if (_contactUid isNotEqualTo "") then {
["forge_server_phone_requestRemoveContact", [getPlayerUID player, _contactUid, player]] call CFUNC(serverEvent);
} else {
diag_log "[FORGE:Client:Phone] No contact UID provided for removal";
};
};
case "phone::refresh::contacts": {
["forge_server_phone_requestRefreshContacts", [getPlayerUID player, player]] call CFUNC(serverEvent);
};
case "phone::send::message": {
private _contactName = _data get "contactName";
private _messageData = _data get "message";
private _messageText = _messageData get "text";
private _toUid = _data get "toUid";
if (_toUid isNotEqualTo "") then {
["forge_server_phone_requestSendMessage", [getPlayerUID player, _toUid, _messageText, player]] call CFUNC(serverEvent);
} else {
diag_log format ["[FORGE:Client:Phone] No recipient UID provided for message to %1", _contactName];
};
};
case "phone::get::messages": {
["forge_server_phone_requestGetMessages", [getPlayerUID player, player]] call CFUNC(serverEvent);
};
case "phone::get::message::thread": {
private _otherUid = _data get "otherUid";
if (_otherUid isNotEqualTo "") then {
["forge_server_phone_requestGetMessageThread", [getPlayerUID player, _otherUid, player]] call CFUNC(serverEvent);
} else {
diag_log "[FORGE:Client:Phone] No other UID provided for message thread";
};
};
case "phone::mark::message::read": {
private _messageId = _data get "messageId";
if (_messageId isNotEqualTo "") then {
["forge_server_phone_requestMarkMessageRead", [getPlayerUID player, _messageId, player]] call CFUNC(serverEvent);
} else {
diag_log "[FORGE:Client:Phone] No message ID provided for mark read";
};
};
case "phone::send::email": {
private _toUid = _data get "toUid";
private _subject = _data get "subject";
private _body = _data get "body";
if (_toUid isNotEqualTo "" && _subject isNotEqualTo "" && _body isNotEqualTo "") then {
["forge_server_phone_requestSendEmail", [getPlayerUID player, _toUid, _subject, _body, player]] call CFUNC(serverEvent);
} else {
diag_log "[FORGE:Client:Phone] Missing required email parameters";
};
};
case "phone::get::emails": {
["forge_server_phone_requestGetEmails", [getPlayerUID player, player]] call CFUNC(serverEvent);
};
case "phone::mark::email::read": {
private _emailId = _data get "emailId";
if (_emailId isNotEqualTo "") then {
["forge_server_phone_requestMarkEmailRead", [getPlayerUID player, _emailId, player]] call CFUNC(serverEvent);
} else {
diag_log "[FORGE:Client:Phone] No email ID provided for mark read";
};
};
case "phone::get::notes": {
private _notes = GVAR(PhoneClass) call ["getAllNotes", []];
_control ctrlWebBrowserAction ["ExecJS", format ["loadNotes(%1)", (toJSON _notes)]];
};
case "phone::save::note": {
private _success = GVAR(PhoneClass) call ["addNote", [_data]];
_success
};
case "phone::delete::note": {
private _noteId = _data get "id";
private _success = GVAR(PhoneClass) call ["deleteNote", [_noteId]];
_success
};
case "phone::get::events": {
private _events = profileNamespace getVariable ["FORGE_Phone_Events", []];
_control ctrlWebBrowserAction ["ExecJS", format ["loadCalendarEvents(%1)", (toJSON _events)]];
};
case "phone::save::event": {
private _eventId = _data get "id";
private _eventTitle = _data get "title";
private _events = profileNamespace getVariable ["FORGE_Phone_Events", []];
private _existingIndex = -1;
{
private _existingId = _x get "id";
if (_existingId isEqualTo _eventId) then {
_existingIndex = _forEachIndex;
};
} forEach _events;
if (_existingIndex >= 0) then {
_events set [_existingIndex, _data];
diag_log format ["[PHONE] Updated event: %1 [ID: %2]", _eventTitle, _eventId];
} else {
_events pushBack _data;
diag_log format ["[PHONE] Added new event: %1 [ID: %2]", _eventTitle, _eventId];
};
profileNamespace setVariable ["FORGE_Phone_Events", _events];
diag_log format ["[PHONE] Saved events to profile. Total events: %1", count _events];
};
case "phone::delete::event": {
private _eventId = _data get "id";
private _events = profileNamespace getVariable ["FORGE_Phone_Events", []];
private _newEvents = [];
private _deleted = false;
{
private _existingId = _x get "id";
if (_existingId isEqualTo _eventId) then {
_deleted = true;
} else {
_newEvents pushBack _x;
};
} forEach _events;
if (_deleted) then {
profileNamespace setVariable ["FORGE_Phone_Events", _newEvents];
diag_log format ["[PHONE] Deleted calendar event [ID: %1]. Remaining events: %2", _eventId, count _newEvents];
} else {
diag_log format ["[PHONE] Calendar event not found for deletion [ID: %1]", _eventId];
};
};
case "phone::get::clocks": {
private _worldClocks = profileNamespace getVariable ["FORGE_Phone_WorldClocks", []];
_control ctrlWebBrowserAction ["ExecJS", format ["loadWorldClocks(%1)", (toJSON _worldClocks)]];
};
case "phone::save::clock": {
private _clockId = _data get "id";
private _timezone = _data get "timezone";
private _city = _data get "city";
private _worldClocks = profileNamespace getVariable ["FORGE_Phone_WorldClocks", []];
private _clockExists = false;
{
private _existingId = _x get "id";
private _existingTimezone = _x get "timezone";
if (_existingId isEqualTo _clockId || _existingTimezone isEqualTo _timezone) then {
_clockExists = true;
};
} forEach _worldClocks;
if (!_clockExists) then {
_worldClocks pushBack _data;
profileNamespace setVariable ["FORGE_Phone_WorldClocks", _worldClocks];
diag_log format ["[PHONE] Added world clock: %1 (%2) [ID: %3]. Total clocks: %4", _city, _timezone, _clockId, count _worldClocks];
} else {
diag_log format ["[PHONE] World clock already exists: %1 (%2) [ID: %3]. Skipping duplicate.", _city, _timezone, _clockId];
};
};
case "phone::delete::clock": {
private _clockId = _data get "id";
private _worldClocks = profileNamespace getVariable ["FORGE_Phone_WorldClocks", []];
private _newClocks = [];
private _deleted = false;
{
private _existingId = _x get "id";
if (_existingId isEqualTo _clockId) then {
_deleted = true;
} else {
_newClocks pushBack _x;
};
} forEach _worldClocks;
if (_deleted) then {
profileNamespace setVariable ["FORGE_Phone_WorldClocks", _newClocks];
diag_log format ["[PHONE] Deleted world clock [ID: %1]. Remaining clocks: %2", _clockId, count _newClocks];
} else {
diag_log format ["[PHONE] World clock not found for deletion [ID: %1]", _clockId];
};
};
case "phone::get::alarms": {
private _alarms = profileNamespace getVariable ["FORGE_Phone_Alarms", []];
_control ctrlWebBrowserAction ["ExecJS", format ["loadAlarms(%1)", (toJSON _alarms)]];
};
case "phone::save::alarm": {
private _alarmId = _data get "id";
private _alarmTime = _data get "time";
private _alarmLabel = _data get "label";
private _alarms = profileNamespace getVariable ["FORGE_Phone_Alarms", []];
private _existingIndex = -1;
{
private _existingId = _x get "id";
if (_existingId isEqualTo _alarmId) then {
_existingIndex = _forEachIndex;
};
} forEach _alarms;
if (_existingIndex >= 0) then {
_alarms set [_existingIndex, _data];
diag_log format ["[PHONE] Updated alarm: %1 at %2 [ID: %3]", _alarmLabel, _alarmTime, _alarmId];
} else {
_alarms pushBack _data;
diag_log format ["[PHONE] Added new alarm: %1 at %2 [ID: %3]", _alarmLabel, _alarmTime, _alarmId];
};
profileNamespace setVariable ["FORGE_Phone_Alarms", _alarms];
diag_log format ["[PHONE] Saved alarms to profile. Total alarms: %1", count _alarms];
};
case "phone::delete::alarm": {
private _alarmId = _data get "id";
private _alarms = profileNamespace getVariable ["FORGE_Phone_Alarms", []];
private _newAlarms = [];
private _deleted = false;
{
private _existingId = _x get "id";
if (_existingId isEqualTo _alarmId) then {
_deleted = true;
} else {
_newAlarms pushBack _x;
};
} forEach _alarms;
if (_deleted) then {
profileNamespace setVariable ["FORGE_Phone_Alarms", _newAlarms];
diag_log format ["[PHONE] Deleted alarm [ID: %1]. Remaining alarms: %2", _alarmId, count _newAlarms];
} else {
diag_log format ["[PHONE] Alarm not found for deletion [ID: %1]", _alarmId];
};
};
case "phone::toggle::alarm": {
private _alarmId = _data get "id";
private _alarms = profileNamespace getVariable ["FORGE_Phone_Alarms", []];
{
private _existingId = _x get "id";
if (_existingId isEqualTo _alarmId) then {
private _currentEnabled = _x get "enabled";
_x set ["enabled", !_currentEnabled];
diag_log format ["[PHONE] Toggled alarm [ID: %1] to %2", _alarmId, !_currentEnabled];
};
} forEach _alarms;
profileNamespace setVariable ["FORGE_Phone_Alarms", _alarms];
};
default { hint format ["Unhandled phone event: %1", _event]; };
};
true;

View File

@ -0,0 +1,293 @@
#include "..\script_component.hpp"
#pragma hemtt ignore_variables ["_self"]
/*
* Author: IDSolutions
* Initialize unified phone class
*
* Arguments:
* N/A
*
* Return Value:
* N/A
*
* Examples:
* [] call forge_client_phone_fnc_initClass
*
* Public: Yes
*/
// TODO: Perform comprehensive review and edit of phone class implementation
// Then integrate this class to replace current phone handling logic
// Key areas to address:
// - Verify all phone data structures and methods
// - Ensure proper data persistence
// - Implement robust error handling
// - Replace direct UI manipulation with class-based approach
GVAR(PhoneClass) = createHashMapObject [[
["#type", "IPhoneClass"],
["#create", {
_self set ["uid", getPlayerUID player];
_self set ["notes", createHashMap];
_self set ["events", []];
_self set ["settings", createHashMap];
_self set ["isLoaded", false];
_self set ["lastSave", time];
// Initialize default settings
private _settings = createHashMap;
_settings set ["theme", "light"];
_settings set ["notifications", true];
_settings set ["sound", true];
_settings set ["vibration", true];
_self set ["settings", _settings];
}],
["init", {
// Contacts/messages/emails are server-owned. Keep only local utility-app
// state in profileNamespace until those apps are migrated.
private _savedNotes = profileNamespace getVariable ["FORGE_Phone_Notes", createHashMap];
private _savedEvents = profileNamespace getVariable ["FORGE_Phone_Events", []];
private _savedSettings = profileNamespace getVariable ["FORGE_Phone_Settings", createHashMap];
_self set ["notes", _savedNotes];
_self set ["events", _savedEvents];
// Merge saved settings with defaults
private _defaultSettings = _self get "settings";
{
_defaultSettings set [_x, _y];
} forEach _savedSettings;
_self set ["settings", _defaultSettings];
_self set ["isLoaded", true];
systemChat format ["Phone loaded for %1", (name player)];
diag_log "[FORGE:Client:Phone] Phone Class Initialized!";
}],
["_padString", {
params [["_number", 0, [0]], ["_length", 0, [0]]];
private _str = str _number;
while { (_str select [(_length - 1), 1]) == "" } do { _str = "0" + _str };
_str
}],
["save", {
params [["_sync", false, [false]]];
// Save local-only phone app state to profile.
profileNamespace setVariable ["FORGE_Phone_Notes", _self get "notes"];
profileNamespace setVariable ["FORGE_Phone_Events", _self get "events"];
profileNamespace setVariable ["FORGE_Phone_Settings", _self get "settings"];
if (_sync) then { saveProfileNamespace; };
_self set ["lastSave", time];
}],
["sync", {
params [["_data", createHashMap, [createHashMap]]];
if (_data isEqualTo createHashMap) exitWith { diag_log "[FORGE:Client:Phone] Empty data received for sync, skipping."; };
}],
["get", {
params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]];
private _settings = _self get "settings";
_settings getOrDefault [_key, _default];
}],
["addNote", {
params [["_data", createHashMap, [createHashMap]]];
if (_data isEqualTo createHashMap) exitWith { false };
private _noteId = _data get "id";
private _notes = _self get "notes";
_notes set [_noteId, _data];
_self call ["save", [true]];
diag_log format ["[FORGE:Client:Phone] Added note [ID: %1]", _noteId];
true
}],
["updateNote", {
params [["_data", createHashMap, [createHashMap]]];
private _noteId = _data get "id";
if (isNil "_noteId" || _noteId == "") exitWith { false };
private _notes = _self get "notes";
if !(_noteId in _notes) exitWith { false };
_notes set [_noteId, _data];
_self set ["notes", _notes];
_self call ["save", [true]];
diag_log format ["[FORGE:Client:Phone] Updated note [ID: %1]", _noteId];
true
}],
["deleteNote", {
params [["_noteId", "", [""]]];
if (_noteId == "") exitWith { false };
private _notes = _self get "notes";
if (!(_noteId in _notes)) exitWith { false };
_notes deleteAt _noteId;
_self set ["notes", _notes];
_self call ["save", [true]];
diag_log format ["[FORGE:Client:Phone] Deleted note [ID: %1]", _noteId];
true
}],
["getNote", {
params [["_noteId", "", [""]], ["_default", nil]];
private _notes = _self get "notes";
_notes getOrDefault [_noteId, _default];
}],
["getAllNotes", {
private _notes = _self get "notes";
private _notesArray = [];
{
_notesArray pushBack _y;
} forEach _notes;
_notesArray
}],
["setSetting", {
params [["_key", "", [""]], ["_value", nil]];
if (_key == "") exitWith { false };
private _settings = _self get "settings";
_settings set [_key, _value];
_self set ["settings", _settings];
_self call ["save", [true]];
true
}],
["getSetting", {
params [["_key", "", [""]], ["_default", nil]];
private _settings = _self get "settings";
_settings getOrDefault [_key, _default];
}],
["getAllSettings", {
_self get "settings";
}],
["addEvent", {
params [["_eventData", createHashMap, [createHashMap]]];
if (_eventData isEqualTo createHashMap) exitWith { false };
private _eventId = _eventData get "id";
if (isNil "_eventId" || _eventId == "") exitWith { false };
private _events = _self get "events";
// Check if event already exists
private _existingIndex = _events findIf {(_x get "id") isEqualTo _eventId};
if (_existingIndex >= 0) then {
// Update existing event
_events set [_existingIndex, _eventData];
diag_log format ["[FORGE:Client:Phone] Updated event [ID: %1]", _eventId];
} else {
// Add new event
_events pushBack _eventData;
diag_log format ["[FORGE:Client:Phone] Added event [ID: %1]", _eventId];
};
_self set ["events", _events];
_self call ["save", [true]];
true
}],
["updateEvent", {
params [["_eventData", createHashMap, [createHashMap]]];
private _eventId = _eventData get "id";
if (isNil "_eventId" || _eventId == "") exitWith { false };
private _events = _self get "events";
private _existingIndex = _events findIf {(_x get "id") isEqualTo _eventId};
if (_existingIndex < 0) exitWith { false };
_events set [_existingIndex, _eventData];
_self set ["events", _events];
_self call ["save", [true]];
diag_log format ["[FORGE:Client:Phone] Updated event [ID: %1]", _eventId];
true
}],
["deleteEvent", {
params [["_eventId", "", [""]]];
if (_eventId == "") exitWith { false };
private _events = _self get "events";
private _existingIndex = _events findIf {(_x get "id") isEqualTo _eventId};
if (_existingIndex < 0) exitWith { false };
_events deleteAt _existingIndex;
_self set ["events", _events];
_self call ["save", [true]];
diag_log format ["[FORGE:Client:Phone] Deleted event [ID: %1]", _eventId];
true
}],
["getEvent", {
params [["_eventId", "", [""]], ["_default", nil]];
private _events = _self get "events";
private _event = _events select {(_x get "id") isEqualTo _eventId};
if (_event isNotEqualTo []) then {
_event select 0
} else {
_default
};
}],
["getAllEvents", {
private _events = _self get "events";
_events
}],
["getEventsByDate", {
params [["_date", "", [""]]];
private _events = _self get "events";
private _dateEvents = _events select {
private _eventStartTime = _x get "startTime";
if (isNil "_eventStartTime") then { false } else {
// Extract date from ISO string (YYYY-MM-DD)
private _eventDate = (_eventStartTime splitString "T") select 0;
_eventDate isEqualTo _date
};
};
_dateEvents
}],
["clearAllEvents", {
_self set ["events", []];
_self call ["save", [true]];
diag_log "[FORGE:Client:Phone] Cleared all events";
true
}],
["getEventsForToday", {
private _currentTime = systemTimeUTC;
private _todayDate = format ["%1-%2-%3",
_currentTime select 0,
_self call ["_padString", [(_currentTime select 1), 2]],
_self call ["_padString", [(_currentTime select 2), 2]]
];
_self call ["getEventsByDate", [_todayDate]]
}]
]];
SETVAR(player,FORGE_PhoneClass,GVAR(PhoneClass));
GVAR(PhoneClass)

View File

@ -0,0 +1,31 @@
#include "..\script_component.hpp"
/*
* Author: IDSolutions
* Open phone interface.
*
* Arguments:
* None
*
* Return Value:
* None
*
* Example:
* [] call forge_client_phone_fnc_openUI;
*
* Public: No
*/
private _display = (findDisplay 46) createDisplay "RscPhone";
private _ctrl = (_display displayCtrl 1001);
_ctrl ctrlAddEventHandler ["JSDialog", {
params ["_control", "_isConfirmDialog", "_message"];
[_control, _isConfirmDialog, _message] call FUNC(handleUIEvents);
}];
_ctrl ctrlWebBrowserAction ["LoadFile", QUOTE(PATHTOF(ui\_site\index.html))];
// _ctrl ctrlWebBrowserAction ["OpenDevConsole"];
true;

View File

@ -0,0 +1,8 @@
#include "\forge\forge_client\addons\main\data\hpp\defineDIKCodes.hpp"
[
_category, QGVAR(ForgePhone),
[LSTRING(phone), LSTRING(phoneTooltip)], {
[] call FUNC(openUI)
}, {}, [DIK_P, [false, false, false]]
] call CFUNC(addKeybind);

View File

@ -0,0 +1,9 @@
#define COMPONENT phone
#define COMPONENT_BEAUTIFIED Phone
#include "\forge\forge_client\addons\main\script_mod.hpp"
// #define DEBUG_MODE_FULL
// #define DISABLE_COMPILE_CACHE
// #define ENABLE_PERFORMANCE_COUNTERS
#include "\forge\forge_client\addons\main\script_macros.hpp"

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<Project name="FFE">
<Package name="Phone">
<Key ID="STR_forge_client_phone_displayName">
<English>Phone</English>
</Key>
<Key ID="STR_forge_client_phone_phone">
<English>Phone</English>
</Key>
<Key ID="STR_forge_client_phone_phoneTooltip">
<English>Open your phone</English>
</Key>
</Package>
</Project>

View File

@ -0,0 +1,265 @@
// Control types
#define CT_STATIC 0
#define CT_BUTTON 1
#define CT_EDIT 2
#define CT_SLIDER 3
#define CT_COMBO 4
#define CT_LISTBOX 5
#define CT_TOOLBOX 6
#define CT_CHECKBOXES 7
#define CT_PROGRESS 8
#define CT_HTML 9
#define CT_STATIC_SKEW 10
#define CT_ACTIVETEXT 11
#define CT_TREE 12
#define CT_STRUCTURED_TEXT 13
#define CT_CONTEXT_MENU 14
#define CT_CONTROLS_GROUP 15
#define CT_SHORTCUTBUTTON 16
#define CT_HITZONES 17
#define CT_XKEYDESC 40
#define CT_XBUTTON 41
#define CT_XLISTBOX 42
#define CT_XSLIDER 43
#define CT_XCOMBO 44
#define CT_ANIMATED_TEXTURE 45
#define CT_OBJECT 80
#define CT_OBJECT_ZOOM 81
#define CT_OBJECT_CONTAINER 82
#define CT_OBJECT_CONT_ANIM 83
#define CT_LINEBREAK 98
#define CT_USER 99
#define CT_MAP 100
#define CT_MAP_MAIN 101
#define CT_LISTNBOX 102
#define CT_ITEMSLOT 103
#define CT_CHECKBOX 77
// Static styles
#define ST_POS 0x0F
#define ST_HPOS 0x03
#define ST_VPOS 0x0C
#define ST_LEFT 0x00
#define ST_RIGHT 0x01
#define ST_CENTER 0x02
#define ST_DOWN 0x04
#define ST_UP 0x08
#define ST_VCENTER 0x0C
#define ST_TYPE 0xF0
#define ST_SINGLE 0x00
#define ST_MULTI 0x10
#define ST_TITLE_BAR 0x20
#define ST_PICTURE 0x30
#define ST_FRAME 0x40
#define ST_BACKGROUND 0x50
#define ST_GROUP_BOX 0x60
#define ST_GROUP_BOX2 0x70
#define ST_HUD_BACKGROUND 0x80
#define ST_TILE_PICTURE 0x90
#define ST_WITH_RECT 0xA0
#define ST_LINE 0xB0
#define ST_UPPERCASE 0xC0
#define ST_LOWERCASE 0xD0
#define ST_SHADOW 0x100
#define ST_NO_RECT 0x200
#define ST_KEEP_ASPECT_RATIO 0x800
// Slider styles
#define SL_DIR 0x400
#define SL_VERT 0
#define SL_HORZ 0x400
#define SL_TEXTURES 0x10
// progress bar
#define ST_VERTICAL 0x01
#define ST_HORIZONTAL 0
// Listbox styles
#define LB_TEXTURES 0x10
#define LB_MULTI 0x20
// Tree styles
#define TR_SHOWROOT 1
#define TR_AUTOCOLLAPSE 2
// Default text sizes
#define GUI_TEXT_SIZE_SMALL (GUI_GRID_H * 0.8)
#define GUI_TEXT_SIZE_MEDIUM (GUI_GRID_H * 1)
#define GUI_TEXT_SIZE_LARGE (GUI_GRID_H * 1.2)
// Pixel grid
#define pixelScale 0.50
#define GRID_W (pixelW * pixelGrid * pixelScale)
#define GRID_H (pixelH * pixelGrid * pixelScale)
class ScrollBar;
class RscObject;
class RscText;
class RscTextSmall;
class RscTitle;
class RscProgress;
class RscProgressNotFreeze;
class RscPicture;
class RscLadderPicture;
class RscPictureKeepAspect;
class RscHTML;
class RscButton;
class RscShortcutButton;
class RscButtonSmall;
class RscEdit;
class RscCombo;
class RscListBox;
class RscListNBox;
class RscXListBox;
class RscTree;
class RscSlider;
class RscSliderH;
class RscXSliderH;
class RscActiveText;
class RscStructuredText;
class RscControlsGroup;
class RscToolbox;
class RscMapControl;
class RscCheckBox;
class RscFrame;
class ctrlDefault;
class ctrlControlsGroup;
class ctrlDefaultText;
class ctrlDefaultButton;
class RscBackgroundStripeTop;
class RscBackgroundStripeBottom;
class RscIGText;
class RscIGProgress;
class RscListBoxKeys;
class RscControlsGroupNoScrollbars;
class RscControlsGroupNoHScrollbars;
class RscControlsGroupNoVScrollbars;
class RscLine;
class RscActivePicture;
class RscButtonTextOnly;
class RscShortcutButtonMain;
class RscButtonEditor;
class RscIGUIShortcutButton;
class RscGearShortcutButton;
class RscButtonMenu;
class RscButtonMenuOK;
class RscButtonMenuCancel;
class RscButtonMenuSteam;
class RscLoadingText;
class RscIGUIListBox;
class RscIGUIListNBox;
class RscBackground;
class RscBackgroundGUI;
class RscBackgroundGUILeft;
class RscBackgroundGUIRight;
class RscBackgroundGUIBottom;
class RscBackgroundGUITop;
class RscBackgroundGUIDark;
class RscBackgroundLogo;
class RscMapControlEmpty;
class RscVignette;
class CA_Mainback;
class CA_Back;
class CA_Title_Back;
class CA_Black_Back;
class CA_Title;
class CA_Logo;
class CA_Logo_Small;
class CA_RscButton;
class CA_RscButton_dialog;
class CA_Ok;
class CA_Ok_image;
class CA_Ok_image2;
class CA_Ok_text;
class ctrlCheckbox;
class ctrlCheckboxBaseline;
class ctrlStatic;
class ctrlControlsGroupNoScrollbars;
class ctrlStructuredText;
class RscTextMulti;
class RscTreeSearch;
class RscVideo;
class RscVideoKeepAspect;
class RscActivePictureKeepAspect;
class RscEditMulti;
class RscMapSignalBackground;
class RscMapSignalPicture;
class RscMapSignalText;
class RscColorPicker;
class RscInterlacingScreen;
class RscFeedback;
class RscTrafficLight;
class RscButtonSearch;
class RscIGUIText;
class RscOpticsText;
class RscOpticsValue;
class RscIGUIValue;
class RscButtonMenuMain;
class RscButtonTestCentered;
class RscDisplaySingleMission_ChallengeOverviewGroup;
class RscDisplayDebriefing_RscTextMultiline;
class RscDisplayDebriefing_ListGroup;
class RscButtonArsenal;
class RscTextNoShadow;
class RscButtonNoColor;
class RscToolboxButton;
class ctrlStaticPicture;
class ctrlStaticPictureKeepAspect;
class ctrlStaticPictureTile;
class ctrlStaticFrame;
class ctrlStaticLine;
class ctrlStaticMulti;
class ctrlStaticBackground;
class ctrlStaticOverlay;
class ctrlStaticTitle;
class ctrlStaticFooter;
class ctrlStaticBackgroundDisable;
class ctrlStaticBackgroundDisableTiles;
class ctrlButton;
class ctrlButtonPicture;
class ctrlButtonPictureKeepAspect;
class ctrlButtonOK;
class ctrlButtonCancel;
class ctrlButtonClose;
class ctrlButtonToolbar;
class ctrlButtonSearch;
class ctrlButtonExpandAll;
class ctrlButtonCollapseAll;
class ctrlButtonFilter;
class ctrlEdit;
class ctrlEditMulti;
class ctrlSliderV;
class ctrlSliderH;
class ctrlCombo;
class ctrlComboToolbar;
class ctrlListbox;
class ctrlToolbox;
class ctrlToolboxPicture;
class ctrlToolboxPictureKeepAspect;
class ctrlCheckboxes;
class ctrlCheckboxesCheckbox;
class ctrlProgress;
class ctrlHTML;
class ctrlActiveText;
class ctrlActivePicture;
class ctrlActivePictureKeepAspect;
class ctrlTree;
class ctrlControlsGroupNoHScrollbars;
class ctrlControlsGroupNoVScrollbars;
class ctrlShortcutButton;
class ctrlShortcutButtonOK;
class ctrlShortcutButtonCancel;
class ctrlShortcutButtonSteam;
class ctrlXListbox;
class ctrlXSliderV;
class ctrlXSliderH;
class ctrlMenu;
class ctrlMenuStrip;
class ctrlMap;
class ctrlMapEmpty;
class ctrlMapMain;
class ctrlListNBox;
class ctrlCheckboxToolbar;

View File

@ -0,0 +1,22 @@
class RscPhone {
idd = 1000;
movingEnable = 1;
enableSimulation = 1;
duration = 1e011;
fadeIn = 0;
fadeOut = 0;
onLoad = "uiNamespace setVariable ['RscPhone', _this select 0]";
class controlsBackground {};
class controls {
class Background: RscText {
type = 106;
idc = 1001;
x = "safezoneX + (safezoneW * 0.4125)";
y = "safezoneY + (safezoneH * 0.1)";
w = "safezoneW * 1";
h = "safezoneH * 1";
colorBackground[] = {0, 0, 0, 0};
};
};
};

View File

@ -0,0 +1,156 @@
# Phone UI Framework
A lightweight, component-based framework for building phone-like user interfaces in the browser. This framework provides a React-like development experience without external dependencies, making it perfect for creating mobile-first web applications.
## Features
- Component-based architecture (React-like API)
- Virtual DOM-like rendering system
- Built-in global and local state management
- Modular, maintainable CSS structure
- Mobile-first, accessible design (ARIA roles/labels)
- No external dependencies
- Easy production bundling (JS & CSS)
## Getting Started
1. Clone the repository
2. **On Windows, run the provided script to build and start the local server:**
```powershell
./start.ps1
```
This will automatically build the JS and CSS bundles and open the app in your browser at [http://localhost:8000](http://localhost:8000).
3. **On Linux/macOS, run the provided shell script:**
```sh
chmod +x start.sh
./start.sh
```
This will automatically build the JS and CSS bundles and open the app in your browser at [http://localhost:8000](http://localhost:8000).
4. If you prefer, you can run the build manually with `node tools/concat-all.js` and start a local server (e.g., `python3 -m http.server`).
> **Note:** The app will not work unless you run the build script. Always re-run the build script if you add, remove, or change any JS or CSS files.
## Project Structure
```
├── index.html # Main entry point
├── dist/ # Production bundles (auto-generated)
│ ├── app.bundle.js
│ └── app.bundle.css
├── styles/ # CSS files
│ ├── base.css
│ ├── main.css
│ └── components/ # Component-specific styles
├── js/ # JavaScript files
│ ├── core/ # Core framework (Component, StateManager)
│ ├── components/ # Shared UI components
│ ├── apps/ # App modules (phone, messages, contacts, settings)
│ ├── utils/ # Utility functions (scriptLoader, helpers)
│ ├── app.js # Main app integration/root
│ └── main.js # App initialization
├── tools/ # Build and utility scripts
│ ├── concat-js.js
│ ├── concat-css.js
│ └── concat-all.js
├── start.ps1 # Windows script to build and start local server
├── start.sh # Linux/macOS script to build and start local server
└── images/ # Image assets
```
## App Structure
- **Main App (`App` class in `js/app.js`)**: Handles app switching, global modals, and integration.
- **Apps (`js/apps/`)**: Each app (Phone, Messages, Contacts, Settings) has its own entry point (`index.js`) and components.
- **Components (`js/components/` and app subfolders)**: Reusable UI elements (NavigationBar, Modal, StatusBar, etc.).
- **State Management (`js/core/StateManager.js`)**: Global state via `globalState`, plus local state in components.
- **Utilities (`js/utils/`)**: Script loader, helpers, etc.
## Creating Components
Components are created by extending the base `Component` class:
```javascript
class MyComponent extends Component {
constructor(props) {
super(props);
this.state = { /* ... */ };
}
render() {
return this.createElement('div', { className: 'my-component' }, 'Hello World');
}
}
```
### Component Lifecycle
- `constructor(props)`: Initialize component
- `render()`: Define component structure
- `componentDidMount()`: Called after mount
- `componentWillUnmount()`: Called before unmount
- `onStateChange(prevState, newState)`: On state change
### State Management
- Local: `this.setState({ ... })`
- Global: `globalState.setState({ ... })`, `globalState.subscribe(cb)`
## Creating Elements
Use `createElement` to create DOM elements:
```javascript
this.createElement('div', { className: 'container', onClick: ... }, 'Content');
```
## Styling
- Base styles: `base.css`, `main.css`
- Component styles: `styles/components/`
- For all environments, use the bundled `dist/app.bundle.css`
## Available Components
- `StatusBar`, `NavigationBar`, `Modal`, `HomeScreen`, `HomeIndicator`, `Header`, `SearchBar`
- App-specific: `ContactList`, `ContactItem`, `AddContactForm`, `MessagesList`, `MessageItem`, `ConversationView`, `Dialpad`, `Settings`
## Scripts
- `tools/concat-js.js`: Bundles all JS files into `dist/app.bundle.js`
- `tools/concat-css.js`: Bundles all CSS files into `dist/app.bundle.css`
- `tools/concat-all.js`: Bundles both JS and CSS (**required for all environments**)
- `start.ps1`: Builds and starts a local server on Windows
- `start.sh`: Builds and starts a local server on Linux/macOS
## How to Add a New App
1. Create a new folder in `js/apps/yourapp/` with an `index.js` and any components.
2. Add your app's entry point to the bundler scripts and (if needed) to the app switch logic in `js/app.js`.
3. Add styles in `styles/components/yourapp.css` and include in the CSS bundle list.
4. **Re-run the build script after any changes.**
## Best Practices
1. Keep components small and focused
2. Use state management for global data
3. Follow the component lifecycle
4. Use modular CSS for styling
5. Handle cleanup in `componentWillUnmount`
6. Use ARIA roles/labels for accessibility
## Development & Production
- **Always run the build script (`node tools/concat-all.js`, `./start.ps1`, or `./start.sh`) before starting or deploying the app.**
- The app will not work unless all JS and CSS are bundled.
- If you encounter issues, re-run the build script to ensure all files are up to date.
## Contributing
1. Fork the repository
2. Create your feature branch
3. Commit your changes
4. Push to the branch
5. Create a Pull Request
## License
This project is licensed under the MIT License.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script>
Promise.all([
// Load CSS file
A3API.RequestFile("forge\\forge_client\\addons\\phone\\ui\\_site\\dist\\app.bundle.css"),
// Load JavaScript file
A3API.RequestFile("forge\\forge_client\\addons\\phone\\ui\\_site\\dist\\app.bundle.js")
]).then(([css, js]) => {
// Apply CSS
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
// Load and execute JavaScript
const script = document.createElement('script');
script.text = js;
document.head.appendChild(script);
// Initialize the phone interface
initializeApp();
});
</script>
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@ -0,0 +1,259 @@
/**
* @fileoverview Root application component and integration logic.
*
* The App class manages:
* - Switching between different app modules (home, phone, messages, contacts, settings)
* - Rendering the correct app UI based on global state
* - Handling global modals (e.g., call confirmation)
* - Integrating shared UI elements (status bar, home indicator, dynamic island)
*
* Each app module is initialized via its global function (e.g., window.initializePhoneApp) and mounted into the app container.
* The placeholder app view is shown for unimplemented apps.
*
* This is the main entry point for the phone UI framework.
*/
/**
* @class App
* @extends Component
* @description The root component that manages app switching and integration
*/
class App extends Component {
/**
* @constructor
* Initializes state and subscribes to global state changes.
*/
constructor(props = {}) {
super(props);
this.state = {
...globalState.getState(),
currentApp: 'home',
showAddContactForm: false
};
this.unsubscribe = null;
}
/**
* Subscribe to global state changes after mounting
* @lifecycle
*/
componentDidMount() {
this.unsubscribe = globalState.subscribe((newState) => {
this.setState(newState);
});
}
/**
* Clean up subscriptions before unmounting
* @lifecycle
*/
componentWillUnmount() {
if (this.unsubscribe) {
this.unsubscribe();
}
}
/**
* Render the current app based on app state
* @returns {HTMLElement} Current app view
* @private
*/
renderCurrentApp() {
const { currentApp } = this.state;
const appContainer = this.createElement('div', { className: 'app-container' });
switch (currentApp) {
case 'clock':
window.initializeClockApp(appContainer);
break;
case 'calendar':
window.initializeCalendarApp(appContainer);
break;
case 'home':
return new HomeScreen();
case 'phone':
window.initializePhoneApp(appContainer);
break;
case 'messages':
window.initializeMessagesApp(appContainer);
break;
case 'mail':
window.initializeMailApp(appContainer);
break;
case 'notes':
window.initializeNotesApp(appContainer);
break;
case 'contacts':
window.initializeContactsApp(appContainer);
break;
case 'settings':
window.initializeSettingsApp(appContainer);
break;
default:
return this.renderPlaceholderApp(currentApp);
}
return appContainer;
}
/**
* Render a placeholder for unimplemented apps
* @param {string} appName - App name
* @returns {HTMLElement} Placeholder app view
* @private
*/
renderPlaceholderApp(appName) {
const appIcons = {
calendar: '',
camera: '',
store: '',
mail: '',
icloud: '',
photos: '',
safari: ''
};
return this.createElement(
'div',
{ className: 'app-container' },
new NavigationBar({ title: appName }),
this.createElement(
'div',
{ className: 'content' },
this.createElement(
'div',
{
style: {
textAlign: 'center',
padding: '50px 20px',
color: '#6c757d',
},
},
this.createElement('h2', { role: 'img', 'aria-label': appName }, appIcons[appName] || ''),
this.createElement('p', {}, `${appName} app coming soon!`)
)
)
);
}
/**
* Render the phone app UI, including status bar, main content, home indicator, and modals.
* @returns {HTMLElement} The rendered phone app
*/
render() {
const { currentApp, selectedContact, showModal, showDeleteModal, noteToDelete, eventToDelete } = this.state;
return this.createElement(
'div',
{
className: 'phone-container',
role: 'application',
'aria-label': 'Phone interface',
},
this.createElement(
'div',
{
className: 'phone-screen dynamic-island',
role: 'main',
},
// Dynamic Island content
this.createElement(
'div',
{
className: 'dynamic-island-content',
'aria-hidden': 'true',
},
this.createElement('div', { className: 'speaker' }),
this.createElement('div', { className: 'camera' })
),
// Status bar
new StatusBar(),
// Main app content
this.renderCurrentApp(),
// Home indicator (except on home screen)
currentApp !== 'home' && new HomeIndicator(),
// Call modal
showModal && selectedContact && new Modal({
show: showModal,
title: `Call ${selectedContact.name}?`,
onClose: () => globalState.setState({ showModal: false, selectedContact: null }),
onConfirm: () => {
globalState.setState({
phoneNumber: selectedContact.phone,
showModal: false,
selectedContact: null,
currentApp: 'phone'
});
},
children: [this.createElement('p', { role: 'alert' }, `Do you want to call ${selectedContact.name} at ${selectedContact.phone}?`)]
}),
// Delete note confirmation modal
showDeleteModal && noteToDelete && new Modal({
show: showDeleteModal,
title: `Delete "${noteToDelete.title}"?`,
confirmText: 'Delete',
cancelText: 'Cancel',
onClose: () => globalState.setState({ showDeleteModal: false, noteToDelete: null }),
onConfirm: () => {
// Find the onDelete handler from the notes editor and call it
const currentState = globalState.getState();
const currentNotes = currentState.notes || [];
const updatedNotes = currentNotes.filter(n => n.id !== noteToDelete.id);
globalState.setState({
notes: updatedNotes,
currentNote: null,
showNoteEditor: false,
showDeleteModal: false,
noteToDelete: null
});
// Delete from server
if (typeof deleteNote === 'function') {
deleteNote(noteToDelete.id);
}
console.log('Note deleted:', noteToDelete.id);
},
children: [this.createElement('p', { role: 'alert' }, `Are you sure you want to delete this note? This action cannot be undone.`)]
}),
showDeleteModal && eventToDelete && new Modal({
show: showDeleteModal,
title: `Delete "${eventToDelete.title}"?`,
confirmText: 'Delete',
cancelText: 'Cancel',
onClose: () => globalState.setState({ showDeleteModal: false, eventToDelete: null }),
onConfirm: () => {
// Find the onDelete handler from the events editor and call it
const currentState = globalState.getState();
const currentEvents = currentState.events || [];
const updatedEvents = currentEvents.filter(n => n.id !== eventToDelete.id);
globalState.setState({
events: updatedEvents,
currentEvent: null,
showEventEditor: false,
showDeleteModal: false,
eventToDelete: null
});
// Delete from server
if (typeof deleteCalendarEvent === 'function') {
deleteCalendarEvent(eventToDelete.id);
}
console.log('Event deleted:', eventToDelete.id);
},
children: [this.createElement('p', { role: 'alert' }, `Are you sure you want to delete this event? This action cannot be undone.`)]
})
)
);
}
}

View File

@ -0,0 +1,191 @@
/**
* @format
* @fileoverview Calendar component for displaying and managing calendar events
*/
class Calendar extends Component {
constructor(props = {}) {
super(props);
let selectedDate = props.selectedDate;
if (!(selectedDate instanceof Date) || isNaN(selectedDate.getTime())) {
selectedDate = new Date();
}
this.state = {
currentDate: props.selectedDate || new Date(),
selectedDate: props.selectedDate || new Date(),
events: props.events || [],
};
this.onEventClick = props.onEventClick;
this.onDayClick = props.onDayClick;
this.handleDayClick = this.handleDayClick.bind(this);
this.handleEventClick = this.handleEventClick.bind(this);
}
/**
* Called when the component is first mounted to the DOM.
* Ensures the initial view is rendered.
*/
componentDidMount() {
this.render(); // Initial render after component is mounted
}
/**
* Called when the component's state or props change.
* Updates the component if necessary.
*/
componentDidUpdate(prevProps, prevState) {
// Re-render if selectedDate or events have changed significantly
if (
prevState.selectedDate.toDateString() !== this.state.selectedDate.toDateString() ||
JSON.stringify(prevState.events) !== JSON.stringify(this.state.events) ||
prevState.currentDate.toDateString() !== this.state.currentDate.toDateString()
) {
this.render();
}
}
render() {
const { currentDate } = this.state;
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
return this.createElement(
'div',
{ className: 'calendar-container' },
this.createElement('div', { className: 'calendar-header' }, this.createElement('div', { className: 'calendar-title' }, `${this.getMonthName(month)} ${year}`)),
this.createElement('div', { className: 'calendar-grid' }, this.renderWeekdays(), this.renderDays(year, month)),
this.createElement('div', { className: 'calendar-events' }, this.renderEvents())
);
}
renderWeekdays() {
const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return weekdays.map((day) => this.createElement('div', { className: 'calendar-weekday' }, day));
}
renderDays(year, month) {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startingDay = firstDay.getDay();
const totalDays = lastDay.getDate();
let days = [];
// Previous month's days (empty placeholders or actual days if needed, currently empty for visual alignment)
for (let i = 0; i < startingDay; i++) {
days.push(this.createElement('div', { className: 'calendar-day other-month' }));
}
// Current month's days
for (let day = 1; day <= totalDays; day++) {
const date = new Date(year, month, day);
const isToday = this.isToday(date);
const isSelected = this.isSelected(date);
const hasEvents = this.hasEvents(date);
let classes = ['calendar-day'];
if (isToday) classes.push('today');
if (isSelected) classes.push('selected');
if (hasEvents) classes.push('has-events');
days.push(
this.createElement(
'div',
{
className: classes.join(' '),
'data-date': date.toISOString(),
onClick: () => this.handleDayClick(date),
},
day
)
);
}
// Next month's days (empty placeholders for visual alignment)
const remainingCells = 42 - days.length; // 42 = 6 rows * 7 days
for (let i = 0; i < remainingCells; i++) {
days.push(this.createElement('div', { className: 'calendar-day other-month' }));
}
return days;
}
renderEvents() {
const events = this.getEventsForDate(this.state.selectedDate);
if (!events || events.length === 0) {
return this.createElement('div', { className: 'no-events' }, 'No events for this day');
}
return events.map((event) =>
this.createElement(
'div',
{
className: 'event-item',
'data-event-id': event.id,
onClick: () => this.handleEventClick(event),
},
this.createElement('div', { className: 'event-dot' }),
this.createElement('div', { className: 'event-time' }, this.formatTime(event.startTime)),
this.createElement('div', { className: 'event-title' }, event.title)
)
);
}
handleDayClick(date) {
this.setState({ selectedDate: date });
if (this.onDayClick) {
this.onDayClick(date);
}
}
handleEventClick(event) {
if (this.onEventClick) {
this.onEventClick(event);
}
}
getEventsForDate(date) {
const dateKey = this.getDateKey(date);
return this.state.events.filter((event) => {
const eventStartDate = new Date(event.startTime);
return this.getDateKey(eventStartDate) === dateKey;
});
}
hasEvents(date) {
return this.getEventsForDate(date).length > 0;
}
getDateKey(date) {
return date.toISOString().split('T')[0];
}
isToday(date) {
const today = new Date();
return date.toDateString() === today.toDateString();
}
isSelected(date) {
return date.toDateString() === this.state.selectedDate.toDateString();
}
getMonthName(month) {
return new Date(2000, month, 1).toLocaleString('default', { month: 'long' });
}
formatTime(time) {
return new Date(time).toLocaleTimeString('default', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
}
}

View File

@ -0,0 +1,290 @@
/**
* @format
* @class EventEditor
* @extends Component
* @description A component for creating and editing calendar events.
*/
class EventEditor extends Component {
/**
* @constructor
* @param {Object} props - Component properties
* @param {Object} [props.event] - Existing event to edit
* @param {Function} props.onSave - Callback when event is saved
* @param {Function} props.onCancel - Callback when editing is cancelled
* @param {Function} [props.onDelete] - Callback when event is deleted
*/
constructor(props = {}) {
super(props);
const existingEvent = props.event || {
title: '',
startTime: new Date(),
endTime: new Date(new Date().getTime() + 60 * 60 * 1000),
description: '',
};
this.state = {
title: existingEvent.title || '',
startTime: this.formatDateTimeForInput(existingEvent.startTime),
endTime: this.formatDateTimeForInput(existingEvent.endTime),
description: existingEvent.description || '',
id: existingEvent.id || null,
isModified: false,
};
// References for DOM elements
this.titleInputRef = null;
this.startTimeInputRef = null;
this.endTimeInputRef = null;
this.descriptionInputRef = null;
// Bind methods
this.handleTitleChange = this.handleTitleChange.bind(this);
this.handleStartTimeChange = this.handleStartTimeChange.bind(this);
this.handleEndTimeChange = this.handleEndTimeChange.bind(this);
this.handleDescriptionChange = this.handleDescriptionChange.bind(this);
this.handleSave = this.handleSave.bind(this);
this.handleCancel = this.handleCancel.bind(this);
this.handleDelete = this.handleDelete.bind(this);
this.setTitleInputRef = this.setTitleInputRef.bind(this);
this.setStartTimeInputRef = this.setStartTimeInputRef.bind(this);
this.setEndTimeInputRef = this.setEndTimeInputRef.bind(this);
this.setDescriptionInputRef = this.setDescriptionInputRef.bind(this);
}
/**
* Component mounted - focus on title if new event
*/
componentDidMount() {
if (!this.state.id && this.titleInputRef) {
this.titleInputRef.focus();
}
}
// Ref setter methods
setTitleInputRef(element) {
if (element) {
this.titleInputRef = element;
if (this.state.title && element.value !== this.state.title) {
element.value = this.state.title;
}
}
}
setStartTimeInputRef(element) {
if (element) {
this.startTimeInputRef = element;
if (this.state.startTime && element.value !== this.state.startTime) {
element.value = this.state.startTime;
}
}
}
setEndTimeInputRef(element) {
if (element) {
this.endTimeInputRef = element;
if (this.state.endTime && element.value !== this.state.endTime) {
element.value = this.state.endTime;
}
}
}
setDescriptionInputRef(element) {
if (element) {
this.descriptionInputRef = element;
if (this.state.description && element.value !== this.state.description) {
element.value = this.state.description;
}
}
}
// Input change handlers
handleTitleChange(e) {
this.state.title = e.target.value;
this.state.isModified = true;
}
handleStartTimeChange(e) {
this.state.startTime = e.target.value;
this.state.isModified = true;
}
handleEndTimeChange(e) {
this.state.endTime = e.target.value;
this.state.isModified = true;
}
handleDescriptionChange(e) {
this.state.description = e.target.value;
this.state.isModified = true;
}
handleSave() {
const { title, startTime, endTime, description, id } = this.state;
// if (!title.trim() || !startTime || !endTime) {
// alert('Please fill in all required fields.');
// return;
// }
const savedEvent = {
id: id || generateId(),
title: title.trim(),
startTime: new Date(startTime),
endTime: new Date(endTime),
description: description.trim(),
};
this.setState({
isModified: false,
id: savedEvent.id,
});
if (this.props.onSave) {
this.props.onSave(savedEvent);
}
}
handleCancel() {
if (this.props.onCancel) {
this.props.onCancel();
}
}
handleDelete() {
if (!this.state.id) {
console.warn('Cannot delete event: no ID present');
return;
}
if (!this.props.onDelete) {
console.warn('Cannot delete event: no onDelete callback provided');
return;
}
try {
// Show delete confirmation modal using global state
globalState.setState({
showDeleteModal: true,
eventToDelete: {
id: this.state.id,
title: this.state.title || 'Untitled',
},
});
} catch (error) {
console.error('Error showing delete confirmation:', error);
}
}
formatDateTimeForInput(date) {
// Make sure date is a valid Date object
if (!(date instanceof Date) || isNaN(date.getTime())) {
// If it's a string that looks like a date, try to parse it
if (typeof date === 'string') {
date = new Date(date);
}
// If still not valid, return current time
if (!(date instanceof Date) || isNaN(date.getTime())) {
date = new Date();
}
}
return date.toISOString().slice(0, 16); // Format: YYYY-MM-DDTHH:mm
}
render() {
const { title, startTime, endTime, description, id } = this.state;
return this.createElement(
'div',
{ className: 'event-editor' },
// Navigation bar
new NavigationBar({
title: id ? 'Edit Event' : 'New Event',
leftButton: {
element: 'button',
props: {
className: 'nav-button cancel-button',
onClick: this.handleCancel,
'aria-label': 'Cancel',
},
content: 'Cancel',
},
rightButton: {
element: 'button',
props: {
className: 'nav-button save-button',
onClick: this.handleSave,
'aria-label': 'Save event',
},
content: 'Save',
},
}),
// Editor content
this.createElement(
'div',
{ className: 'event-form' },
// Title input
this.createElement('input', {
type: 'text',
className: 'event-title-input',
placeholder: 'Event title...',
value: title,
onInput: this.handleTitleChange,
ref: this.setTitleInputRef,
required: true,
}),
// Time inputs container
this.createElement(
'div',
{ className: 'time-container' },
// Start time input
this.createElement('input', {
type: 'datetime-local',
className: 'time-input',
value: startTime,
onInput: this.handleStartTimeChange,
ref: this.setStartTimeInputRef,
required: true,
}),
// End time input
this.createElement('input', {
type: 'datetime-local',
className: 'time-input',
value: endTime,
onInput: this.handleEndTimeChange,
ref: this.setEndTimeInputRef,
required: true,
})
),
// Description textarea
this.createElement('textarea', {
className: 'event-description-input',
placeholder: 'Add description...',
value: description,
onInput: this.handleDescriptionChange,
ref: this.setDescriptionInputRef,
}),
// Delete button (only for existing events)
id &&
this.createElement(
'button',
{
type: 'button',
className: 'delete-event-button',
onClick: this.handleDelete,
},
'Delete Event'
)
)
);
}
}

View File

@ -0,0 +1,118 @@
/**
* @fileoverview Main entry point for the Calendar application
*
* This module initializes the Calendar app UI, including:
* - Displaying the calendar view
* - Handling event creation, editing, and deletion via EventEditor
* - Managing event persistence via A3API
*/
/**
* Initializes and mounts the Calendar application.
* @param {HTMLElement} container - The DOM element to mount the app into.
*/
function initializeCalendarApp(container) {
const { events = [], selectedDate = new Date(), showEventEditor = false, currentEvent = null } = globalState.getState();
const appContainer = document.createElement('div');
appContainer.className = 'app-container';
appContainer.setAttribute('role', 'main');
appContainer.setAttribute('aria-label', 'Calendar');
// Check if we're viewing/editing a specific event
if (showEventEditor || currentEvent) {
// Show event editor
const eventEditor = new EventEditor({
event: currentEvent,
onSave: (savedEvent) => {
const currentEvents = globalState.getState().events || [];
let updatedEvents;
if (savedEvent.id && currentEvents.find(e => e.id === savedEvent.id)) {
// Update existing event
updatedEvents = currentEvents.map(e => e.id === savedEvent.id ? savedEvent : e);
} else {
// Add new event
updatedEvents = [savedEvent, ...currentEvents];
}
globalState.setState({
events: updatedEvents,
currentEvent: null,
showEventEditor: false
});
// Save to server
if (typeof saveCalendarEvent === 'function') {
saveCalendarEvent(savedEvent);
}
},
onCancel: () => {
globalState.setState({
currentEvent: null,
showEventEditor: false
});
},
onDelete: (eventId) => {
const currentEvents = globalState.getState().events || [];
const updatedEvents = currentEvents.filter(e => e.id !== eventId);
globalState.setState({
events: updatedEvents,
currentEvent: null,
showEventEditor: false
});
// Delete from server
if (typeof deleteCalendarEvent === 'function') {
deleteCalendarEvent(eventId);
}
}
});
eventEditor.mount(appContainer);
} else {
// Show calendar view
const navBar = new NavigationBar({
title: 'Calendar',
rightButton: {
element: 'button',
props: {
className: 'nav-button add-event-button',
onClick: () => {
globalState.setState({
showEventEditor: true,
currentEvent: null
});
},
'aria-label': 'Add Event'
},
content: '+'
}
});
navBar.mount(appContainer);
const calendar = new Calendar({
selectedDate: selectedDate,
events: events,
onDayClick: (date) => {
globalState.setState({
selectedDate: date,
currentEvent: null,
showEventEditor: false
});
},
onEventClick: (event) => {
globalState.setState({
currentEvent: event,
showEventEditor: true
});
}
});
calendar.mount(appContainer);
}
container.appendChild(appContainer);
}
// Make initialization function globally available
window.initializeCalendarApp = initializeCalendarApp;

View File

@ -0,0 +1,218 @@
/**
* @format
* @class AlarmClock
* @extends Component
* @description A component for managing alarms.
*/
class AlarmClock extends Component {
/**
* @constructor
* @param {Object} props - Component properties
*/
constructor(props = {}) {
super(props);
this.state = {
showAddForm: false,
newAlarmTime: '07:00',
newAlarmLabel: ''
};
// Bind methods
this.toggleAddForm = this.toggleAddForm.bind(this);
this.handleAddAlarm = this.handleAddAlarm.bind(this);
this.formatTime = this.formatTime.bind(this);
}
/**
* Toggle add alarm form
*/
toggleAddForm() {
// Use setState for form visibility changes as they need re-render
this.setState({
showAddForm: !this.state.showAddForm,
newAlarmTime: '07:00',
newAlarmLabel: ''
});
}
/**
* Handle adding a new alarm
*/
handleAddAlarm() {
const newAlarmTime = this.state.newAlarmTime;
const newAlarmLabel = this.state.newAlarmLabel;
if (newAlarmTime && this.props.onAddAlarm) {
this.props.onAddAlarm({
time: newAlarmTime,
label: newAlarmLabel || 'Alarm',
enabled: true,
days: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'] // Default to weekdays
});
// Use setState to hide form and reset state
this.setState({
showAddForm: false,
newAlarmTime: '07:00',
newAlarmLabel: ''
});
}
}
/**
* Format time for display
*/
formatTime(timeString) {
const [hours, minutes] = timeString.split(':');
if (this.props.format24h) {
return `${hours}:${minutes}`;
} else {
const hour = parseInt(hours);
const ampm = hour >= 12 ? 'PM' : 'AM';
const displayHour = hour % 12 || 12;
return `${displayHour}:${minutes} ${ampm}`;
}
}
/**
* Render add alarm form
*/
renderAddForm() {
if (!this.state.showAddForm) return null;
return this.createElement(
'div',
{ className: 'add-alarm-form' },
this.createElement('h3', {}, 'Add Alarm'),
this.createElement('input', {
type: 'time',
value: this.state.newAlarmTime,
onChange: (e) => {
// Update state directly to avoid re-render during input
this.state.newAlarmTime = e.target.value;
}
}),
this.createElement('input', {
type: 'text',
placeholder: 'Alarm label (optional)',
value: this.state.newAlarmLabel,
onChange: (e) => {
// Update state directly to avoid re-render during input
this.state.newAlarmLabel = e.target.value;
}
}),
this.createElement(
'div',
{ className: 'form-buttons' },
this.createElement(
'button',
{ onClick: this.toggleAddForm },
'Cancel'
),
this.createElement(
'button',
{ onClick: this.handleAddAlarm },
'Add Alarm'
)
)
);
}
/**
* Render alarms list
*/
renderAlarms() {
const { alarms } = this.props;
if (!alarms || alarms.length === 0) {
return this.createElement(
'div',
{ className: 'empty-state' },
this.createElement('p', {}, 'No alarms set. Tap + to add one.')
);
}
return this.createElement(
'div',
{ className: 'alarms-list' },
...alarms.map(alarm =>
this.createElement(
'div',
{
className: `alarm-item ${alarm.enabled ? 'enabled' : 'disabled'}`,
key: alarm.id
},
this.createElement(
'div',
{ className: 'alarm-info' },
this.createElement(
'div',
{ className: 'alarm-time' },
this.formatTime(alarm.time)
),
this.createElement(
'div',
{ className: 'alarm-label' },
alarm.label
),
alarm.days && this.createElement(
'div',
{ className: 'alarm-days' },
alarm.days.join(', ')
)
),
this.createElement(
'div',
{ className: 'alarm-controls' },
this.createElement(
'button',
{
className: 'toggle-alarm',
onClick: () => this.props.onToggleAlarm(alarm.id)
},
alarm.enabled ? 'On' : 'Off'
),
this.createElement(
'button',
{
className: 'remove-alarm',
onClick: () => this.props.onRemoveAlarm(alarm.id),
'aria-label': 'Delete alarm'
},
'Delete'
)
)
)
)
);
}
/**
* Render the alarm clock component
*/
render() {
return this.createElement(
'div',
{ className: 'alarm-clock' },
// Add alarm button
!this.state.showAddForm && this.createElement(
'button',
{
className: 'add-alarm-button',
onClick: this.toggleAddForm
},
'+ Add Alarm'
),
// Add alarm form
this.renderAddForm(),
// Alarms list
this.renderAlarms()
);
}
}

View File

@ -0,0 +1,311 @@
/**
* @format
* @class Stopwatch
* @extends Component
* @description A component that provides stopwatch functionality with lap timing.
*/
class Stopwatch extends Component {
/**
* @constructor
* @param {Object} props - Component properties
* @param {boolean} props.format24h - Whether to use 24-hour format
*/
constructor(props = {}) {
super(props);
this.state = {
time: 0, // Time in milliseconds
isRunning: false,
lapTimes: [],
startTime: null
};
// Bind methods
this.start = this.start.bind(this);
this.stop = this.stop.bind(this);
this.reset = this.reset.bind(this);
this.lap = this.lap.bind(this);
this.updateTime = this.updateTime.bind(this);
this.formatTime = this.formatTime.bind(this);
// Timer for updates
this.interval = null;
}
/**
* Component will unmount - clear intervals
*/
componentWillUnmount() {
if (this.interval) {
clearInterval(this.interval);
}
}
/**
* Start the stopwatch
*/
start() {
if (!this.state.isRunning) {
const startTime = Date.now() - this.state.time;
this.setState({
isRunning: true,
startTime: startTime
});
this.interval = setInterval(this.updateTime, 10); // Update every 10ms for precision
}
}
/**
* Stop the stopwatch
*/
stop() {
if (this.state.isRunning) {
this.setState({ isRunning: false });
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
}
/**
* Reset the stopwatch
*/
reset() {
this.setState({
time: 0,
isRunning: false,
lapTimes: [],
startTime: null
});
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
/**
* Record a lap time
*/
lap() {
if (this.state.isRunning) {
const currentTime = this.state.time;
const previousLapTime = this.state.lapTimes.length > 0
? this.state.lapTimes[this.state.lapTimes.length - 1].totalTime
: 0;
const lapTime = {
id: generateId(),
lapNumber: this.state.lapTimes.length + 1,
lapTime: currentTime - previousLapTime,
totalTime: currentTime,
timestamp: new Date().toISOString()
};
this.setState({
lapTimes: [...this.state.lapTimes, lapTime]
});
}
}
/**
* Update the current time
*/
updateTime() {
if (this.state.isRunning && this.state.startTime) {
const currentTime = Date.now() - this.state.startTime;
// Update state directly to avoid re-render during stopwatch running
this.state.time = currentTime;
// Update only the stopwatch time display element
const stopwatchDisplay = document.querySelector('.stopwatch-time');
if (stopwatchDisplay) {
stopwatchDisplay.textContent = this.formatTime(currentTime);
}
}
}
/**
* Format time for display (HH:MM:SS.mmm)
*/
formatTime(milliseconds) {
const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const ms = Math.floor((milliseconds % 1000) / 10); // Show centiseconds
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${ms.toString().padStart(2, '0')}`;
}
/**
* Get the fastest and slowest lap times
*/
getLapStats() {
if (this.state.lapTimes.length === 0) return null;
const lapTimes = this.state.lapTimes.map(lap => lap.lapTime);
const fastest = Math.min(...lapTimes);
const slowest = Math.max(...lapTimes);
return {
fastest: this.state.lapTimes.find(lap => lap.lapTime === fastest),
slowest: this.state.lapTimes.find(lap => lap.lapTime === slowest)
};
}
/**
* Render the main stopwatch display
*/
renderStopwatchDisplay() {
const { time, isRunning } = this.state;
return this.createElement(
'div',
{ className: 'stopwatch-display' },
this.createElement(
'div',
{
className: `stopwatch-time ${isRunning ? 'running' : 'stopped'}`,
'aria-live': 'polite',
'aria-label': 'Stopwatch time'
},
this.formatTime(time)
),
this.createElement(
'div',
{ className: 'stopwatch-status' },
isRunning ? 'Running' : (time > 0 ? 'Stopped' : 'Ready')
)
);
}
/**
* Render control buttons
*/
renderControls() {
const { isRunning, time } = this.state;
return this.createElement(
'div',
{ className: 'stopwatch-controls' },
// Start/Stop button
this.createElement(
'button',
{
className: `control-button ${isRunning ? 'stop-button' : 'start-button'}`,
onClick: isRunning ? this.stop : this.start,
'aria-label': isRunning ? 'Stop stopwatch' : 'Start stopwatch'
},
isRunning ? 'Stop' : 'Start'
),
// Lap button (only when running)
isRunning && this.createElement(
'button',
{
className: 'control-button lap-button',
onClick: this.lap,
'aria-label': 'Record lap time'
},
'Lap'
),
// Reset button (only when stopped and time > 0)
!isRunning && time > 0 && this.createElement(
'button',
{
className: 'control-button reset-button',
onClick: this.reset,
'aria-label': 'Reset stopwatch'
},
'Reset'
)
);
}
/**
* Render lap times list
*/
renderLapTimes() {
const { lapTimes } = this.state;
if (lapTimes.length === 0) {
return null;
}
const stats = this.getLapStats();
return this.createElement(
'div',
{ className: 'lap-times-section' },
this.createElement(
'h3',
{ className: 'lap-times-title' },
'Lap Times'
),
// Lap times list
this.createElement(
'div',
{ className: 'lap-times-list' },
...lapTimes.slice().reverse().map(lap => {
const isFastest = stats && lap.id === stats.fastest.id;
const isSlowest = stats && lap.id === stats.slowest.id && lapTimes.length > 1;
return this.createElement(
'div',
{
className: `lap-time-item ${
isFastest ? 'fastest' : isSlowest ? 'slowest' : ''
}`,
key: lap.id
},
this.createElement(
'div',
{ className: 'lap-number' },
`Lap ${lap.lapNumber}`
),
this.createElement(
'div',
{ className: 'lap-time' },
this.formatTime(lap.lapTime)
),
this.createElement(
'div',
{ className: 'total-time' },
this.formatTime(lap.totalTime)
),
(isFastest || isSlowest) && this.createElement(
'div',
{ className: 'lap-indicator' },
isFastest ? 'Fastest' : 'Slowest'
)
);
})
)
);
}
/**
* Render the stopwatch component
*/
render() {
return this.createElement(
'div',
{ className: 'stopwatch' },
// Main stopwatch display
this.renderStopwatchDisplay(),
// Control buttons
this.renderControls(),
// Lap times
this.renderLapTimes()
);
}
}

View File

@ -0,0 +1,245 @@
/**
* @format
* @class Timer
* @extends Component
* @description A countdown timer component.
*/
class Timer extends Component {
/**
* @constructor
* @param {Object} props - Component properties
*/
constructor(props = {}) {
super(props);
this.state = {
minutes: 5,
seconds: 0,
totalTime: 0,
timeLeft: 0,
isRunning: false,
isFinished: false
};
// Bind methods
this.start = this.start.bind(this);
this.pause = this.pause.bind(this);
this.reset = this.reset.bind(this);
this.setTime = this.setTime.bind(this);
this.updateTimer = this.updateTimer.bind(this);
this.formatTime = this.formatTime.bind(this);
// Timer interval
this.interval = null;
}
/**
* Component will unmount - clear intervals
*/
componentWillUnmount() {
if (this.interval) {
clearInterval(this.interval);
}
}
/**
* Set timer duration
*/
setTime(minutes, seconds) {
const totalSeconds = minutes * 60 + seconds;
this.setState({
minutes,
seconds,
totalTime: totalSeconds,
timeLeft: totalSeconds,
isFinished: false
});
}
/**
* Start the timer
*/
start() {
if (this.state.timeLeft > 0 && !this.state.isRunning) {
this.setState({ isRunning: true });
this.interval = setInterval(this.updateTimer, 1000);
}
}
/**
* Pause the timer
*/
pause() {
this.setState({ isRunning: false });
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
/**
* Reset the timer
*/
reset() {
this.setState({
timeLeft: this.state.totalTime,
isRunning: false,
isFinished: false
});
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
/**
* Update timer countdown
*/
updateTimer() {
if (this.state.timeLeft > 0) {
// Update state directly to avoid re-render during countdown
this.state.timeLeft = this.state.timeLeft - 1;
// Update only the timer display element
const timerDisplay = document.querySelector('.timer-time');
if (timerDisplay) {
timerDisplay.textContent = this.formatTime(this.state.timeLeft);
}
} else {
// Timer finished - this needs a full re-render
this.setState({
isRunning: false,
isFinished: true
});
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
}
/**
* Format time for display
*/
formatTime(totalSeconds) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
/**
* Render timer controls
*/
renderControls() {
const { isRunning, timeLeft, isFinished } = this.state;
return this.createElement(
'div',
{ className: 'timer-controls' },
// Start/Pause button
timeLeft > 0 && !isFinished && this.createElement(
'button',
{
className: `control-button ${isRunning ? 'pause-button' : 'start-button'}`,
onClick: isRunning ? this.pause : this.start
},
isRunning ? 'Pause' : 'Start'
),
// Reset button
(timeLeft !== this.state.totalTime || isFinished) && this.createElement(
'button',
{
className: 'control-button reset-button',
onClick: this.reset
},
'Reset'
)
);
}
/**
* Render time setters
*/
renderTimeSetters() {
if (this.state.isRunning) return null;
return this.createElement(
'div',
{ className: 'time-setters' },
this.createElement(
'div',
{ className: 'time-setter' },
this.createElement('label', {}, 'Minutes'),
this.createElement('input', {
type: 'number',
min: '0',
max: '59',
value: this.state.minutes,
onChange: (e) => {
// Update state directly to avoid re-render during input
const minutes = parseInt(e.target.value) || 0;
this.state.minutes = minutes;
this.setTime(minutes, this.state.seconds);
}
})
),
this.createElement(
'div',
{ className: 'time-setter' },
this.createElement('label', {}, 'Seconds'),
this.createElement('input', {
type: 'number',
min: '0',
max: '59',
value: this.state.seconds,
onChange: (e) => {
// Update state directly to avoid re-render during input
const seconds = parseInt(e.target.value) || 0;
this.state.seconds = seconds;
this.setTime(this.state.minutes, seconds);
}
})
)
);
}
/**
* Render the timer component
*/
render() {
const { timeLeft, isFinished } = this.state;
return this.createElement(
'div',
{ className: 'timer' },
// Timer display
this.createElement(
'div',
{ className: 'timer-display' },
this.createElement(
'div',
{
className: `timer-time ${isFinished ? 'finished' : ''}`,
'aria-live': 'polite'
},
this.formatTime(timeLeft)
),
this.createElement(
'div',
{ className: 'timer-status' },
isFinished ? 'Time\'s up!' : 'Timer'
)
),
// Time setters
this.renderTimeSetters(),
// Controls
this.renderControls()
);
}
}

View File

@ -0,0 +1,433 @@
/**
* @format
* @class WorldClock
* @extends Component
* @description A component that displays multiple world clocks for different time zones.
*/
class WorldClock extends Component {
/**
* @constructor
* @param {Object} props - Component properties
* @param {Array} props.clocks - Array of world clock objects
* @param {boolean} props.format24h - Whether to use 24-hour format
* @param {Function} props.onAddClock - Callback when adding a new clock
* @param {Function} props.onRemoveClock - Callback when removing a clock
*/
constructor(props = {}) {
super(props);
this.state = {
currentTime: new Date(),
showAddForm: false,
selectedTimezone: ''
};
// Bind methods
this.updateTime = this.updateTime.bind(this);
this.toggleAddForm = this.toggleAddForm.bind(this);
this.handleAddClock = this.handleAddClock.bind(this);
this.handleRemoveClock = this.handleRemoveClock.bind(this);
this.formatTime = this.formatTime.bind(this);
this.getTimezoneTime = this.getTimezoneTime.bind(this);
// Timer for real-time updates
this.timeUpdateInterval = null;
// Popular time zones
this.popularTimezones = [
'America/New_York',
'America/Los_Angeles',
'America/Chicago',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Asia/Tokyo',
'Asia/Shanghai',
'Asia/Kolkata',
'Australia/Sydney',
'Pacific/Auckland',
'Africa/Cairo',
'America/Sao_Paulo',
'Asia/Dubai',
'Europe/Moscow'
];
}
/**
* Component mounted - start time updates
*/
componentDidMount() {
this.timeUpdateInterval = setInterval(this.updateTime, 1000);
}
/**
* Component will unmount - clear intervals
*/
componentWillUnmount() {
if (this.timeUpdateInterval) {
clearInterval(this.timeUpdateInterval);
}
}
/**
* Update current time
*/
updateTime() {
// Update state directly to avoid re-render during time updates
this.state.currentTime = new Date();
const currentTime = this.state.currentTime;
// Update local time display
const localTimeElement = document.querySelector('.local-time');
if (localTimeElement) {
const timeOptions = {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: !this.props.format24h
};
localTimeElement.textContent = currentTime.toLocaleTimeString('en-US', timeOptions);
}
// Update all world clock time displays
const worldClockItems = document.querySelectorAll('.world-clock-item');
worldClockItems.forEach((clockItem, index) => {
const clockTimeElement = clockItem.querySelector('.clock-time');
const clockDateElement = clockItem.querySelector('.clock-date');
if (clockTimeElement && this.props.clocks && this.props.clocks[index]) {
const timezone = this.props.clocks[index].timezone;
// Update time
try {
const timeOptions = {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: !this.props.format24h
};
clockTimeElement.textContent = currentTime.toLocaleTimeString('en-US', timeOptions);
} catch (error) {
clockTimeElement.textContent = '--:--:--';
}
// Update date
if (clockDateElement) {
try {
const dateOptions = {
timeZone: timezone,
weekday: 'short',
month: 'short',
day: 'numeric'
};
clockDateElement.textContent = currentTime.toLocaleDateString('en-US', dateOptions);
} catch (error) {
clockDateElement.textContent = 'Invalid date';
}
}
}
});
}
/**
* Toggle add clock form
*/
toggleAddForm() {
// Use setState for form visibility changes as they need re-render
this.setState({
showAddForm: !this.state.showAddForm,
selectedTimezone: '' // Reset selection when toggling
});
}
/**
* Handle adding a new clock
*/
handleAddClock() {
const selectedTimezone = this.state.selectedTimezone;
if (selectedTimezone && this.props.onAddClock) {
this.props.onAddClock(selectedTimezone);
// Use setState to hide form and reset state
this.setState({
showAddForm: false,
selectedTimezone: ''
});
}
}
/**
* Handle removing a clock
*/
handleRemoveClock(clockId) {
if (this.props.onRemoveClock) {
this.props.onRemoveClock(clockId);
}
}
/**
* Get time for a specific timezone
*/
getTimezoneTime(timezone) {
try {
return new Date().toLocaleString('en-US', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: !this.props.format24h
});
} catch (error) {
return 'Invalid timezone';
}
}
/**
* Format time for display
*/
formatTime(date, timezone) {
try {
const options = {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: !this.props.format24h
};
return date.toLocaleTimeString('en-US', options);
} catch (error) {
return '--:--:--';
}
}
/**
* Get date for timezone
*/
getTimezoneDate(timezone) {
try {
return new Date().toLocaleDateString('en-US', {
timeZone: timezone,
weekday: 'short',
month: 'short',
day: 'numeric'
});
} catch (error) {
return 'Invalid date';
}
}
/**
* Render local time section
*/
renderLocalTime() {
const { currentTime } = this.state;
const { format24h } = this.props;
const timeOptions = {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: !format24h
};
const dateOptions = {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
};
return this.createElement(
'div',
{ className: 'local-time-section' },
this.createElement(
'h2',
{ className: 'local-time-label' },
'Local Time'
),
this.createElement(
'div',
{ className: 'local-time-display' },
this.createElement(
'div',
{ className: 'local-time' },
currentTime.toLocaleTimeString('en-US', timeOptions)
),
this.createElement(
'div',
{ className: 'local-date' },
currentTime.toLocaleDateString('en-US', dateOptions)
)
)
);
}
/**
* Render add clock form
*/
renderAddForm() {
if (!this.state.showAddForm) return null;
return this.createElement(
'div',
{ className: 'add-clock-form' },
this.createElement(
'h3',
{},
'Add World Clock'
),
this.createElement(
'select',
{
className: 'timezone-select',
value: this.state.selectedTimezone,
onChange: (e) => {
// Update state directly to avoid re-render during selection
this.state.selectedTimezone = e.target.value;
// Update button disabled state directly
const addButton = document.querySelector('.add-button');
if (addButton) {
addButton.disabled = !e.target.value;
}
}
},
this.createElement('option', { value: '' }, 'Select a timezone...'),
...this.popularTimezones.map(tz =>
this.createElement(
'option',
{ value: tz, key: tz },
tz.replace('_', ' ').split('/').join(' - ')
)
)
),
this.createElement(
'div',
{ className: 'form-buttons' },
this.createElement(
'button',
{
type: 'button',
onClick: this.toggleAddForm,
className: 'cancel-button'
},
'Cancel'
),
this.createElement(
'button',
{
type: 'button',
onClick: this.handleAddClock,
className: 'add-button',
disabled: !this.state.selectedTimezone
},
'Add Clock'
)
)
);
}
/**
* Render world clocks list
*/
renderWorldClocks() {
const { clocks } = this.props;
const { currentTime } = this.state;
if (!clocks || clocks.length === 0) {
return this.createElement(
'div',
{ className: 'empty-state' },
this.createElement(
'p',
{},
'No world clocks added yet. Tap + to add one.'
)
);
}
return this.createElement(
'div',
{ className: 'world-clocks-list' },
...clocks.map(clock =>
this.createElement(
'div',
{
className: 'world-clock-item',
key: clock.id
},
this.createElement(
'div',
{ className: 'clock-info' },
this.createElement(
'div',
{ className: 'clock-city' },
clock.city
),
this.createElement(
'div',
{ className: 'clock-timezone' },
clock.timezone.split('/').join(' / ')
)
),
this.createElement(
'div',
{ className: 'clock-time-info' },
this.createElement(
'div',
{ className: 'clock-time' },
this.formatTime(currentTime, clock.timezone)
),
this.createElement(
'div',
{ className: 'clock-date' },
this.getTimezoneDate(clock.timezone)
)
),
this.createElement(
'button',
{
className: 'remove-clock-button',
onClick: () => this.handleRemoveClock(clock.id),
'aria-label': `Remove ${clock.city} clock`
},
'Remove'
)
)
)
);
}
/**
* Render the world clock component
*/
render() {
return this.createElement(
'div',
{ className: 'world-clock' },
// Local time section
this.renderLocalTime(),
// Add clock button
!this.state.showAddForm && this.createElement(
'button',
{
className: 'add-world-clock-button',
onClick: this.toggleAddForm
},
'+ Add World Clock'
),
// Add clock form
this.renderAddForm(),
// World clocks list
this.renderWorldClocks()
);
}
}

Some files were not shown because too many files have changed in this diff Show More