Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] New Vue Web UI #1933

Draft
wants to merge 84 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 76 commits
Commits
Show all changes
84 commits
Select commit Hold shift + click to select a range
6b172e8
[WIP] Create a new web ui with vue
Tofandel Oct 7, 2019
230f627
Configured webpack compilation (outputs image in dist/index.html.gz)
Tofandel Oct 11, 2019
a8672f3
Move each tab to its own component
Tofandel Oct 11, 2019
e139e42
Add environment conditional compilation
Tofandel Oct 11, 2019
907b7c7
Make Form and Input components
Tofandel Oct 11, 2019
0e7335e
New favicon
Tofandel Oct 12, 2019
81cdc76
New favicon
Tofandel Oct 12, 2019
c14f03f
Added compile mode for dev +
Tofandel Oct 17, 2019
101c152
Harmonize ws api
Tofandel Oct 17, 2019
7e455b0
Merge branch 'dev' into dev
Tofandel Oct 17, 2019
8e57f4f
Continue template conversion + some styling
Tofandel Oct 17, 2019
3d7189c
Merge remote-tracking branch 'origin/dev' into dev
Tofandel Oct 17, 2019
99400a7
Convert OOP to schema arrays
Tofandel Oct 21, 2019
6e7f90f
Restore start parameter
Tofandel Oct 21, 2019
221bb37
First test unit
Tofandel Oct 21, 2019
bb822f7
Add test unit for input
Tofandel Oct 21, 2019
1a6878a
Form unit tests
Tofandel Oct 21, 2019
5c9a717
Designing menu + start grid conversion
Tofandel Oct 21, 2019
b15ba1e
Moving alexa integration to it's own tab + Add hint component
Tofandel Oct 22, 2019
15b1ef0
Populating websocket data
Tofandel Oct 23, 2019
4cdcc69
Implement module visibility
Tofandel Oct 25, 2019
15c2dc6
New switch styling + repeater improvements
Tofandel Oct 25, 2019
ae997d5
More ws schema refactor
Tofandel Oct 25, 2019
d28cb84
Merge branch 'dev' into dev
Tofandel Oct 25, 2019
2c90d11
Added multiselect support
Tofandel Oct 25, 2019
a8a0394
Finished all common tabs except sensor
Tofandel Oct 26, 2019
9e9521a
PWA initial setup
Tofandel Oct 26, 2019
5266284
PWA initial setup
Tofandel Oct 26, 2019
508af01
Fix pwa compilation (npm run build:pwa)
Tofandel Oct 30, 2019
066e527
Fix typos
Tofandel Oct 30, 2019
a4cadb6
Fix compile errors
Tofandel Oct 30, 2019
c5947a4
Fix compile errors
Tofandel Oct 30, 2019
88a29e1
Fix compile errors
Tofandel Oct 30, 2019
9263b1f
Add codeowners
Tofandel Oct 30, 2019
769be42
Add codeowners
Tofandel Oct 30, 2019
f3b6565
More ws refactor
Tofandel Oct 30, 2019
60cb976
More ws refactor
Tofandel Oct 30, 2019
6222903
Add favicons + generate pwa icons automatically
Tofandel Nov 5, 2019
3f6e9b3
Add favicons + generate pwa icons automatically
Tofandel Nov 5, 2019
b8d00e6
Add favicons + generate pwa icons automatically
Tofandel Nov 5, 2019
2cb0059
Fix conditional loading of pwa
Tofandel Nov 5, 2019
41a2ecf
Replace gif loader by css
Tofandel Nov 5, 2019
c4500a3
Add package info for pwa
Tofandel Nov 5, 2019
0e7a486
Refactor integrations
Tofandel Nov 10, 2019
bb54a26
Refactor integrations
Tofandel Nov 10, 2019
3f88649
Refactor features
Tofandel Nov 10, 2019
75849f9
Make a vue data table component and code diff analysis to send the data
Tofandel Nov 10, 2019
8e250b0
Multiple fixes + finish features refactor
Tofandel Nov 15, 2019
65e06b9
Merge branch 'dev' into dev
Tofandel Nov 19, 2019
e77d0bb
Merge branch 'dev' into dev
Tofandel Dec 3, 2019
7f5e3ea
Work on the PWA
Tofandel Dec 3, 2019
c875953
Merge remote-tracking branch 'origin/dev' into dev
Tofandel Dec 3, 2019
ae0172d
Move from webpack-conditional-loader to webpack-preprocessor-loader
Tofandel Dec 8, 2019
4045365
Working device scanner
Tofandel Dec 9, 2019
751d621
Style pwa with element-ui
Tofandel Dec 9, 2019
58e1ec5
Add authentication modal
Tofandel Dec 9, 2019
e68de35
Add wifi status + refresh info
Tofandel Dec 10, 2019
d38b5e8
Add uploader
Tofandel Dec 10, 2019
03f2b03
Add uploader
Tofandel Dec 10, 2019
d7f68e3
Add pwa mocking device for 127.0.0.1
Tofandel Dec 23, 2019
74022d8
Improve uploader
Tofandel Dec 25, 2019
3efd905
Update dependencies + optimizations
Tofandel Jan 27, 2020
830761c
Merge branch 'dev' into dev
Tofandel Feb 13, 2020
dc202a5
Implement changes of 13.6 to 14.2 in vue ui
Tofandel Feb 13, 2020
f7c62f3
Restore scheduler visible and fix rfm69 visible
Tofandel Feb 13, 2020
39ff17b
Fix wifi schema
Tofandel Feb 13, 2020
9dbf4be
Fix relay schema
Tofandel Feb 13, 2020
4af5993
Add ws disconnection dialog
Tofandel Feb 13, 2020
2708706
Various bug fixes
Tofandel Feb 13, 2020
2b1c8f1
Cleanup debug messages
Tofandel Feb 13, 2020
ddc7215
Cleanup debug logs
Tofandel Feb 13, 2020
7ac8ae8
Restore debug line break
Tofandel Feb 14, 2020
fa8e609
Restore debug line break
Tofandel Feb 14, 2020
4054100
Added hardcode check for wifi
Tofandel Feb 14, 2020
3694b16
New underscore notation for non settings in api
Tofandel Feb 16, 2020
7c16335
Improve schema + fixes
Tofandel Feb 16, 2020
b352284
Add options for button + fix build error
Tofandel Feb 18, 2020
17d0bc9
Move options to button + fixes
Tofandel Feb 18, 2020
7c0ac08
Add codacity lint rules + implements websocket callback
Tofandel Feb 19, 2020
cf0e786
WIP refactor json buffer alloc + ws action callback
Tofandel Feb 20, 2020
ecc04d3
More api and ui rework + test of better allocation
Tofandel Apr 22, 2020
0376955
Merge branch 'dev' of https://github.com/xoseperez/espurna into dev
Tofandel Apr 22, 2020
6e18585
Remove some useless diff
Tofandel Apr 22, 2020
9c9caf0
Fix some merge errors
Tofandel Apr 22, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Global owners
* @xoseperez @mcspr

# Vue ui codeowner
/code/ui/ @tofandel
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ custom.h
.env
.DS_Store
.vscode
.idea
_pycache_/
*.py[cod]
*.py[cod]
14 changes: 10 additions & 4 deletions code/espurna/alexa.ino
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,15 @@ bool _alexaWebSocketOnKeyCheck(const char * key, JsonVariant& value) {
return (strncmp(key, "alexa", 5) == 0);
}

void _alexaWebSocketOnVisible(JsonObject& root) {
JsonObject& modules = root["_modules"];
modules["alexa"] = 1;
}

void _alexaWebSocketOnConnected(JsonObject& root) {
root["alexaEnabled"] = alexaEnabled();
root["alexaName"] = getSetting("alexaName");
JsonObject& alexa = root.createNestedObject("alexa");
alexa["enabled"] = alexaEnabled();
alexa["name"] = getSetting("alexaName");
}

void _alexaConfigure() {
Expand All @@ -49,7 +55,7 @@ void _alexaConfigure() {

#if BROKER_SUPPORT
void _alexaBrokerCallback(const String& topic, unsigned char id, unsigned int value) {

// Only process status messages for switches and channels
if (!topic.equals(MQTT_TOPIC_CHANNEL)
&& !topic.equals(MQTT_TOPIC_RELAY)) {
Expand Down Expand Up @@ -124,7 +130,7 @@ void alexaSetup() {
webBodyRegister(_alexaBodyCallback);
webRequestRegister(_alexaRequestCallback);
wsRegister()
.onVisible([](JsonObject& root) { root["alexaVisible"] = 1; })
.onVisible(_alexaWebSocketOnVisible)
.onConnected(_alexaWebSocketOnConnected)
.onKeyCheck(_alexaWebSocketOnKeyCheck);
#endif
Expand Down
16 changes: 11 additions & 5 deletions code/espurna/api.ino
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,17 @@ bool _apiWebSocketOnKeyCheck(const char * key, JsonVariant& value) {
return (strncmp(key, "api", 3) == 0);
}

void _apiWebSocketOnVisible(JsonObject& root) {
JsonObject& modules = root["_modules"];
modules["api"] = 1;
}

void _apiWebSocketOnConnected(JsonObject& root) {
root["apiEnabled"] = _apiEnabled();
root["apiKey"] = _apiKey();
root["apiRestFul"] = _apiRestFul();
root["apiRealTime"] = getSetting("apiRealTime", 1 == API_REAL_TIME_VALUES);
JsonObject& api = root.createNestedObject("api");
api["enabled"] = _apiEnabled();
api["key"] = _apiKey();
api["restFul"] = _apiRestFul();
api["realTime"] = getSetting("apiRealTime", 1 == API_REAL_TIME_VALUES);
}

void _apiConfigure() {
Expand Down Expand Up @@ -253,7 +259,7 @@ void apiRegister(const char * key, api_get_callback_f getFn, api_put_callback_f
void apiSetup() {
_apiConfigure();
wsRegister()
.onVisible([](JsonObject& root) { root["apiVisible"] = 1; })
.onVisible(_apiWebSocketOnVisible)
.onConnected(_apiWebSocketOnConnected)
.onKeyCheck(_apiWebSocketOnKeyCheck);
webRequestRegister(_apiRequestCallback);
Expand Down
11 changes: 6 additions & 5 deletions code/espurna/button.ino
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ void buttonMQTT(unsigned char id, uint8_t event) {

void _buttonWebSocketOnVisible(JsonObject& root) {
if (buttonCount() > 0) {
root["btnVisible"] = 1;
JsonObject& modules = root["_modules"];
modules["btn"] = 1;
}
}

Expand Down Expand Up @@ -104,15 +105,15 @@ void buttonEvent(unsigned char id, unsigned char event) {
if (BUTTON_MODE_OFF == action) {
relayStatus(button.relayID, false);
}

if (BUTTON_MODE_AP == action) {
if (wifiState() & WIFI_STATE_AP) {
wifiStartSTA();
} else {
wifiStartAP();
}
}

if (BUTTON_MODE_RESET == action) {
deferredReset(100, CUSTOM_RESET_HARDWARE);
}
Expand All @@ -128,13 +129,13 @@ void buttonEvent(unsigned char id, unsigned char event) {
wifiStartWPS();
}
#endif // defined(JUSTWIFI_ENABLE_WPS)

#if defined(JUSTWIFI_ENABLE_SMARTCONFIG)
if (BUTTON_MODE_SMART_CONFIG == action) {
wifiStartSmartConfig();
}
#endif // defined(JUSTWIFI_ENABLE_SMARTCONFIG)

#if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
if (BUTTON_MODE_DIM_UP == action) {
lightBrightnessStep(1);
Expand Down
2 changes: 1 addition & 1 deletion code/espurna/config/general.h
Original file line number Diff line number Diff line change
Expand Up @@ -689,7 +689,7 @@
// there are no special requirements. Any static web server will do (NGinx, Apache, Lighttpd,...).
// The only requirement is that the resource must be available under this domain.
#ifndef WEB_REMOTE_DOMAIN
#define WEB_REMOTE_DOMAIN "http://espurna.io"
#define WEB_REMOTE_DOMAIN "*"
#endif

// -----------------------------------------------------------------------------
Expand Down
10 changes: 8 additions & 2 deletions code/espurna/debug.ino
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,12 @@ void _debugSendInternal(const char * message, bool add_timestamp) {

#if DEBUG_WEB_SUPPORT

void _debugWebSocketOnVisible(JsonObject& root) {
JsonObject& modules = root["_modules"];

modules["dbg"] = 1;
}

void _debugWebSocketOnAction(uint32_t client_id, const char * action, JsonObject& data) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to terminal


#if TERMINAL_SUPPORT
Expand All @@ -232,7 +238,7 @@ void _debugWebSocketOnAction(uint32_t client_id, const char * action, JsonObject
void debugWebSetup() {

wsRegister()
.onVisible([](JsonObject& root) { root["dbgVisible"] = 1; })
.onVisible(_debugWebSocketOnVisible)
.onAction(_debugWebSocketOnAction);

// TODO: if hostname string changes, need to update header too
Expand Down Expand Up @@ -305,7 +311,7 @@ DebugLogMode _debugLogModeDeserialize(const String& value) {

void debugConfigureBoot() {
static_assert(
std::is_same<int, std::underlying_type<DebugLogMode>::type>::value,
std::is_same<int, std::underlying_type<DebugLogMode>::type>::value,
"should be able to match DebugLogMode with int"
);

Expand Down
18 changes: 11 additions & 7 deletions code/espurna/domoticz.ino
Original file line number Diff line number Diff line change
Expand Up @@ -199,24 +199,28 @@ bool _domoticzWebSocketOnKeyCheck(const char * key, JsonVariant& value) {
}

void _domoticzWebSocketOnVisible(JsonObject& root) {
root["dczVisible"] = static_cast<unsigned char>(haveRelaysOrSensors());
JsonObject& modules = root["_modules"];
modules["dcz"] = static_cast<unsigned char>(haveRelaysOrSensors());
}

void _domoticzWebSocketOnConnected(JsonObject& root) {
JsonObject& dcz = root.createNestedObject("dcz");

root["dczEnabled"] = getSetting("dczEnabled", 1 == DOMOTICZ_ENABLED);
root["dczTopicIn"] = getSetting("dczTopicIn", DOMOTICZ_IN_TOPIC);
root["dczTopicOut"] = getSetting("dczTopicOut", DOMOTICZ_OUT_TOPIC);
dcz["enabled"] = getSetting("dczEnabled", 1 == DOMOTICZ_ENABLED);
dcz["topicIn"] = getSetting("dczTopicIn", DOMOTICZ_IN_TOPIC);
dcz["topicOut"] = getSetting("dczTopicOut", DOMOTICZ_OUT_TOPIC);

JsonArray& relays = root.createNestedArray("dczRelays");
JsonArray& relays = dcz.createNestedArray("relays");
for (unsigned char i=0; i<relayCount(); i++) {
relays.add(_domoticzIdx(i));
}

//Is this really needed since already done in sensor
/*
#if SENSOR_SUPPORT
_sensorWebSocketMagnitudes(root, "dcz");
_sensorWebSocketMagnitudesConfig(dcz);
#endif

*/
}

#endif // WEB_SUPPORT
Expand Down
15 changes: 9 additions & 6 deletions code/espurna/homeassistant.ino
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ void _haSendMagnitude(unsigned char i, JsonObject& config) {
unsigned char type = magnitudeType(i);
config["name"] = _haFixName(getSetting("hostname") + String(" ") + magnitudeTopic(type));
config["state_topic"] = mqttTopic(magnitudeTopicIndex(i).c_str(), false);
config["unit_of_measurement"] = magnitudeUnits(type);
config["unit_of_measurement"] = magnitudeUnit(type);
}

void ha_discovery_t::prepareMagnitudes(ha_config_t& config) {
Expand All @@ -224,7 +224,7 @@ void ha_discovery_t::prepareMagnitudes(ha_config_t& config) {
_haSendMagnitude(i, root);
root["uniq_id"] = getIdentifier() + "_" + magnitudeTopic(magnitudeType(i)) + "_" + String(i);
root["device"] = config.deviceConfig;

message.reserve(root.measureLength());
root.printTo(message);
}
Expand Down Expand Up @@ -423,7 +423,7 @@ void _haSend() {
#if SENSOR_SUPPORT
_ha_discovery->prepareMagnitudes(config);
#endif

_ha_send_flag = false;
schedule_function(_haSendDiscovery);

Expand Down Expand Up @@ -456,12 +456,15 @@ bool _haWebSocketOnKeyCheck(const char * key, JsonVariant& value) {
}

void _haWebSocketOnVisible(JsonObject& root) {
root["haVisible"] = 1;
JsonObject& modules = root["_modules"];
modules["ha"] = 1;
}

void _haWebSocketOnConnected(JsonObject& root) {
root["haPrefix"] = getSetting("haPrefix", HOMEASSISTANT_PREFIX);
root["haEnabled"] = getSetting("haEnabled", 1 == HOMEASSISTANT_ENABLED);
JsonObject& ha = root.createNestedObject("ha");

ha["enabled"] = getSetting("haEnabled", 1 == HOMEASSISTANT_ENABLED);
ha["prefix"] = getSetting("haPrefix", HOMEASSISTANT_PREFIX);
}

void _haWebSocketOnAction(uint32_t client_id, const char * action, JsonObject& data) {
Expand Down
17 changes: 10 additions & 7 deletions code/espurna/influxdb.ino
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,19 @@ bool _idbWebSocketOnKeyCheck(const char * key, JsonVariant& value) {
}

void _idbWebSocketOnVisible(JsonObject& root) {
root["idbVisible"] = 1;
JsonObject& modules = root["_modules"];
modules["idb"] = 1;
}

void _idbWebSocketOnConnected(JsonObject& root) {
root["idbEnabled"] = getSetting("idbEnabled", 1 == INFLUXDB_ENABLED);
root["idbHost"] = getSetting("idbHost", INFLUXDB_HOST);
root["idbPort"] = getSetting("idbPort", INFLUXDB_PORT);
root["idbDatabase"] = getSetting("idbDatabase", INFLUXDB_DATABASE);
root["idbUsername"] = getSetting("idbUsername", INFLUXDB_USERNAME);
root["idbPassword"] = getSetting("idbPassword", INFLUXDB_PASSWORD);
JsonObject& idb = root.createNestedObject("idb");

idb["enabled"] = getSetting("idbEnabled", 1 == INFLUXDB_ENABLED);
idb["host"] = getSetting("idbHost", INFLUXDB_HOST);
idb["port"] = getSetting("idbPort", INFLUXDB_PORT);
idb["database"] = getSetting("idbDatabase", INFLUXDB_DATABASE);
idb["username"] = getSetting("idbUsername", INFLUXDB_USERNAME);
idb["password"] = getSetting("idbPassword", INFLUXDB_PASSWORD);
}

void _idbConfigure() {
Expand Down
4 changes: 2 additions & 2 deletions code/espurna/ir.ino
Original file line number Diff line number Diff line change
Expand Up @@ -398,13 +398,13 @@ void irSetup() {

#if defined(IR_RX_PIN)
_ir_receiver.enableIRIn();
DEBUG_MSG_P(PSTR("[IR] Receiver initialized \n"));
DEBUG_MSG_P(PSTR("[IR] Receiver initialized\n"));
#endif

#if MQTT_SUPPORT && defined(IR_TX_PIN)
_ir_sender.begin();
mqttRegister(_irMqttCallback);
DEBUG_MSG_P(PSTR("[IR] Transmitter initialized \n"));
DEBUG_MSG_P(PSTR("[IR] Transmitter initialized\n"));
#endif

espurnaRegisterLoop(_irLoop);
Expand Down
20 changes: 14 additions & 6 deletions code/espurna/led.ino
Original file line number Diff line number Diff line change
Expand Up @@ -106,17 +106,25 @@ bool _ledWebSocketOnKeyCheck(const char * key, JsonVariant& value) {

void _ledWebSocketOnVisible(JsonObject& root) {
if (_ledCount() > 0) {
root["ledVisible"] = 1;
JsonObject& modules = root["_modules"];
modules["led"] = 1;
}
}

void _ledWebSocketOnConnected(JsonObject& root) {
if (!_ledCount()) return;
JsonArray& leds = root.createNestedArray("ledConfig");
for (unsigned char id = 0; id < _ledCount(); ++id) {
JsonObject& led = leds.createNestedObject();
led["mode"] = getSetting({"ledMode", id}, _leds[id].mode);
led["relay"] = getSetting<unsigned char>({"ledRelay", id}, _leds[id].relayID);
JsonObject& led = root.createNestedObject("led");

JsonArray& schema = led.createNestedArray("_schema");

schema.add("mode");
schema.add("relay");

JsonArray& leds = root.createNestedArray("list");
for (unsigned char id = 0; id <_ledCount(); ++id) {
JsonArray& led = leds.createNestedArray();
led.add(getSetting({"ledMode", id}, _leds[id].mode));
led.add(getSetting<unsigned char>({"ledRelay", id}, _leds[id].relayID));
}
}

Expand Down
9 changes: 5 additions & 4 deletions code/espurna/light.ino
Original file line number Diff line number Diff line change
Expand Up @@ -781,13 +781,13 @@ void lightMQTT() {
mqttSend(MQTT_TOPIC_COLOR_HSV, buffer);

}

if (_light_has_color || _light_use_cct) {

// Mireds
snprintf_P(buffer, sizeof(buffer), PSTR("%d"), _light_mireds);
mqttSend(MQTT_TOPIC_MIRED, buffer);

}

// Channels
Expand Down Expand Up @@ -1042,7 +1042,8 @@ void _lightWebSocketStatus(JsonObject& root) {
}

void _lightWebSocketOnVisible(JsonObject& root) {
root["colorVisible"] = 1;
JsonObject& modules = root["_modules"];
modules["color"] = 1;
}

void _lightWebSocketOnConnected(JsonObject& root) {
Expand Down
12 changes: 7 additions & 5 deletions code/espurna/lightfox.ino
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ void lightfoxLearn() {
Serial.write(LIGHTFOX_CODE_STOP);
Serial.println();
Serial.flush();
DEBUG_MSG_P(PSTR("[LIGHTFOX] Learn comman sent\n"));
DEBUG_MSG_P(PSTR("[LIGHTFOX] Learn command sent\n"));
}

void lightfoxClear() {
Expand All @@ -38,7 +38,7 @@ void lightfoxClear() {
Serial.write(LIGHTFOX_CODE_STOP);
Serial.println();
Serial.flush();
DEBUG_MSG_P(PSTR("[LIGHTFOX] Clear comman sent\n"));
DEBUG_MSG_P(PSTR("[LIGHTFOX] Clear command sent\n"));
}

// -----------------------------------------------------------------------------
Expand All @@ -48,10 +48,12 @@ void lightfoxClear() {
#if WEB_SUPPORT

void _lightfoxWebSocketOnConnected(JsonObject& root) {
root["lightfoxVisible"] = 1;
JsonObject& lightfox = root.createNestedObject("lightfox");

lightfox["enabled"] = 1;

uint8_t buttonsCount = _buttons.size();
root["lightfoxRelayCount"] = relayCount();
JsonArray& rfb = root.createNestedArray("lightfoxButtons");
JsonArray& rfb = root.createNestedArray("buttons");
for (byte id=0; id<buttonsCount; id++) {
JsonObject& node = rfb.createNestedObject();
node["id"] = id;
Expand Down
Loading