From a7c6e520e66ac6198d9deb95bd3be2ae3c2a2121 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Mon, 1 Mar 2021 18:38:39 +0100 Subject: [PATCH] Better UX * All buttons are in the same DOM container as the iframe * Icons for buttons * Rewriting the build process * Simplier state management * Buttons are hidden using CSS --- README.md | 4 + assets/style.css | 45 ++- client/common-client-plugin.js | 9 +- client/videowatch-client-plugin.js | 98 ++--- package-lock.json | 98 ++++- package.json | 10 +- public/images/.gitkeep | 0 public/images/bye.png | Bin 0 -> 1842 bytes public/images/bye.svg | 307 +++++++++++++++ public/images/talking-new-window.png | Bin 0 -> 1162 bytes public/images/talking-new-window.svg | 533 +++++++++++++++++++++++++++ public/images/talking.png | Bin 0 -> 1797 bytes public/images/talking.svg | 464 +++++++++++++++++++++++ 13 files changed, 1515 insertions(+), 53 deletions(-) delete mode 100644 public/images/.gitkeep create mode 100644 public/images/bye.png create mode 100644 public/images/bye.svg create mode 100644 public/images/talking-new-window.png create mode 100644 public/images/talking-new-window.svg create mode 100644 public/images/talking.png create mode 100644 public/images/talking.svg diff --git a/README.md b/README.md index b6f95bbd..9c0c9d1d 100644 --- a/README.md +++ b/README.md @@ -170,3 +170,7 @@ There is an example file [here](documentation/examples/nginx/site.conf). NB: this example files also serve the static html files with converseJS. NB: it is recommanded to change ```Access-Control-Allow-Origin``` to something else that ```"*"```. + +## Credits + +Thanks to David Revoy for his work on Peertube's mascot, [Sepia](https://www.davidrevoy.com/index.php?tag/peertube). diff --git a/assets/style.css b/assets/style.css index 8f0c0081..42f448bb 100644 --- a/assets/style.css +++ b/assets/style.css @@ -1,4 +1,47 @@ -iframe.peertube-plugin-livechat { +#peertube-plugin-livechat-container { + display: flex; + flex-direction: column; +} + +.peertube-plugin-livechat-buttons { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-end; +} + +.peertube-plugin-livechat-button { + border: 1px solid black; + background-color: var(--mainBackgroundColor); + min-height: 32px; + min-width: 32px; + padding: 0; +} + +.peertube-plugin-livechat-button-icon { + background-size: 32px 32px; + background-repeat: no-repeat; + background-color: transparent; + background-position: center center; + display: inline-block; + height: 100%; + width: 100%; +} + +[peertube-plugin-livechat-state="initializing"] { + display: none; +} + +[peertube-plugin-livechat-state="open"] .peertube-plugin-livechat-button-open { + display: none; +} + +[peertube-plugin-livechat-state="closed"] .peertube-plugin-livechat-button-close { + display: none; +} + +#peertube-plugin-livechat-container iframe { border: 1px solid black; min-height: 30vh; + height: 100%; } diff --git a/client/common-client-plugin.js b/client/common-client-plugin.js index dde51eaa..3e233faf 100644 --- a/client/common-client-plugin.js +++ b/client/common-client-plugin.js @@ -5,13 +5,10 @@ function register ({ registerHook, _peertubeHelpers }) { registerHook({ target: 'action:router.navigation-end', handler: () => { - const el = document.querySelector('.peertube-plugin-livechat-init') - if (el) { - el.classList.remove('peertube-plugin-livechat-init') + const container = document.querySelector('#peertube-plugin-livechat-container') + if (container) { + container.remove() } - - document.querySelectorAll('.peertube-plugin-livechat-stuff') - .forEach(dom => dom.remove()) } }) } diff --git a/client/videowatch-client-plugin.js b/client/videowatch-client-plugin.js index 7c227dfd..2ce450b4 100644 --- a/client/videowatch-client-plugin.js +++ b/client/videowatch-client-plugin.js @@ -63,21 +63,30 @@ function register ({ registerHook, peertubeHelpers }) { return iframeUri } - function displayButton (buttons, name, label, callback) { + function displayButton (buttonContainer, name, label, callback, icon) { const button = document.createElement('button') button.classList.add( - 'action-button', - 'peertube-plugin-livechat-stuff', + 'peertube-plugin-livechat-button', 'peertube-plugin-livechat-button-' + name ) - button.setAttribute('type', 'button') - button.textContent = label button.onclick = callback - buttons.prepend(button) + if (icon) { + const iconUrl = peertubeHelpers.getBaseStaticRoute() + '/images/' + icon + const iconEl = document.createElement('span') + iconEl.classList.add('peertube-plugin-livechat-button-icon') + iconEl.setAttribute('style', + 'background-image: url(\'' + iconUrl + '\');' + ) + button.prepend(iconEl) + button.setAttribute('title', label) + } else { + button.textContent = label + } + buttonContainer.append(button) } - function displayChatButtons (peertubeHelpers, uuid, showOpenBlank) { - logger.log('Adding buttons in the DOM...') + function insertChatDom (container, peertubeHelpers, uuid, showOpenBlank) { + logger.log('Adding livechat in the DOM...') const p = new Promise((resolve, reject) => { Promise.all([ peertubeHelpers.translate('Open chat'), @@ -87,37 +96,31 @@ function register ({ registerHook, peertubeHelpers }) { const labelOpen = labels[0] const labelOpenBlank = labels[1] const labelClose = labels[2] - const buttons = document.querySelector('.video-actions') const iframeUri = getIframeUri(uuid) if (!iframeUri) { return reject(new Error('No uri, cant display the buttons.')) } + + const buttonContainer = document.createElement('div') + buttonContainer.classList.add('peertube-plugin-livechat-buttons') + container.append(buttonContainer) + + displayButton(buttonContainer, 'open', labelOpen, () => openChat(), 'talking.png') if (showOpenBlank) { - displayButton(buttons, 'openblank', labelOpenBlank, () => { + displayButton(buttonContainer, 'openblank', labelOpenBlank, () => { closeChat() window.open(iframeUri) - }) + }, 'talking-new-window.png') } - displayButton(buttons, 'open', labelOpen, () => openChat()) - displayButton(buttons, 'close', labelClose, () => closeChat()) + displayButton(buttonContainer, 'close', labelClose, () => closeChat(), 'bye.png') - toggleShowHideButtons(null) resolve() }) }) return p } - function toggleShowHideButtons (chatOpened) { - // showing/hiding buttons... - document.querySelectorAll('.peertube-plugin-livechat-button-open') - .forEach(button => (button.style.display = (chatOpened === true || chatOpened === null ? 'none' : ''))) - - document.querySelectorAll('.peertube-plugin-livechat-button-close') - .forEach(button => (button.style.display = (chatOpened === false || chatOpened === null ? 'none' : ''))) - } - function openChat () { const p = new Promise((resolve, reject) => { const uuid = lastUUID @@ -135,25 +138,27 @@ function register ({ registerHook, peertubeHelpers }) { const additionalStyles = settings['chat-style'] || '' logger.info('Opening the chat...') - const videoWrapper = document.querySelector('#video-wrapper') + const container = document.getElementById('peertube-plugin-livechat-container') + if (!container) { + logger.error('Cant found the livechat container.') + return reject(new Error('Cant found the livechat container')) + } + + if (container.querySelector('iframe')) { + logger.error('Seems that there is already an iframe in the container.') + return reject(new Error('Seems that there is already an iframe in the container.')) + } // Creating the iframe... const iframe = document.createElement('iframe') iframe.setAttribute('src', iframeUri) - iframe.classList.add( - 'peertube-plugin-livechat', - 'peertube-plugin-livechat-stuff', - 'peertube-plugin-livechat-iframe-stuff' - ) iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-popups allow-forms') iframe.setAttribute('frameborder', '0') if (additionalStyles) { iframe.setAttribute('style', additionalStyles) } - videoWrapper.append(iframe) - - // showing/hiding buttons... - toggleShowHideButtons(true) + container.append(iframe) + container.setAttribute('peertube-plugin-livechat-state', 'open') resolve() }) @@ -161,25 +166,32 @@ function register ({ registerHook, peertubeHelpers }) { } function closeChat () { - document.querySelectorAll('.peertube-plugin-livechat-iframe-stuff') + const container = document.getElementById('peertube-plugin-livechat-container') + if (!container) { + logger.error('Cant close livechat, container not found.') + return + } + container.querySelectorAll('iframe') .forEach(dom => dom.remove()) - // showing/hiding buttons... - toggleShowHideButtons(false) + container.setAttribute('peertube-plugin-livechat-state', 'closed') } function initChat () { - const el = document.querySelector('#videojs-wrapper') - if (!el) { + const videoWrapper = document.querySelector('#video-wrapper') + if (!videoWrapper) { logger.error('The required div is not present in the DOM.') return } - if (el.classList.contains('peertube-plugin-livechat-init')) { + let container = videoWrapper.querySelector('#peertube-plugin-livechat-container') + if (container) { logger.log('The chat seems already initialized...') return } - // Adding a custom class in the dom, so we know initChat was already called. - el.classList.add('peertube-plugin-livechat-init') + container = document.createElement('div') + container.setAttribute('id', 'peertube-plugin-livechat-container') + container.setAttribute('peertube-plugin-livechat-state', 'initializing') + videoWrapper.append(container) peertubeHelpers.getSettings().then(s => { settings = s @@ -214,11 +226,11 @@ function register ({ registerHook, peertubeHelpers }) { return } - displayChatButtons(peertubeHelpers, uuid, !!settings['chat-open-blank']).then(() => { + insertChatDom(container, peertubeHelpers, uuid, !!settings['chat-open-blank']).then(() => { if (settings['chat-auto-display']) { openChat() } else { - toggleShowHideButtons(false) + container.setAttribute('peertube-plugin-livechat-state', 'closed') } }) }) diff --git a/package-lock.json b/package-lock.json index 5bc50721..35bc2119 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "peertube-plugin-livechat", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -2920,6 +2920,12 @@ "readable-stream": "^2.0.1" } }, + "memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha1-htcJCzDORV1j+64S3aUaR93K+bI=", + "dev": true + }, "micromatch": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", @@ -3147,6 +3153,73 @@ "dev": true, "optional": true }, + "npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "dependencies": { + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + } + } + }, "npm-run-path": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", @@ -3464,6 +3537,12 @@ "dev": true, "optional": true }, + "pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true + }, "pify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", @@ -3938,6 +4017,12 @@ "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", "dev": true }, + "shell-quote": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz", + "integrity": "sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==", + "dev": true + }, "signal-exit": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", @@ -4268,6 +4353,17 @@ "strip-ansi": "^5.1.0" } }, + "string.prototype.padend": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.2.tgz", + "integrity": "sha512-/AQFLdYvePENU3W5rgurfWSMU6n+Ww8n/3cUt7E+vPBB/D7YDG8x+qjoFs4M/alR2bW7Qg6xMjVwWUOvuQ0XpQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.2" + } + }, "string.prototype.trimend": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.3.tgz", diff --git a/package.json b/package.json index 762bf1a6..efe0de6e 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "eslint-plugin-import": "^2.22.1", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^4.3.1", + "npm-run-all": "^4.1.5", "webpack": "^4.41.2", "webpack-cli": "^3.3.10" }, @@ -43,13 +44,18 @@ ], "library": "./main.js", "scripts": { + "clean": "rm -rf dist/*", "prepare": "npm run build", - "build": "mkdir -p dist/conversejs && cp -r node_modules/converse.js/dist/* dist/conversejs/ && webpack --mode=production", + "build:converse": "mkdir -p dist/conversejs && cp -r node_modules/converse.js/dist/* dist/conversejs/", + "build:images": "mkdir -p dist/images && cp public/images/* dist/images/", + "build:webpack": "webpack --mode=production", + "build": "npm-run-all -s clean -p build:converse build:images build:webpack", "lint": "npx eslint --ext .js ." }, "staticDirs": { "static": "dist/static", - "conversejs": "dist/conversejs/" + "conversejs": "dist/conversejs/", + "images": "dist/images/" }, "translations": { "fr-FR": "./languages/fr.json" diff --git a/public/images/.gitkeep b/public/images/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/public/images/bye.png b/public/images/bye.png new file mode 100644 index 0000000000000000000000000000000000000000..9238964ef977a4606ab0e6d37d343e4a197d22f9 GIT binary patch literal 1842 zcmV-22hI42P)hA0Ya|jWtE#HqX>BHmW<4yoD z8jXz(hvRiJ-QaS$3OycAQ!EzC<2X*W+wFTE*EptQpO~1a1OQ2rP8Ajw?%2F}bA-%p zbh%uGw+64D4~CPL$0gx%xpYHALx&#(2LNVvbH(I8nV(ubW|IJ_C<;3=GV<;>8Hd~L z7Q9~XNAtnIBA^lM=^wgHhOV{~k)=>o^^qmqZnxm^c6JlL!a=@b+GWewIXSZS9-i1YS^3P&l6 zM(t)NWRfcyx}2001VFiHf55udfSF)s#O+)Y@`7p5zdxEJQ{{8mmEI zw0R%W2#b#*MVAKr(e(h3k&zLNL?UUFQq5+wy}5h$?!PPqP7hX?G&XgW0qE}}LH$LJ zF8)C#ugrn>`;+MNB>({R-IGwj;7n{4rs0w#H98!Q^&H3D&@_$l`F!u~-@pI$g$QVD z>iw~(;+Q!N>46tAH64Ewz|5x~Qz%j@MTWp2Nm-mr6yUSbsYfJ9YAh`+tt=`kin?5` zyuQA^(}_f4CBrZeO+q){c(EC&h#mkmhNWks1!Nj-m6n{@mDzPvr^cS4R6Eb= zB8}w7XXots+2deYmP(Seue!SW9RN^k%J)*>?dCBvRAUY{m2J5vKq-j!_gBY9N5evJ zA|5rQ>!mB{YsnRv1puruYj|;q$HOW~1CyEcTeAIwgAa;$K8ZIB&F3}Be0nWx&|L~- zOr^Ob1B8B9zFIRFe@>{jH5rLy500{L+*(@v`JJ&?gJ(i<06>;xy0A5V!Q`+qPZL3Z znF#7L#CcquPvAikTe2qPeB8309JN5D_kt(&_4R8D!k2%*(WIkGePTF};xZG7M1?HN zj=M7-<$3&EVzk`< z$Pr@5JnGNGNQ8i42sYV5SV@M#GjvK|G2k;2wJ$%V0tH`^T=9h0`uk8kwUl89w&et| zni~RXX=%}Sb#>JR0)g!SAW726;^N}U#{s{;jvYI;Y-F&nEf`H@vn+#c_DL`R6prJP zz|@;B790R7D=P=`bHDXmPC6S01$dg88bNz|yV%pyvv>LOX8-^I literal 0 HcmV?d00001 diff --git a/public/images/bye.svg b/public/images/bye.svg new file mode 100644 index 00000000..777aebfe --- /dev/null +++ b/public/images/bye.svg @@ -0,0 +1,307 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/talking-new-window.png b/public/images/talking-new-window.png new file mode 100644 index 0000000000000000000000000000000000000000..69a6638e8a86e2c60bead62293319623f31aec7b GIT binary patch literal 1162 zcmV;51a35MY zzl~|<-n*b*e@O1iC_COe0D#xK%y(zcaOuiK836LvQv`ZXAg3>bQq0Y|INN!eWxu4)ZI7@5?kQYje{l158UTNeUt@aub9V14 z!G3EQp=kuW4-s$pk^N`Ttcjo$?*6a%>B>BDN)l&=kkTRpP$S=BGf4`Qm$7M*1hc{_ zLg=HNSaNnEw&Wr5UqRSXh|Al9ni*mGd^?%$VM-DVQXT&kKG6{wfGDgYC9jOanGsa) zQ2-_u0z94;&*Wl|>=#QAxut){0>ZJ4)So+0N@5sm&MB&num@}@@Yps){;P;53jxSZ zi|68xE}q(&amW0QEIPK*H(6RSNU{hJ8X|u$BCjmEF+^x6VZty3n9LMCpMfZrc7*)8~NTk)<0;l^*^n~wruaT3*a5YOxk3yv3<_Sw0WQ-;6q6l&lktjynyl|iuB z@%cjN&L;eNHfS1{EfF@MQuyOi@#Q*M%#O$7@!-$cO+rEfsW~r^lxW3fO2i6CDG4mi zA^d)7ri5BrT5fK%8TM68CKK)LCnzW=2sbr0 z0HIKb^z?KNA3j7yMa82BFbo5w6oZ3<^z`&Z4G_^g(9zLBYwO2%zTig=(9-fz)W)ld z-MmGpbt{mSl@*f)18})qabW{YO-*s`+_{(ll$VzW!v@IA%%q{AAtnGGkLP;W0J^SI zT3U+N>t$(aX~X2Cq$KQi`=$UxLqp!M0an%3)y1)6$2Z)ss;Z=>re;%s2We9Pr_+hk z>5R(YLHhs4PzWLJU6fK12n1qcAf?>&lA^h}d8<!IZ2 + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/talking.png b/public/images/talking.png new file mode 100644 index 0000000000000000000000000000000000000000..9c0791e21a5c617a60b8b01c5fb7a0e40b3e972a GIT binary patch literal 1797 zcmV+g2m1JlP)KgpAV52%YNl~UE*bj=T_iCk zrdu*yqMMf}tZ>>*4VytrJ6pnFn_~mYgaOh41x76`J-wZCdQN+8zuWh{{!u7vPeG%L z|H$*__kQ2|Jn!>eHas*{j87hrd#bijxca`2jl6D){$5Yw4U+5) zufD40jshrLeV+tg{PVf^sl;!pH+&od-s$dqrT)F>vIFOb!3cHKuwnLX1t^B^pXtr8 zgB?lV&ZCj0o}aJ&-hV@W@!3aywEtZEt4a|Lbc~=oVHc5N^+aHK0;pkQDM)?e-K(Pj zfX=wZcDKb|xc2-byQ#J|uT}pF*MGHo&8ugJo=MwktT;vZ{mB?);jD+%AHVZdo&Xh? z_JgEe?z=pc8&A?V>fqP!3~aujpJ=6*9{2sb0AvzR zQ~mly0LHAKx-Gim(XPh!_$OZPNaiIpHIB!>Sa$C7_m~f{tu3)TkyG8qX&1BF`M>qc zS;0^NxX=JFC`HKSvT*Gjsuvk3;@OizmaSj_;O(jUrUL%Z94Uf-+6xtaa5fpjXNyuhxC~dX2gA|}sURFr;rQ`396ERasZ?rW21Nh? zUScrh#IC>XHEj6WN5f9tnoVxBZwn~}vT{(dTPCH`X|%R9-yk1Ddn$;2%itW0e%NQ5 zNVxiVuy0@p%}tHCbgAQ(y{Um)l_p}k*Vp}utXsPs04Tiv0XZ1iMYeUz?z7DRfMha> z*1t4iWHd9$6NY1nB4`Zw!i7{Y&ob9jXlpxxp3ciy@n9wNp^0txGNYVn<}YM@^#V6D z`gM}7#tjoh5>N_W*ZUahzJ$Z?o`B_2UVZ>bdjibeQ$>3t024aEkRrk`V@LuB0^nen1A+hmS1}M{@Dw0vY)?7FK?EoOQZZ

nqB?3ahx5m#J?Oa=|480pb!OgLpM{p&f(B$wGppE4`FE%wVo!TOR z>b~+R53=2JmCiHD9dA6_b|66v6CNMG#!z5zG`!q#lN?0& z$?eDifNgEO#5Fu+_&D_r+8D`r6Um81xMJ(s)|M6E1dM5z@z$@k-Vxegt&YR`)W)vGY5`h4b;3YH9plsgwgEQ$r@aO~3gWJYvZ!Q8J z`}X|+5aXPF2>`VJ$?11gp!vciu@K-|B56}kZ~Dscu*IP3#pGtz!S8=LPXG~^61;V= zKTelBlkvzvnnqI&izh6KbPvTcS+9hI@3+*ly$YJ~WqPSHiuk9#cjg5`VKAk@Gutv_DvvPiK+~$S0ZI`;PJHvI|5>3-aOw$|+a7rxp4;IeQ z92wJRo`?qTJ+En6G?&W_)YQ~C06_0=(Gf9!3eR3c#FR=5Er<rrT}2yzJ30Jf`W3(vdUc7EwwDGbg^^sOP^G|t5V8z{%)5KT(zcUR)u;i78uD= z0jWB*xvf`yOOFa6qHMB_|0!x~Yx(l!%l$%#P%4!Q5mCr-9D{RiGRBw?!XqME2w@2! n(z>pvXV0EJ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +