Merge branch 'develop' into project-comments

This commit is contained in:
picklesrus 2021-06-07 15:38:40 -04:00 committed by GitHub
commit ef801112cd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 2340 additions and 782 deletions

View file

@ -270,12 +270,11 @@ async.auto({
fastly.activateVersion(results.version, function (e, resp) {
if (e) throw new Error(e);
process.stdout.write('Successfully configured and activated version ' + resp.number + '\n');
if (process.env.FASTLY_PURGE_ALL) {
fastly.purgeAll(FASTLY_SERVICE_ID, function (error) {
if (error) throw new Error(error);
process.stdout.write('Purged all.\n');
});
}
// purge static-assets using surrogate key
fastly.purgeKey(FASTLY_SERVICE_ID, 'static-assets', function (error) {
if (error) throw new Error(error);
process.stdout.write('Purged static assets.\n');
});
});
}
});

277
package-lock.json generated
View file

@ -16,9 +16,9 @@
}
},
"@babel/cli": {
"version": "7.13.16",
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.13.16.tgz",
"integrity": "sha512-cL9tllhqvsQ6r1+d9Invf7nNXg/3BlfL1vvvL/AdH9fZ2l5j0CeBcoq6UjsqHpvyN1v5nXSZgqJZoGeK+ZOAbw==",
"version": "7.14.3",
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.14.3.tgz",
"integrity": "sha512-zU4JLvwk32ay1lhhyGfqiRUSPoltVDjhYkA3aQq8+Yby9z30s/EsFw1EPOHxWG9YZo2pAGfgdRNeHZQAYU5m9A==",
"dev": true,
"requires": {
"@nicolo-ribaudo/chokidar-2": "2.1.8-no-fsevents",
@ -219,23 +219,23 @@
}
},
"@babel/compat-data": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.14.0.tgz",
"integrity": "sha512-vu9V3uMM/1o5Hl5OekMUowo3FqXLJSw+s+66nt0fSWVWTtmosdzn45JHOB3cPtZoe6CTBDzvSw0RdOY85Q37+Q==",
"version": "7.14.4",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.14.4.tgz",
"integrity": "sha512-i2wXrWQNkH6JplJQGn3Rd2I4Pij8GdHkXwHMxm+zV5YG/Jci+bCNrWZEWC4o+umiDkRrRs4dVzH3X4GP7vyjQQ==",
"dev": true
},
"@babel/core": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.14.2.tgz",
"integrity": "sha512-OgC1mON+l4U4B4wiohJlQNUU3H73mpTyYY3j/c8U9dr9UagGGSm+WFpzjy/YLdoyjiG++c1kIDgxCo/mLwQJeQ==",
"version": "7.14.3",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.14.3.tgz",
"integrity": "sha512-jB5AmTKOCSJIZ72sd78ECEhuPiDMKlQdDI/4QRI6lzYATx5SSogS1oQA2AoPecRCknm30gHi2l+QVvNUu3wZAg==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.12.13",
"@babel/generator": "^7.14.2",
"@babel/generator": "^7.14.3",
"@babel/helper-compilation-targets": "^7.13.16",
"@babel/helper-module-transforms": "^7.14.2",
"@babel/helpers": "^7.14.0",
"@babel/parser": "^7.14.2",
"@babel/parser": "^7.14.3",
"@babel/template": "^7.12.13",
"@babel/traverse": "^7.14.2",
"@babel/types": "^7.14.2",
@ -257,9 +257,9 @@
}
},
"@babel/generator": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.2.tgz",
"integrity": "sha512-OnADYbKrffDVai5qcpkMxQ7caomHOoEwjkouqnN2QhydAjowFAZcsdecFIRUBdb+ZcruwYE4ythYmF1UBZU5xQ==",
"version": "7.14.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.3.tgz",
"integrity": "sha512-bn0S6flG/j0xtQdz3hsjJ624h3W0r3llttBMfyHX3YrZ/KtLYr15bjA0FXkgW7FpvrDuTuElXeVjiKlYRpnOFA==",
"dev": true,
"requires": {
"@babel/types": "^7.14.2",
@ -308,9 +308,9 @@
}
},
"@babel/parser": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.2.tgz",
"integrity": "sha512-IoVDIHpsgE/fu7eXBeRWt8zLbDrSvD7H1gpomOkPpBoEN8KCruCqSDdqo8dddwQQrui30KSvQBaMUOJiuFu6QQ==",
"version": "7.14.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.4.tgz",
"integrity": "sha512-ArliyUsWDUqEGfWcmzpGUzNfLxTdTp6WU4IuP6QFSp9gGfWS6boxFCkJSJ/L4+RG8z/FnIU3WxCk6hPL9SSWeA==",
"dev": true
},
"@babel/template": {
@ -341,9 +341,9 @@
}
},
"@babel/types": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.2.tgz",
"integrity": "sha512-SdjAG/3DikRHpUOjxZgnkbR11xUlyDMUFJdvnIgZEE16mqmY0BINMmc4//JMJglEmn6i7sq6p+mGrFWyZ98EEw==",
"version": "7.14.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.4.tgz",
"integrity": "sha512-lCj4aIs0xUefJFQnwwQv2Bxg7Omd6bgquZ6LGC+gGMh6/s5qDVfjuCMlDmYQ15SLsWHd9n+X3E75lKIhl5Lkiw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.14.0",
@ -478,14 +478,14 @@
}
},
"@babel/helper-compilation-targets": {
"version": "7.13.16",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.13.16.tgz",
"integrity": "sha512-3gmkYIrpqsLlieFwjkGgLaSHmhnvlAYzZLlYVjlW+QwI+1zE17kGxuJGmIqDQdYp56XdmGeD+Bswx0UTyG18xA==",
"version": "7.14.4",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.14.4.tgz",
"integrity": "sha512-JgdzOYZ/qGaKTVkn5qEDV/SXAh8KcyUVkCoSWGN8T3bwrgd6m+/dJa2kVGi6RJYJgEYPBdZ84BZp9dUjNWkBaA==",
"dev": true,
"requires": {
"@babel/compat-data": "^7.13.15",
"@babel/compat-data": "^7.14.4",
"@babel/helper-validator-option": "^7.12.17",
"browserslist": "^4.14.5",
"browserslist": "^4.16.6",
"semver": "^6.3.0"
},
"dependencies": {
@ -503,9 +503,9 @@
}
},
"electron-to-chromium": {
"version": "1.3.727",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.727.tgz",
"integrity": "sha512-Mfz4FIB4FSvEwBpDfdipRIrwd6uo8gUDoRDF4QEYb4h4tSuI3ov594OrjU6on042UlFHouIJpClDODGkPcBSbg==",
"version": "1.3.749",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.749.tgz",
"integrity": "sha512-F+v2zxZgw/fMwPz/VUGIggG4ZndDsYy0vlpthi3tjmDZlcfbhN5mYW0evXUsBr2sUtuDANFtle410A9u/sd/4A==",
"dev": true
},
"semver": {
@ -546,9 +546,9 @@
},
"dependencies": {
"@babel/types": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.2.tgz",
"integrity": "sha512-SdjAG/3DikRHpUOjxZgnkbR11xUlyDMUFJdvnIgZEE16mqmY0BINMmc4//JMJglEmn6i7sq6p+mGrFWyZ98EEw==",
"version": "7.14.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.4.tgz",
"integrity": "sha512-lCj4aIs0xUefJFQnwwQv2Bxg7Omd6bgquZ6LGC+gGMh6/s5qDVfjuCMlDmYQ15SLsWHd9n+X3E75lKIhl5Lkiw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.14.0",
@ -573,9 +573,9 @@
},
"dependencies": {
"@babel/types": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.2.tgz",
"integrity": "sha512-SdjAG/3DikRHpUOjxZgnkbR11xUlyDMUFJdvnIgZEE16mqmY0BINMmc4//JMJglEmn6i7sq6p+mGrFWyZ98EEw==",
"version": "7.14.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.4.tgz",
"integrity": "sha512-lCj4aIs0xUefJFQnwwQv2Bxg7Omd6bgquZ6LGC+gGMh6/s5qDVfjuCMlDmYQ15SLsWHd9n+X3E75lKIhl5Lkiw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.14.0",
@ -616,9 +616,9 @@
}
},
"@babel/generator": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.2.tgz",
"integrity": "sha512-OnADYbKrffDVai5qcpkMxQ7caomHOoEwjkouqnN2QhydAjowFAZcsdecFIRUBdb+ZcruwYE4ythYmF1UBZU5xQ==",
"version": "7.14.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.3.tgz",
"integrity": "sha512-bn0S6flG/j0xtQdz3hsjJ624h3W0r3llttBMfyHX3YrZ/KtLYr15bjA0FXkgW7FpvrDuTuElXeVjiKlYRpnOFA==",
"dev": true,
"requires": {
"@babel/types": "^7.14.2",
@ -667,9 +667,9 @@
}
},
"@babel/parser": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.2.tgz",
"integrity": "sha512-IoVDIHpsgE/fu7eXBeRWt8zLbDrSvD7H1gpomOkPpBoEN8KCruCqSDdqo8dddwQQrui30KSvQBaMUOJiuFu6QQ==",
"version": "7.14.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.4.tgz",
"integrity": "sha512-ArliyUsWDUqEGfWcmzpGUzNfLxTdTp6WU4IuP6QFSp9gGfWS6boxFCkJSJ/L4+RG8z/FnIU3WxCk6hPL9SSWeA==",
"dev": true
},
"@babel/template": {
@ -700,9 +700,9 @@
}
},
"@babel/types": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.2.tgz",
"integrity": "sha512-SdjAG/3DikRHpUOjxZgnkbR11xUlyDMUFJdvnIgZEE16mqmY0BINMmc4//JMJglEmn6i7sq6p+mGrFWyZ98EEw==",
"version": "7.14.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.4.tgz",
"integrity": "sha512-lCj4aIs0xUefJFQnwwQv2Bxg7Omd6bgquZ6LGC+gGMh6/s5qDVfjuCMlDmYQ15SLsWHd9n+X3E75lKIhl5Lkiw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.14.0",
@ -789,9 +789,9 @@
},
"dependencies": {
"@babel/types": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.2.tgz",
"integrity": "sha512-SdjAG/3DikRHpUOjxZgnkbR11xUlyDMUFJdvnIgZEE16mqmY0BINMmc4//JMJglEmn6i7sq6p+mGrFWyZ98EEw==",
"version": "7.14.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.4.tgz",
"integrity": "sha512-lCj4aIs0xUefJFQnwwQv2Bxg7Omd6bgquZ6LGC+gGMh6/s5qDVfjuCMlDmYQ15SLsWHd9n+X3E75lKIhl5Lkiw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.14.0",
@ -813,15 +813,15 @@
"dev": true
},
"@babel/helper-replace-supers": {
"version": "7.13.12",
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.13.12.tgz",
"integrity": "sha512-Gz1eiX+4yDO8mT+heB94aLVNCL+rbuT2xy4YfyNqu8F+OI6vMvJK891qGBTqL9Uc8wxEvRW92Id6G7sDen3fFw==",
"version": "7.14.4",
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.14.4.tgz",
"integrity": "sha512-zZ7uHCWlxfEAAOVDYQpEf/uyi1dmeC7fX4nCf2iz9drnCwi1zvwXL3HwWWNXUQEJ1k23yVn3VbddiI9iJEXaTQ==",
"dev": true,
"requires": {
"@babel/helper-member-expression-to-functions": "^7.13.12",
"@babel/helper-optimise-call-expression": "^7.12.13",
"@babel/traverse": "^7.13.0",
"@babel/types": "^7.13.12"
"@babel/traverse": "^7.14.2",
"@babel/types": "^7.14.4"
},
"dependencies": {
"@babel/code-frame": {
@ -834,9 +834,9 @@
}
},
"@babel/generator": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.2.tgz",
"integrity": "sha512-OnADYbKrffDVai5qcpkMxQ7caomHOoEwjkouqnN2QhydAjowFAZcsdecFIRUBdb+ZcruwYE4ythYmF1UBZU5xQ==",
"version": "7.14.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.3.tgz",
"integrity": "sha512-bn0S6flG/j0xtQdz3hsjJ624h3W0r3llttBMfyHX3YrZ/KtLYr15bjA0FXkgW7FpvrDuTuElXeVjiKlYRpnOFA==",
"dev": true,
"requires": {
"@babel/types": "^7.14.2",
@ -885,9 +885,9 @@
}
},
"@babel/parser": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.2.tgz",
"integrity": "sha512-IoVDIHpsgE/fu7eXBeRWt8zLbDrSvD7H1gpomOkPpBoEN8KCruCqSDdqo8dddwQQrui30KSvQBaMUOJiuFu6QQ==",
"version": "7.14.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.4.tgz",
"integrity": "sha512-ArliyUsWDUqEGfWcmzpGUzNfLxTdTp6WU4IuP6QFSp9gGfWS6boxFCkJSJ/L4+RG8z/FnIU3WxCk6hPL9SSWeA==",
"dev": true
},
"@babel/template": {
@ -918,9 +918,9 @@
}
},
"@babel/types": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.2.tgz",
"integrity": "sha512-SdjAG/3DikRHpUOjxZgnkbR11xUlyDMUFJdvnIgZEE16mqmY0BINMmc4//JMJglEmn6i7sq6p+mGrFWyZ98EEw==",
"version": "7.14.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.4.tgz",
"integrity": "sha512-lCj4aIs0xUefJFQnwwQv2Bxg7Omd6bgquZ6LGC+gGMh6/s5qDVfjuCMlDmYQ15SLsWHd9n+X3E75lKIhl5Lkiw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.14.0",
@ -1007,9 +1007,9 @@
},
"dependencies": {
"@babel/types": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.2.tgz",
"integrity": "sha512-SdjAG/3DikRHpUOjxZgnkbR11xUlyDMUFJdvnIgZEE16mqmY0BINMmc4//JMJglEmn6i7sq6p+mGrFWyZ98EEw==",
"version": "7.14.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.4.tgz",
"integrity": "sha512-lCj4aIs0xUefJFQnwwQv2Bxg7Omd6bgquZ6LGC+gGMh6/s5qDVfjuCMlDmYQ15SLsWHd9n+X3E75lKIhl5Lkiw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.14.0",
@ -1066,9 +1066,9 @@
}
},
"@babel/generator": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.2.tgz",
"integrity": "sha512-OnADYbKrffDVai5qcpkMxQ7caomHOoEwjkouqnN2QhydAjowFAZcsdecFIRUBdb+ZcruwYE4ythYmF1UBZU5xQ==",
"version": "7.14.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.3.tgz",
"integrity": "sha512-bn0S6flG/j0xtQdz3hsjJ624h3W0r3llttBMfyHX3YrZ/KtLYr15bjA0FXkgW7FpvrDuTuElXeVjiKlYRpnOFA==",
"dev": true,
"requires": {
"@babel/types": "^7.14.2",
@ -1117,9 +1117,9 @@
}
},
"@babel/parser": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.2.tgz",
"integrity": "sha512-IoVDIHpsgE/fu7eXBeRWt8zLbDrSvD7H1gpomOkPpBoEN8KCruCqSDdqo8dddwQQrui30KSvQBaMUOJiuFu6QQ==",
"version": "7.14.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.4.tgz",
"integrity": "sha512-ArliyUsWDUqEGfWcmzpGUzNfLxTdTp6WU4IuP6QFSp9gGfWS6boxFCkJSJ/L4+RG8z/FnIU3WxCk6hPL9SSWeA==",
"dev": true
},
"@babel/template": {
@ -1150,9 +1150,9 @@
}
},
"@babel/types": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.2.tgz",
"integrity": "sha512-SdjAG/3DikRHpUOjxZgnkbR11xUlyDMUFJdvnIgZEE16mqmY0BINMmc4//JMJglEmn6i7sq6p+mGrFWyZ98EEw==",
"version": "7.14.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.4.tgz",
"integrity": "sha512-lCj4aIs0xUefJFQnwwQv2Bxg7Omd6bgquZ6LGC+gGMh6/s5qDVfjuCMlDmYQ15SLsWHd9n+X3E75lKIhl5Lkiw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.14.0",
@ -1368,10 +1368,27 @@
}
}
},
"@formatjs/ecma402-abstract": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.9.3.tgz",
"integrity": "sha512-DBrRUL65m4SVtfq+T4Qltd8+upAzfb9K1MX0UZ0hqQ0wpBY0PSIti9XJe0ZQ/j2v/KxpwQ0Jw5NLumKVezJFQg==",
"dev": true,
"requires": {
"tslib": "^2.1.0"
},
"dependencies": {
"tslib": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz",
"integrity": "sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==",
"dev": true
}
}
},
"@formatjs/intl-getcanonicallocales": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-1.6.0.tgz",
"integrity": "sha512-1967VujZeow0K0NpzerdNOaskcE0KwnnchxT8TzlkLs4RjXx7Uz0bjQPtUYGV7kvbgMJ9qb6tWmCqIwe3sBKUw==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-1.7.0.tgz",
"integrity": "sha512-3pQjp+WCHrFfkUeSuw//LIx/orcykopZX4U/2kY1jTYhATVJbKdU8Rl5V2/d+fw1naKjKYoIjIXGn85Ti396+A==",
"dev": true,
"requires": {
"cldr-core": "38",
@ -1387,26 +1404,17 @@
}
},
"@formatjs/intl-locale": {
"version": "2.4.26",
"resolved": "https://registry.npmjs.org/@formatjs/intl-locale/-/intl-locale-2.4.26.tgz",
"integrity": "sha512-f5NyHb5qdfA0oO2IGLhNU0k9BWq8xI26VqxzZqjTfEKnn8xJ+SBE9drwRfTqlRz6pmvztPmkDg3FSflmGdDKGw==",
"version": "2.4.32",
"resolved": "https://registry.npmjs.org/@formatjs/intl-locale/-/intl-locale-2.4.32.tgz",
"integrity": "sha512-jA6f4ASAO3P4mBl+1nxc+lhx6x74wP+ahUOKPK9tPYZmCn7JPTAlit0iWae79vH3wE9TblOTUZ9RwYlOPWzchw==",
"dev": true,
"requires": {
"@formatjs/ecma402-abstract": "1.8.0",
"@formatjs/intl-getcanonicallocales": "1.6.0",
"@formatjs/ecma402-abstract": "1.9.3",
"@formatjs/intl-getcanonicallocales": "1.7.0",
"cldr-core": "38",
"tslib": "^2.1.0"
},
"dependencies": {
"@formatjs/ecma402-abstract": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.8.0.tgz",
"integrity": "sha512-X+nxZcIQr0YfYNtw1ZkHjN3YSyi0fEmdAJqRzk24KwNvqLv7GmVfw70mf7ADnwOvkcrSaAdx24GfAqckGTv9ww==",
"dev": true,
"requires": {
"tslib": "^2.1.0"
}
},
"tslib": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz",
@ -1416,24 +1424,15 @@
}
},
"@formatjs/intl-pluralrules": {
"version": "4.0.20",
"resolved": "https://registry.npmjs.org/@formatjs/intl-pluralrules/-/intl-pluralrules-4.0.20.tgz",
"integrity": "sha512-ayyjvIh9ShXQxo0Y9GhUTyW9zyB3jiBFRIEhMmGowpIlTVRhOwl57XZ+YuUgo/yCvtJqnvGPu/4/fQTouxHuGw==",
"version": "4.0.27",
"resolved": "https://registry.npmjs.org/@formatjs/intl-pluralrules/-/intl-pluralrules-4.0.27.tgz",
"integrity": "sha512-Q4RAXZXuhWwMWK3Vsbf9AIRBa9B+BTiWjNkzlCq77pCRQYo555owWrGxnZUVk2203wygOIkoRYSvP7Tu2RXNFA==",
"dev": true,
"requires": {
"@formatjs/ecma402-abstract": "1.8.0",
"@formatjs/ecma402-abstract": "1.9.3",
"tslib": "^2.1.0"
},
"dependencies": {
"@formatjs/ecma402-abstract": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.8.0.tgz",
"integrity": "sha512-X+nxZcIQr0YfYNtw1ZkHjN3YSyi0fEmdAJqRzk24KwNvqLv7GmVfw70mf7ADnwOvkcrSaAdx24GfAqckGTv9ww==",
"dev": true,
"requires": {
"tslib": "^2.1.0"
}
},
"tslib": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz",
@ -20856,9 +20855,9 @@
}
},
"scratch-blocks": {
"version": "0.1.0-prerelease.20210518033204",
"resolved": "https://registry.npmjs.org/scratch-blocks/-/scratch-blocks-0.1.0-prerelease.20210518033204.tgz",
"integrity": "sha512-o3RUtZuepebeWvk0qGnemE1Efl3LTA/uULuyXDdFDCtigY8UJd4G981KW7NrBUzn6hcIC3tmtOw3JyR4MStBHA==",
"version": "0.1.0-prerelease.20210606091012",
"resolved": "https://registry.npmjs.org/scratch-blocks/-/scratch-blocks-0.1.0-prerelease.20210606091012.tgz",
"integrity": "sha512-ZAK5zI4rTJSOsyP6ZImHJkH5dtSwHZCRpH5UL3dx0PGyO2lHTljhKZ6WUzONyGi4lAE2KOmxWKFNkzkXketbBg==",
"dev": true,
"requires": {
"exports-loader": "0.6.3",
@ -20866,9 +20865,9 @@
}
},
"scratch-gui": {
"version": "0.1.0-prerelease.20210519034531",
"resolved": "https://registry.npmjs.org/scratch-gui/-/scratch-gui-0.1.0-prerelease.20210519034531.tgz",
"integrity": "sha512-1QaK9K4jm2aztmvUcC1Wjwegy/eb8d6Tc6Zv/E8ppcmCvei/KchYuKIXbP+R5ezciRZvS7scqjLEuDoqRay1Xw==",
"version": "0.1.0-prerelease.20210606094924",
"resolved": "https://registry.npmjs.org/scratch-gui/-/scratch-gui-0.1.0-prerelease.20210606094924.tgz",
"integrity": "sha512-2Kll76gdxQT0QJoGhXabKf2byfA/rlrTFmyXzHvADLApIi/jo21/xclTD5MIu+EPbTwv5PL95N32osUvuedkcw==",
"dev": true,
"requires": {
"arraybuffer-loader": "^1.0.6",
@ -20919,14 +20918,14 @@
"redux": "3.7.2",
"redux-throttle": "0.1.1",
"scratch-audio": "0.1.0-prerelease.20200528195344",
"scratch-blocks": "0.1.0-prerelease.20210518033204",
"scratch-l10n": "3.11.20210519031633",
"scratch-blocks": "0.1.0-prerelease.20210606091012",
"scratch-l10n": "3.11.20210606031618",
"scratch-paint": "0.2.0-prerelease.20210407203313",
"scratch-render": "0.1.0-prerelease.20210325231800",
"scratch-render-fonts": "1.0.0-prerelease.20210401210003",
"scratch-storage": "1.3.4",
"scratch-storage": "1.3.5",
"scratch-svg-renderer": "0.2.0-prerelease.20210511195415",
"scratch-vm": "0.2.0-prerelease.20210510162256",
"scratch-vm": "0.2.0-prerelease.20210601191643",
"startaudiocontext": "1.2.1",
"style-loader": "^0.23.0",
"text-encoding": "0.7.0",
@ -21089,9 +21088,9 @@
"dev": true
},
"electron-to-chromium": {
"version": "1.3.732",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.732.tgz",
"integrity": "sha512-qKD5Pbq+QMk4nea4lMuncUMhpEiQwaJyCW7MrvissnRcBDENhVfDmAqQYRQ3X525oTzhar9Zh1cK0L2d1UKYcw==",
"version": "1.3.749",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.749.tgz",
"integrity": "sha512-F+v2zxZgw/fMwPz/VUGIggG4ZndDsYy0vlpthi3tjmDZlcfbhN5mYW0evXUsBr2sUtuDANFtle410A9u/sd/4A==",
"dev": true
},
"has-flag": {
@ -21312,28 +21311,17 @@
"dev": true
},
"scratch-storage": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/scratch-storage/-/scratch-storage-1.3.4.tgz",
"integrity": "sha512-BuMwX0337JKdHI90k9ULu5AapxMiVhwUTD9GEtAIUjyLUdiWO9MHCC0pzfrEXwosMV3BxtFzTe21UiSkWK9Pcw==",
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/scratch-storage/-/scratch-storage-1.3.5.tgz",
"integrity": "sha512-MrIRidvUJtncx0xlMJDng9KypHR3/kyCE2stXJ1CYNLTWrl2pGCpNNcYWygRQw7aFQ0hIzP/Z118T8X53sUDAA==",
"dev": true,
"requires": {
"arraybuffer-loader": "^1.0.8",
"arraybuffer-loader": "^1.0.3",
"base64-js": "1.3.0",
"fastestsmallesttextencoderdecoder": "^1.0.7",
"js-md5": "0.7.3",
"minilog": "3.1.0",
"worker-loader": "^2.0.0"
},
"dependencies": {
"arraybuffer-loader": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/arraybuffer-loader/-/arraybuffer-loader-1.0.8.tgz",
"integrity": "sha512-CwUVCcxCgcgZUu2w741OV6Xj1tvRVQebq22RCyGXiLgJOJ4e4M/59EPYdtK2MLfIN28t1TDvuh2ojstNq3Kh5g==",
"dev": true,
"requires": {
"loader-utils": "^1.1.0"
}
}
}
},
"source-list-map": {
@ -21388,9 +21376,9 @@
}
},
"scratch-l10n": {
"version": "3.11.20210519031633",
"resolved": "https://registry.npmjs.org/scratch-l10n/-/scratch-l10n-3.11.20210519031633.tgz",
"integrity": "sha512-MpwoltjrM0Yzi1j2I+s2QEneDLwnM8tt/FtD67dKNnRgkAS+gYuY+xcHf6w/DBhuN1GziDNHHB/LNvfMag4c8A==",
"version": "3.11.20210606031618",
"resolved": "https://registry.npmjs.org/scratch-l10n/-/scratch-l10n-3.11.20210606031618.tgz",
"integrity": "sha512-AKKaQERm74zlgfeY8YJ49nlBe0S+sa2ulB5ZAOfi+reD0J52tvNOrubQTtOPIRbp7PPfHYlnD84RCnVb/P73IA==",
"dev": true,
"requires": {
"@babel/cli": "^7.1.2",
@ -21478,15 +21466,6 @@
"twgl.js": "4.4.0"
},
"dependencies": {
"arraybuffer-loader": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/arraybuffer-loader/-/arraybuffer-loader-1.0.8.tgz",
"integrity": "sha512-CwUVCcxCgcgZUu2w741OV6Xj1tvRVQebq22RCyGXiLgJOJ4e4M/59EPYdtK2MLfIN28t1TDvuh2ojstNq3Kh5g==",
"dev": true,
"requires": {
"loader-utils": "^1.1.0"
}
},
"base64-js": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz",
@ -21524,12 +21503,12 @@
}
},
"scratch-storage": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/scratch-storage/-/scratch-storage-1.3.4.tgz",
"integrity": "sha512-BuMwX0337JKdHI90k9ULu5AapxMiVhwUTD9GEtAIUjyLUdiWO9MHCC0pzfrEXwosMV3BxtFzTe21UiSkWK9Pcw==",
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/scratch-storage/-/scratch-storage-1.3.5.tgz",
"integrity": "sha512-MrIRidvUJtncx0xlMJDng9KypHR3/kyCE2stXJ1CYNLTWrl2pGCpNNcYWygRQw7aFQ0hIzP/Z118T8X53sUDAA==",
"dev": true,
"requires": {
"arraybuffer-loader": "^1.0.8",
"arraybuffer-loader": "^1.0.3",
"base64-js": "1.3.0",
"fastestsmallesttextencoderdecoder": "^1.0.7",
"js-md5": "0.7.3",
@ -21666,9 +21645,9 @@
"dev": true
},
"scratch-vm": {
"version": "0.2.0-prerelease.20210510162256",
"resolved": "https://registry.npmjs.org/scratch-vm/-/scratch-vm-0.2.0-prerelease.20210510162256.tgz",
"integrity": "sha512-14KHdLRoEcpHRsLPkiBNnMDNAtoGYr0ZkAUdHatwvqTfzS1eN22qytqdkJ8Mx3LIxP52K/IMOVq9rFVUZ4df3w==",
"version": "0.2.0-prerelease.20210601191643",
"resolved": "https://registry.npmjs.org/scratch-vm/-/scratch-vm-0.2.0-prerelease.20210601191643.tgz",
"integrity": "sha512-SWXa176Ymo2EER+dEF5yJXGOaq7xekHcmggEJ2p+8vt3LZUlBpmUlL/U1FTY65wjaYLxQWMi7q+d+IpnO/vkEg==",
"dev": true,
"requires": {
"@vernier/godirect": "1.5.0",

View file

@ -27,7 +27,7 @@
"deploy": "npm run deploy:s3 && npm run deploy:fastly",
"deploy:fastly": "node ./bin/configure-fastly.js",
"deploy:s3": "npm run deploy:s3:all && npm run deploy:s3:svg && npm run deploy:s3:js",
"deploy:s3cmd": "s3cmd sync -P --delete-removed --add-header=Cache-Control:no-cache,public,max-age=3600",
"deploy:s3cmd": "s3cmd sync -P --delete-removed --add-header=Cache-Control:no-cache,public,max-age=3600 --add-header=x-amz-meta-surrogate-key:static-assets",
"deploy:s3:all": "npm run deploy:s3cmd -- --exclude '.DS_Store' --exclude '*.svg' --exclude '*.js' ./build/ s3://$S3_BUCKET_NAME/",
"deploy:s3:svg": "npm run deploy:s3cmd -- --exclude '*' --include '*.svg' --mime-type 'image/svg+xml' ./build/ s3://$S3_BUCKET_NAME/",
"deploy:s3:js": "npm run deploy:s3cmd -- --exclude '*' --include '*.js' --mime-type 'application/javascript' ./build/ s3://$S3_BUCKET_NAME/",
@ -126,8 +126,8 @@
"redux-mock-store": "^1.2.3",
"redux-thunk": "2.0.1",
"sass-loader": "6.0.6",
"scratch-gui": "0.1.0-prerelease.20210519034531",
"scratch-l10n": "latest",
"scratch-gui": "0.1.0-prerelease.20210606094924",
"scratch-l10n": "3.11.20210606031618",
"selenium-webdriver": "3.6.0",
"slick-carousel": "1.6.0",
"style-loader": "0.12.3",

View file

@ -0,0 +1,41 @@
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import {FormattedMessage} from 'react-intl';
import Button from '../../components/forms/button.jsx';
import './alert.scss';
const AlertComponent = ({className, icon, id, values, onClear}) => (
<div className="alert-wrapper">
<div
className={classNames('alert', className)}
>
{icon && <img
className="alert-icon"
src={icon}
/>}
<div className="alert-msg">
<FormattedMessage
id={id}
values={values}
/>
</div>
{onClear && <Button
className="alert-close-button"
isCloseType
onClick={onClear}
/>}
</div>
</div>
);
AlertComponent.propTypes = {
className: PropTypes.string,
icon: PropTypes.string,
id: PropTypes.string.isRequired,
values: PropTypes.shape({}),
onClear: PropTypes.func
};
export default AlertComponent;

View file

@ -0,0 +1,18 @@
import {createContext, useContext} from 'react';
import AlertStatus from './alert-status.js';
const AlertContext = createContext({
// Note: defaults here are only used if there is no Provider in the tree
status: AlertStatus.NONE,
data: {},
clearAlert: () => {},
successAlert: () => {},
errorAlert: () => {}
});
const useAlertContext = () => useContext(AlertContext);
export {
AlertContext as default,
useAlertContext
};

View file

@ -0,0 +1,54 @@
import React, {useRef, useState} from 'react';
import PropTypes from 'prop-types';
import AlertStatus from './alert-status.js';
import AlertContext from './alert-context.js';
const AlertProvider = ({children}) => {
const defaultState = {
status: AlertStatus.NONE,
data: {},
showClear: false
};
const [state, setState] = useState(defaultState);
const timeoutRef = useRef(null);
const clearAlert = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = null;
setState(defaultState);
};
const handleAlert = (status, data, timeoutSeconds = 3) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setState({status, data, showClear: !timeoutSeconds});
if (timeoutSeconds) {
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
setState(defaultState);
}, timeoutSeconds * 1000);
}
};
return (
<AlertContext.Provider
value={{
status: state.status,
data: state.data,
showClear: state.showClear,
clearAlert: clearAlert,
successAlert: (newData, timeoutSeconds = 3) =>
handleAlert(AlertStatus.SUCCESS, newData, timeoutSeconds),
errorAlert: (newData, timeoutSeconds = 3) =>
handleAlert(AlertStatus.ERROR, newData, timeoutSeconds)
}}
>
{children}
</AlertContext.Provider>
);
};
AlertProvider.propTypes = {
children: PropTypes.node
};
export default AlertProvider;

View file

@ -0,0 +1,5 @@
export default {
NONE: 'NONE',
SUCCESS: 'SUCCESS',
ERROR: 'ERROR'
};

View file

@ -0,0 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import AlertComponent from './alert-component.jsx';
import AlertStatus from './alert-status.js';
import {useAlertContext} from './alert-context.js';
import successIcon from './icon-alert-success.svg';
import errorIcon from './icon-alert-error.svg';
const Alert = ({className}) => {
const {status, data, showClear, clearAlert} = useAlertContext();
if (status === AlertStatus.NONE) return null;
return (
<AlertComponent
className={classNames(className, {
'alert-success': status === AlertStatus.SUCCESS,
'alert-error': status === AlertStatus.ERROR
})}
icon={status === AlertStatus.SUCCESS ? successIcon : errorIcon}
id={data.id}
values={data.values}
onClear={showClear && clearAlert}
/>
);
};
Alert.propTypes = {
className: PropTypes.string
};
export default Alert;

View file

@ -0,0 +1,40 @@
.alert-wrapper {
position: absolute;
display: flex;
width: 100%;
justify-content: center;
z-index: 100;
pointer-events: none;
.alert {
display: flex;
box-sizing: border-box;
padding: 10px 20px;
border-radius: 8px;
align-items: center;
margin-top: 1rem;
min-height: 60px;
pointer-events: auto;
&.alert-error {
background: #FFF0DF;
border: 1px solid #FF8C1A;
box-shadow: 0px 0px 0px 2px rgba(255, 140, 26, 0.25)
}
&.alert-success {
background: #CEF2E8;
border: 1px solid #0EBD8C;
box-shadow: 0px 0px 0px rgba(14, 189, 140, 0.25);
}
.alert-msg {
font-size: 14px;
font-weight: bold;
}
.alert-close-button {
position: unset;
margin-left: 1rem;
}
}
}

View file

@ -0,0 +1,3 @@
<svg width="28" height="20" viewBox="-2 -1 15 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.40571 6.50912C1.50472 6.50912 0.775271 7.23857 0.775271 8.13956C0.775271 9.04055 1.50472 9.77 2.40571 9.77C3.3067 9.77 4.03615 9.04055 4.03615 8.13956C4.03615 7.23857 3.3067 6.50912 2.40571 6.50912ZM3.34168 5.02359C2.92699 5.9523 1.88444 5.9523 1.46975 5.02359L0.145744 2.07519C-0.268945 1.15289 0.250665 0 1.08171 0H3.72972C4.56076 0 5.08037 1.15289 4.66568 2.07519L3.34168 5.02359Z" fill="#FF8C1A"/>
</svg>

After

Width:  |  Height:  |  Size: 559 B

View file

@ -0,0 +1,9 @@
<svg width="28" height="20" viewBox="0 0 28 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.86144 15.403C7.43527 15.403 7.0091 15.2398 6.68447 14.9152L3.48818 11.7189C2.83727 11.068 2.83727 10.0159 3.48818 9.36498C4.13909 8.71407 5.19121 8.71407 5.84212 9.36498L7.86144 11.3843L14.1591 5.08828C14.8084 4.43737 15.8622 4.43737 16.5131 5.08828C17.1623 5.73753 17.1623 6.7913 16.5131 7.44222L9.03841 14.9152C8.71378 15.2398 8.28761 15.403 7.86144 15.403Z" fill="#575E75"/>
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="3" y="4" width="14" height="12">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.86144 15.403C7.43527 15.403 7.0091 15.2398 6.68447 14.9152L3.48818 11.7189C2.83727 11.068 2.83727 10.0159 3.48818 9.36498C4.13909 8.71407 5.19121 8.71407 5.84212 9.36498L7.86144 11.3843L14.1591 5.08828C14.8084 4.43737 15.8622 4.43737 16.5131 5.08828C17.1623 5.73753 17.1623 6.7913 16.5131 7.44222L9.03841 14.9152C8.71378 15.2398 8.28761 15.403 7.86144 15.403Z" fill="white"/>
</mask>
<g mask="url(#mask0)">
<rect width="20" height="20" fill="#0FBD8C"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -7,6 +7,7 @@
margin: .5rem 0 2.25rem;
background-color: $ui-blue-10percent;
text-align: center;
box-sizing: border-box;
p {
margin-bottom: 0;

View file

@ -135,6 +135,7 @@ class Navigation extends React.Component {
/>
<Input
aria-label={this.props.intl.formatMessage({id: 'general.search'})}
className="search-wrapper"
name="q"
placeholder={this.props.intl.formatMessage({id: 'general.search'})}
type="text"

View file

@ -12,7 +12,7 @@
flex-wrap: wrap;
li {
li, button {
display: inline-block;
margin: 5px;
border: 1px solid $active-gray;

View file

@ -338,6 +338,7 @@
"comments.cancel": "Cancel",
"comments.lengthWarning": "{remainingCharacters, plural, one {1 character left} other {{remainingCharacters} characters left}}",
"comments.loadMoreReplies": "See more replies",
"comments.replyLimitReached": "This comment thread has reached its limit. To continue commenting, you can start a new thread.",
"comments.status.delbyusr": "Deleted by project owner",
"comments.status.censbyfilter": "Censored by filter",
"comments.status.delbyparentcomment": "Parent comment deleted",

View file

@ -130,6 +130,11 @@ module.exports.selectIsSocial = state => get(state, ['session', 'session', 'perm
module.exports.selectIsEducator = state => get(state, ['session', 'session', 'permissions', 'educator'], false);
module.exports.selectProjectCommentsGloballyEnabled = state =>
get(state, ['session', 'session', 'flags', 'project_comments_enabled'], false);
module.exports.selectMuteStatus = state => get(state, ['session', 'session', 'permissions', 'mute_status'],
{muteExpiresAt: 0, offenses: [], showWarning: false});
module.exports.selectIsMuted = state => (module.exports.selectMuteStatus(state).muteExpiresAt || 0) * 1000 > Date.now();
module.exports.selectHasFetchedSession = state => state.session.status === module.exports.Status.FETCHED;
// NB logged out user id as NaN so that it can never be used in equality testing since NaN !== NaN
module.exports.selectUserId = state => get(state, ['session', 'session', 'user', 'id'], NaN);

View file

@ -1,4 +1,5 @@
const {selectUserId, selectIsAdmin, selectIsSocial, selectIsLoggedIn, selectUsername} = require('./session');
const {selectUserId, selectIsAdmin, selectIsSocial,
selectIsLoggedIn, selectUsername, selectIsMuted} = require('./session');
// Fine-grain selector helpers - not exported, use the higher level selectors below
const isCreator = state => selectUserId(state) === state.studio.owner;
@ -6,11 +7,12 @@ const isCurator = state => state.studio.curator;
const isManager = state => state.studio.manager || isCreator(state);
// Action-based permissions selectors
const selectCanEditInfo = state => selectIsAdmin(state) || isManager(state);
const selectCanEditInfo = state => !selectIsMuted(state) && (selectIsAdmin(state) || isManager(state));
const selectCanAddProjects = state =>
isManager(state) ||
!selectIsMuted(state) &&
(isManager(state) ||
isCurator(state) ||
(selectIsSocial(state) && state.studio.openToAll);
(selectIsSocial(state) && state.studio.openToAll));
// This isn't "canComment" since they could be muted, but comment composer handles that
const selectShowCommentComposer = state => selectIsSocial(state);
@ -26,12 +28,13 @@ const selectCanDeleteCommentWithoutConfirm = state => selectIsAdmin(state);
const selectCanFollowStudio = state => selectIsLoggedIn(state);
// Matching existing behavior, only admin/creator is allowed to toggle comments.
const selectCanEditCommentsAllowed = state => selectIsAdmin(state) || isCreator(state);
const selectCanEditOpenToAll = state => isManager(state);
const selectCanEditCommentsAllowed = state => !selectIsMuted(state) && (selectIsAdmin(state) || isCreator(state));
const selectCanEditOpenToAll = state => !selectIsMuted(state) && isManager(state);
const selectShowCuratorInvite = state => !!state.studio.invited;
const selectCanInviteCurators = state => isManager(state);
const selectShowCuratorInvite = state => !selectIsMuted(state) && !!state.studio.invited;
const selectCanInviteCurators = state => !selectIsMuted(state) && isManager(state);
const selectCanRemoveCurator = (state, username) => {
if (selectIsMuted(state)) return false;
// Admins/managers can remove any curators
if (isManager(state) || selectIsAdmin(state)) return true;
// Curators can remove themselves
@ -41,10 +44,12 @@ const selectCanRemoveCurator = (state, username) => {
return false;
};
const selectCanRemoveManager = (state, managerId) =>
(selectIsAdmin(state) || isManager(state)) && managerId !== state.studio.owner;
const selectCanPromoteCurators = state => isManager(state);
!selectIsMuted(state) && (selectIsAdmin(state) || isManager(state)) && managerId !== state.studio.owner;
const selectCanPromoteCurators = state => !selectIsMuted(state) && isManager(state);
const selectCanRemoveProject = (state, creatorUsername, actorId) => {
if (selectIsMuted(state)) return false;
// Admins/managers can remove any projects
if (isManager(state) || selectIsAdmin(state)) return true;
// Project owners can always remove their projects
@ -58,6 +63,15 @@ const selectCanRemoveProject = (state, creatorUsername, actorId) => {
return false;
};
// We should only show the mute errors to muted users who have any permissions related to the content
const selectShowEditMuteError = state => selectIsMuted(state) && (isManager(state) || selectIsAdmin(state));
const selectShowProjectMuteError = state => selectIsMuted(state) &&
(selectIsAdmin(state) ||
isManager(state) ||
isCurator(state) ||
(selectIsSocial(state) && state.studio.openToAll));
const selectShowCuratorMuteError = state => selectIsMuted(state) && (isManager(state) || selectIsAdmin(state));
export {
selectCanEditInfo,
selectCanAddProjects,
@ -74,5 +88,8 @@ export {
selectCanRemoveCurator,
selectCanRemoveManager,
selectCanPromoteCurators,
selectCanRemoveProject
selectCanRemoveProject,
selectShowEditMuteError,
selectShowProjectMuteError,
selectShowCuratorMuteError
};

View file

@ -94,7 +94,11 @@ const selectStudioDescription = state => state.studio.description;
const selectStudioImage = state => state.studio.image;
const selectStudioOpenToAll = state => state.studio.openToAll;
const selectStudioCommentsAllowed = state => state.studio.commentsAllowed;
const selectStudioLastUpdated = state => state.studio.updated;
const selectStudioLoadFailed = state => state.studio.infoStatus === Status.ERROR;
const selectStudioCommentCount = state => state.studio.commentCount;
const selectStudioFollowerCount = state => state.studio.followers;
const selectStudioProjectCount = state => state.studio.projectCount;
const selectIsFetchingInfo = state => state.studio.infoStatus === Status.FETCHING;
const selectIsFollowing = state => state.studio.following;
const selectIsFetchingRoles = state => state.studio.rolesStatus === Status.FETCHING;
@ -115,7 +119,9 @@ const getInfo = () => ((dispatch, getState) => {
openToAll: body.open_to_all,
commentsAllowed: body.comments_allowed,
updated: new Date(body.history.modified),
commentCount: body.stats.comments,
followers: body.stats.followers,
projectCount: body.stats.projects,
owner: body.owner
}));
});
@ -170,7 +176,11 @@ module.exports = {
selectStudioImage,
selectStudioOpenToAll,
selectStudioCommentsAllowed,
selectStudioLastUpdated,
selectStudioLoadFailed,
selectStudioCommentCount,
selectStudioFollowerCount,
selectStudioProjectCount,
selectIsFetchingInfo,
selectIsFetchingRoles,
selectIsFollowing,

View file

@ -301,7 +301,8 @@
"pattern": "^/studios-playground/\\d+(/projects|/curators|/activity|/comments)?/?(\\?.*)?$",
"routeAlias": "/studios-playground/?$",
"view": "studio/studio",
"title": "Studio Playground"
"title": "Studio Playground",
"dynamicMetaTags": true
},
{
"name": "teacher-faq",

View file

@ -14,12 +14,43 @@ const SubNavigation = require('../../components/subnavigation/subnavigation.jsx'
const Select = require('../../components/forms/select.jsx');
const OverflowMenu = require('../../components/overflow-menu/overflow-menu.jsx').default;
const exampleIcon = require('./example-icon.svg');
const AlertProvider = require('../../components/alert/alert-provider.jsx').default;
const {useAlertContext} = require('../../components/alert/alert-context.js');
const Alert = require('../../components/alert/alert.jsx').default;
require('./components.scss');
/* eslint-disable react/prop-types, react/jsx-no-bind */
/* Demo of how to use the useAlertContext hook */
const AlertButton = ({type, timeoutSeconds}) => {
const {errorAlert, successAlert} = useAlertContext();
const onClick = type === 'success' ?
() => successAlert({id: 'success-alert.string.id'}, timeoutSeconds) :
() => errorAlert({id: 'error-alert.string.id'}, timeoutSeconds);
return (
<Button onClick={onClick}>
{type}, {timeoutSeconds || 'no '} timeout
</Button>
);
};
const Components = () => (
<div className="components">
<div className="inner">
<h1>Alert Provider, Display and Hooks</h1>
<AlertProvider>
<div style={{position: 'relative', minHeight: '200px', border: '1px solid red'}}>
<Alert />
<div><AlertButton
type="success"
timeoutSeconds={3}
/></div>
<div><AlertButton
type="error"
timeoutSeconds={null}
/></div>
</div>
</AlertProvider>
<h1>Overflow Menu</h1>
<div className="example-tile">
<OverflowMenu>

View file

@ -16,27 +16,28 @@ const Guidelines = () => (
}
>
<p>
<FormattedMessage
className="intro"
id="guidelines.header"
/>
<FormattedMessage id="guidelines.header1" />&nbsp;
<strong><FormattedMessage id="guidelines.header2" /></strong>&nbsp;
<FormattedMessage id="guidelines.header3" />
</p>
<dl>
<dt><FormattedMessage id="guidelines.respectheader" /></dt>
<dd><FormattedMessage id="guidelines.respectbody" /></dd>
<dt><FormattedMessage id="guidelines.constructiveheader" /></dt>
<dd><FormattedMessage id="guidelines.constructivebody" /></dd>
<dt><FormattedMessage id="guidelines.shareheader" /></dt>
<dd><FormattedMessage id="guidelines.sharebody" /></dd>
<dt><FormattedMessage id="guidelines.privacyheader" /></dt>
<dd><FormattedMessage id="guidelines.privacybody" /></dd>
<dt><FormattedMessage id="guidelines.helpfulheader" /></dt>
<dd><FormattedMessage id="guidelines.helpfulbody" /></dd>
<dt><FormattedMessage id="guidelines.remixheader" /></dt>
<dd>
<em><FormattedMessage id="guidelines.remixbody1" /></em><br />
<FormattedMessage id="guidelines.remixbody2" />
</dd>
<dt><FormattedMessage id="guidelines.honestyheader" /></dt>
<dd><FormattedMessage id="guidelines.honestybody" /></dd>
<dt><FormattedMessage id="guidelines.friendlyheader" /></dt>
<dd><FormattedMessage id="guidelines.friendlybody" /></dd>
</dl>
<div className="guidelines-footer">
<p><FormattedMessage id="guidelines.footer" /></p>
<img
alt="sprites"
src="//cdn.scratch.mit.edu/scratchr2/static/images/help/spritesforcommunityguid.png"

View file

@ -1,17 +1,19 @@
{
"guidelines.title": "Scratch Community Guidelines",
"guidelines.header": "We need everyones help to keep Scratch a friendly and creative community where people with different backgrounds and interests feel welcome.",
"guidelines.respectheader": "Be respectful.",
"guidelines.respectbody": "When sharing projects or posting comments, remember that people of many different ages and backgrounds will see what youve shared.",
"guidelines.constructiveheader": "Be constructive.",
"guidelines.constructivebody": "When commenting on others' projects, say something you like about it and offer suggestions.",
"guidelines.shareheader": "Share.",
"guidelines.sharebody": "You are free to remix projects, ideas, images, or anything else you find on Scratch and anyone can use anything that you share. Be sure to give credit when you remix.",
"guidelines.privacyheader": "Keep personal info private.",
"guidelines.privacybody": "For safety reasons, don't give out any information that could be used for private communication - such as real last names, phone numbers, addresses, email addresses, links to social media sites or websites with unmoderated chat.",
"guidelines.header1": "Scratch is a friendly and welcoming community for everyone, where people create, share, and learn together.",
"guidelines.header2": "We welcome people of all ages, races, ethnicities, religions, abilities, sexual orientations, and gender identities.",
"guidelines.header3": "Help keep Scratch a welcoming, supportive, and creative space for all by following these Community Guidelines:",
"guidelines.respectheader": "Treat everyone with respect.",
"guidelines.respectbody": "Scratchers have diverse backgrounds, interests, identities, and experiences. Everyone on Scratch is encouraged to share things that excite them and are important to them—we hope that you find ways to celebrate your own identity on Scratch, and allow others to do the same. Its never OK to attack a person or groups identity or to be unkind to someone about their background or interests.",
"guidelines.privacyheader": "Be safe: keep personal and contact information private.",
"guidelines.privacybody": "For safety reasons, don't give out any information that could be used for private communication, in person or online. This includes sharing real last names, phone numbers, addresses, hometowns, school names, email addresses, usernames or links to social media sites, video chatting applications, or websites with private chat functionality.",
"guidelines.helpfulheader": "Give helpful feedback.",
"guidelines.helpfulbody": "Everyone on Scratch is learning. When commenting on a project, remember to say something you like about it, offer suggestions, and be kind, not critical. Please keep comments respectful and avoid spamming or posting chain mail. We encourage you to try new things, experiment, and learn from others.",
"guidelines.remixheader": "Embrace remix culture.",
"guidelines.remixbody1": "Remixing is when you build upon someone elses projects, code, ideas, images, or anything else they share on Scratch to make your own unique creation.",
"guidelines.remixbody2": "Remixing is a great way to collaborate and connect with other Scratchers. You are encouraged to use anything you find on Scratch in your own creations, as long as you provide credit to everyone whose work you used and make a meaningful change to it. And when you share something on Scratch, you are giving permission to all Scratchers to use your work in their creations, too.",
"guidelines.honestyheader": "Be honest.",
"guidelines.honestybody": "Dont try to impersonate other Scratchers, spread rumors, or otherwise try to trick the community.",
"guidelines.honestybody": "Its important to be honest and authentic when interacting with others on Scratch, and remember that there is a person behind every Scratch account. Spreading rumors, impersonating other Scratchers or celebrities, or pretending to be seriously ill is not respectful to the Scratch Community.",
"guidelines.friendlyheader": "Help keep the site friendly.",
"guidelines.friendlybody": "If you think a project or comment is mean, insulting, too violent, or otherwise inappropriate, click “Report” to let us know about it.",
"guidelines.footer": "Scratch welcomes people of all ages, races, ethnicities, religions, abilities, sexual orientations, and gender identities."
"guidelines.friendlybody": "Its important to keep your creations and conversations friendly and appropriate for all ages. If you think something on Scratch is mean, insulting, too violent, or otherwise disruptive to the community, click “Report” to let us know about it. Please use the “Report” button rather than engaging in fights, spreading rumors about other peoples behavior, or otherwise responding to any inappropriate content. The Scratch Team will look at your report and take the appropriate action."
}

View file

@ -26,6 +26,7 @@ class Comment extends React.Component {
'handleConfirmReport',
'handleCancelReport',
'handlePostReply',
'handleReply',
'handleToggleReplying',
'handleRestore',
'setRef'
@ -49,6 +50,14 @@ class Comment extends React.Component {
this.props.onAddComment(comment);
}
handleReply () {
if (this.props.hasReachedThreadLimit) {
this.props.onReply(this.props.id, (this.props.parentId || this.props.id));
} else {
this.handleToggleReplying();
}
}
handleToggleReplying () {
this.setState({replying: !this.state.replying});
}
@ -220,7 +229,7 @@ class Comment extends React.Component {
{(canReply && visible) ? (
<span
className="comment-reply"
onClick={this.handleToggleReplying}
onClick={this.handleReply}
>
<FormattedMessage id="comments.reply" />
</span>
@ -278,10 +287,12 @@ Comment.propTypes = {
canRestore: PropTypes.bool,
content: PropTypes.string,
datetimeCreated: PropTypes.string,
hasReachedThreadLimit: PropTypes.bool,
highlighted: PropTypes.bool,
id: PropTypes.number,
onAddComment: PropTypes.func,
onDelete: PropTypes.func,
onReply: PropTypes.func,
onReport: PropTypes.func,
onRestore: PropTypes.func,
parentId: PropTypes.number,

View file

@ -1,5 +1,10 @@
@import "../../../colors";
.compose-row {
margin-top: 30px;
margin-bottom: 40px;
}
.compose-comment {
margin-left: .5rem;
width: 100%;
@ -279,6 +284,15 @@
}
}
.thread-limit-status {
width: calc(100% - 4rem);
margin-left: auto;
.comment-status-icon {
display: none;
}
}
.compose-disabled {
opacity: .5;
}

View file

@ -17,6 +17,7 @@ const formatTime = require('../../../lib/format-time');
const connect = require('react-redux').connect;
const api = require('../../../lib/api');
const {selectMuteStatus} = require('../../../redux/session.js');
require('./comment.scss');
@ -329,7 +330,7 @@ class ComposeComment extends React.Component {
className={classNames('flex-row',
'comment',
this.state.status === ComposeStatus.REJECTED_MUTE ?
'compose-disabled' : '')}
'compose-disabled' : 'compose-row')}
>
<a href={`/users/${this.props.user.username}`}>
<Avatar src={this.props.user.thumbnailUrl} />
@ -444,9 +445,7 @@ ComposeComment.propTypes = {
};
const mapStateToProps = state => ({
muteStatus: state.session.session.permissions.mute_status ?
state.session.session.permissions.mute_status :
{muteExpiresAt: 0, offenses: [], showWarning: false},
muteStatus: selectMuteStatus(state),
user: state.session.session.user
});

View file

@ -6,9 +6,13 @@ const FormattedMessage = require('react-intl').FormattedMessage;
const FlexRow = require('../../../components/flex-row/flex-row.jsx');
const Comment = require('./comment.jsx');
const CommentingStatus = require('../../../components/commenting-status/commenting-status.jsx');
require('./comment.scss');
// Thread limit only applies if hasThreadLimit prop is true
const THREAD_LIMIT = 25;
class TopLevelComment extends React.Component {
constructor (props) {
super(props);
@ -16,11 +20,14 @@ class TopLevelComment extends React.Component {
'handleExpandThread',
'handleAddComment',
'handleDeleteReply',
'handleReplyStatus',
'handleReportReply',
'handleRestoreReply'
]);
this.state = {
expanded: this.props.defaultExpanded
expanded: this.props.defaultExpanded,
threadLimitCommentId: '',
threadLimitParentId: ''
};
// A cache of {userId: username, ...} in order to show reply usernames
@ -55,6 +62,12 @@ class TopLevelComment extends React.Component {
this.props.onAddComment(comment, this.props.id);
}
handleReplyStatus (id, parentId) {
// Send the parentId up to track which thread got "reply" clicked on
if (this.props.onReply) this.props.onReply(parentId);
this.setState({threadLimitCommentId: id, threadLimitParentId: parentId});
}
authorUsername (authorId) {
if (this.authorUsernameCache[authorId]) return this.authorUsernameCache[authorId];
@ -80,6 +93,7 @@ class TopLevelComment extends React.Component {
canRestore,
content,
datetimeCreated,
hasThreadLimit,
highlightedCommentId,
id,
moreRepliesToLoad,
@ -88,17 +102,40 @@ class TopLevelComment extends React.Component {
onRestore,
replies,
postURI,
threadHasReplyStatus,
totalReplyCount,
visibility
} = this.props;
const parentVisible = visibility === 'visible';
// Check whether this comment thread has reached the thread limit
const hasReachedThreadLimit = hasThreadLimit && totalReplyCount >= THREAD_LIMIT;
/*
Check all the following conditions:
- hasReachedThreadLimit: the thread has reached the limit
- threadHasReplyStatus: this thread should be showing the status
(false, if the user just clicked reply elsewhere and another thread/comment stole the status message)
- Use the comment id and parent id of this particular comment in this thread
to see if it has the reply status,
only one comment in a thread can have the status
All of these conditions together ensure that the user only sees one status message on the comments page.
*/
const commentHasReplyStatus = (commentId, commentParentId) =>
hasReachedThreadLimit &&
threadHasReplyStatus &&
(this.state.threadLimitCommentId === commentId) &&
(this.state.threadLimitParentId === commentParentId);
return (
<FlexRow className="comment-container">
<Comment
highlighted={highlightedCommentId === id}
postURI={postURI}
onAddComment={this.handleAddComment}
onReply={this.handleReplyStatus}
{...{
author,
content,
@ -108,6 +145,7 @@ class TopLevelComment extends React.Component {
canReply,
canReport,
canRestore,
hasReachedThreadLimit,
id,
onDelete,
onReport,
@ -115,6 +153,13 @@ class TopLevelComment extends React.Component {
visibility
}}
/>
{commentHasReplyStatus(id, id) &&
<CommentingStatus className="thread-limit-status">
<p>
<FormattedMessage id="comments.replyLimitReached" />
</p>
</CommentingStatus>
}
{replies.length > 0 &&
<FlexRow
className={classNames(
@ -125,27 +170,40 @@ class TopLevelComment extends React.Component {
key={id}
>
{(this.state.expanded ? replies : replies.slice(0, 3)).map(reply => (
<Comment
author={reply.author}
canDelete={canDelete}
canDeleteWithoutConfirm={canDeleteWithoutConfirm}
canReply={canReply}
canReport={canReport}
canRestore={canRestore && parentVisible}
content={reply.content}
datetimeCreated={reply.datetime_created}
highlighted={highlightedCommentId === reply.id}
id={reply.id}
key={reply.id}
parentId={id}
postURI={postURI}
replyUsername={this.authorUsername(reply.commentee_id)}
visibility={reply.visibility}
onAddComment={this.handleAddComment}
onDelete={this.handleDeleteReply}
onReport={this.handleReportReply}
onRestore={this.handleRestoreReply}
/>
<React.Fragment
key={`reply-and-status-${reply.id}`}
>
<Comment
author={reply.author}
canDelete={canDelete}
canDeleteWithoutConfirm={canDeleteWithoutConfirm}
canReply={canReply}
canReport={canReport}
canRestore={canRestore && parentVisible}
content={reply.content}
datetimeCreated={reply.datetime_created}
hasReachedThreadLimit={hasReachedThreadLimit}
highlighted={highlightedCommentId === reply.id}
id={reply.id}
key={reply.id}
parentId={id}
postURI={postURI}
replyUsername={this.authorUsername(reply.commentee_id)}
visibility={reply.visibility}
onAddComment={this.handleAddComment}
onDelete={this.handleDeleteReply}
onReply={this.handleReplyStatus}
onReport={this.handleReportReply}
onRestore={this.handleRestoreReply}
/>
{commentHasReplyStatus(reply.id, id) &&
<CommentingStatus className="thread-limit-status">
<p>
<FormattedMessage id="comments.replyLimitReached" />
</p>
</CommentingStatus>
}
</React.Fragment>
))}
{((!this.state.expanded && replies.length > 3) ||
(this.state.expanded && moreRepliesToLoad)) &&
@ -179,24 +237,30 @@ TopLevelComment.propTypes = {
datetimeCreated: PropTypes.string,
defaultExpanded: PropTypes.bool,
deletable: PropTypes.bool,
hasThreadLimit: PropTypes.bool,
highlightedCommentId: PropTypes.oneOfType([PropTypes.number, PropTypes.bool]),
id: PropTypes.number,
moreRepliesToLoad: PropTypes.bool,
onAddComment: PropTypes.func,
onDelete: PropTypes.func,
onLoadMoreReplies: PropTypes.func,
onReply: PropTypes.func,
onReport: PropTypes.func,
onRestore: PropTypes.func,
parentId: PropTypes.number,
postURI: PropTypes.string,
replies: PropTypes.arrayOf(PropTypes.object),
threadHasReplyStatus: PropTypes.bool,
totalReplyCount: PropTypes.number,
visibility: PropTypes.string
};
TopLevelComment.defaultProps = {
canDeleteWithoutConfirm: false,
defaultExpanded: false,
moreRepliesToLoad: false
hasThreadLimit: false,
moreRepliesToLoad: false,
threadHasReplyStatus: false
};
module.exports = TopLevelComment;

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.70008 9.60002C8.42098 7.95136 9.87058 6.64641 11.7481 6.17829C14.9634 5.37663 18.2197 7.33326 19.0214 10.5485C19.8231 13.7638 17.8664 17.0202 14.6512 17.8218C11.9437 18.4969 9.20407 17.2157 7.92938 14.8716C7.61277 14.2893 6.88413 14.074 6.3019 14.3906C5.71967 14.7072 5.50434 15.4359 5.82094 16.0181C7.60517 19.2993 11.4374 21.0966 15.2318 20.1506C19.7332 19.0282 22.4724 14.4693 21.3501 9.96792C20.2278 5.46653 15.6689 2.72726 11.1675 3.84958C8.91622 4.41089 7.10663 5.8319 5.99998 7.67313V6.00002C5.99998 5.33728 5.46272 4.80002 4.79998 4.80002C4.13723 4.80002 3.59998 5.33728 3.59998 6.00002V10.8C3.59998 11.4628 4.13723 12 4.79998 12H9.59998C10.2627 12 10.8 11.4628 10.8 10.8C10.8 10.1373 10.2627 9.60002 9.59998 9.60002H7.70008Z" fill="white"/>
<path d="M13.2 7.80002C13.5313 7.80002 13.8 8.06865 13.8 8.40002V11.5938L16.4228 12.6429C16.7305 12.766 16.8801 13.1152 16.7571 13.4229C16.634 13.7305 16.2848 13.8802 15.9771 13.7571L12.9771 12.5571C12.7493 12.466 12.6 12.2454 12.6 12V8.40002C12.6 8.06865 12.8686 7.80002 13.2 7.80002Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.4 11.6548C20.4 15.4381 16.6448 18.5003 12 18.5003C11.4006 18.5003 10.8107 18.4535 10.2488 18.3505L7.00594 20.1059C6.41785 20.4242 6.02835 20.1523 6.13619 19.4972L6.56854 16.8709C4.75182 15.6254 3.59998 13.7525 3.59998 11.6548C3.59998 7.87156 7.36452 4.79999 12 4.79999C16.6448 4.79999 20.4 7.87156 20.4 11.6548Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 484 B

View file

@ -0,0 +1,3 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.0592 15.9579C9.96446 16.0526 9.85758 16.1134 9.72976 16.1711L7.56659 17.0795C7.48799 16.8115 7.29336 16.4709 6.93361 16.1111C6.58592 15.7635 6.25738 15.5809 5.99914 15.4903L6.89672 13.338C6.94337 13.1992 7.0271 13.0936 7.1208 12.9999C7.14262 13 7.16333 12.9793 7.17418 12.9684L7.20476 12.9378C7.35075 12.7919 7.55585 12.703 7.79617 12.6711C8.40705 12.5955 9.11521 12.8854 9.64935 13.4195C10.1835 13.9537 10.4734 14.6618 10.3857 15.2606C10.3505 15.4582 10.2854 15.6615 10.1833 15.8118C10.1529 15.8642 10.1114 15.9057 10.069 15.9481L10.0592 15.9579ZM13.6054 6.53721C13.9427 6.19986 14.4603 6.12759 14.9902 6.28654C15.2973 6.37865 15.4255 6.75936 15.2154 6.96946L10.177 12.0079C10.0566 12.1282 9.86135 12.148 9.70333 12.0472C9.54003 11.9451 9.37127 11.8572 9.20119 11.7838C8.92376 11.6642 8.82736 11.3152 9.02168 11.1209L13.6054 6.53721ZM16.5317 9.46348L11.9489 14.0462C11.7586 14.2366 11.4084 14.1547 11.2923 13.8847C11.2173 13.7052 11.1239 13.5309 11.0163 13.3622C10.9099 13.1966 10.9364 12.9902 11.0616 12.865L16.0882 7.83835C16.2964 7.63022 16.677 7.75634 16.7711 8.06359C16.9356 8.59891 16.871 9.12417 16.5317 9.46348ZM18.5347 4.5342C17.2459 3.24545 15.3869 3.01396 14.3838 4.01711L6.29247 12.1084C6.09421 12.3067 5.91898 12.528 5.81284 12.7986L4.10642 16.8961C3.89183 17.4134 4.05796 18.0589 4.53397 18.5349C5.00998 19.0109 5.65549 19.177 6.1728 18.9625L10.2703 17.256C10.5398 17.1488 10.7611 16.9736 10.9594 16.7753L19.0507 8.68402C20.0538 7.68087 19.8234 5.82294 18.5347 4.5342Z" fill="#4C97FF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13 6C13 7.65685 11.6569 9 10 9C8.34315 9 7 7.65685 7 6C7 4.34315 8.34315 3 10 3C11.6569 3 13 4.34315 13 6ZM3.66 9C4.76457 9 5.66 8.10457 5.66 7C5.66 5.89543 4.76457 5 3.66 5C2.55543 5 1.66 5.89543 1.66 7C1.66 8.10457 2.55543 9 3.66 9ZM5.27807 14.6538C4.75697 14.8738 4.14912 15 3.5 15C1.567 15 0 13.8807 0 12.5C0 11.1193 1.567 10 3.5 10C4.69639 10 5.75257 10.4288 6.3838 11.0829C7.29425 10.4157 8.57778 10 10 10C11.4222 10 12.7058 10.4157 13.6162 11.0829C14.2474 10.4288 15.3036 10 16.5 10C18.433 10 20 11.1193 20 12.5C20 13.8807 18.433 15 16.5 15C15.8509 15 15.243 14.8738 14.7219 14.6538C14.0407 16.0199 12.1839 17 10 17C7.81612 17 5.95925 16.0199 5.27807 14.6538ZM16.66 9C17.7646 9 18.66 8.10457 18.66 7C18.66 5.89543 17.7646 5 16.66 5C15.5554 5 14.66 5.89543 14.66 7C14.66 8.10457 15.5554 9 16.66 9Z" fill="#575E75"/>
</svg>

After

Width:  |  Height:  |  Size: 975 B

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 2C5 1.44772 5.44772 1 6 1C6.55228 1 7 1.44772 7 2V3C7 3.55228 6.55228 4 6 4C5.44772 4 5 3.55228 5 3V2ZM12 2H8V3C8 4.10457 7.10457 5 6 5C4.89543 5 4 4.10457 4 3V2C2.34315 2 1 3.34315 1 5V16C1 17.6569 2.34315 19 4 19H16C17.6569 19 19 17.6569 19 16V5C19 3.34315 17.6569 2 16 2V3C16 4.10457 15.1046 5 14 5C12.8954 5 12 4.10457 12 3V2ZM3 15.6V8H17V15.6C17 16.4284 16.3284 17.1 15.5 17.1H4.5C3.67157 17.1 3 16.4284 3 15.6ZM14 1C13.4477 1 13 1.44772 13 2V3C13 3.55228 13.4477 4 14 4C14.5523 4 15 3.55228 15 3V2C15 1.44772 14.5523 1 14 1Z" fill="#575E75"/>
</svg>

After

Width:  |  Height:  |  Size: 704 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.8 10.812C20.1312 10.812 20.4 10.5432 20.4 10.212V6.6C20.4 6.2688 20.1312 6 19.8 6H11.0484C10.8888 6 10.7364 6.0636 10.6248 6.1752L9.77518 7.0248C9.66358 7.1364 9.51118 7.2 9.35158 7.2H7.44838C7.28878 7.2 7.13638 7.1364 7.02478 7.0248L6.17518 6.1752C6.06358 6.0636 5.91118 6 5.75158 6H4.19998C3.86878 6 3.59998 6.2688 3.59998 6.6V10.212C3.59998 10.5432 3.86878 10.812 4.19998 10.812H5.76358C5.92318 10.812 6.07438 10.8744 6.18718 10.9872L7.02478 11.8248C7.13638 11.9364 7.28878 12 7.44838 12H9.35158C9.51118 12 9.66358 11.9364 9.77518 11.8248L10.6128 10.9872C10.7244 10.8744 10.8768 10.812 11.0364 10.812H19.8ZM17.4 16.812C17.7312 16.812 18 16.5432 18 16.212V12.6C18 12.2688 17.7312 12 17.4 12H11.0484C10.8888 12 10.7364 12.0636 10.6248 12.1752L9.77518 13.0248C9.66358 13.1364 9.51118 13.2 9.35158 13.2H7.44838C7.28878 13.2 7.13638 13.1364 7.02478 13.0248L6.17518 12.1752C6.06358 12.0636 5.91118 12 5.75158 12H4.19998C3.86878 12 3.59998 12.2688 3.59998 12.6V16.212C3.59998 16.5432 3.86878 16.812 4.19998 16.812H5.76358C5.92318 16.812 6.07438 16.8744 6.18718 16.9872L7.02478 17.8248C7.13638 17.9364 7.28878 18 7.44838 18H9.35158C9.51118 18 9.66358 17.9364 9.77518 17.8248L10.6128 16.9872C10.7244 16.8744 10.8768 16.812 11.0364 16.812H17.4Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,9 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4057 11.5091C9.50472 11.5091 8.77527 12.2386 8.77527 13.1396C8.77527 14.0405 9.50472 14.77 10.4057 14.77C11.3067 14.77 12.0362 14.0405 12.0362 13.1396C12.0362 12.2386 11.3067 11.5091 10.4057 11.5091ZM11.3417 10.0236C10.927 10.9523 9.88444 10.9523 9.46975 10.0236L8.14574 7.07519C7.73106 6.15289 8.25066 5 9.08171 5H11.7297C12.5608 5 13.0804 6.15289 12.6657 7.07519L11.3417 10.0236Z" fill="white"/>
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="8" y="5" width="5" height="10">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4057 11.5091C9.50472 11.5091 8.77527 12.2386 8.77527 13.1396C8.77527 14.0405 9.50472 14.77 10.4057 14.77C11.3067 14.77 12.0362 14.0405 12.0362 13.1396C12.0362 12.2386 11.3067 11.5091 10.4057 11.5091ZM11.3417 10.0236C10.927 10.9523 9.88444 10.9523 9.46975 10.0236L8.14574 7.07519C7.73106 6.15289 8.25066 5 9.08171 5H11.7297C12.5608 5 13.0804 6.15289 12.6657 7.07519L11.3417 10.0236Z" fill="white"/>
</mask>
<g mask="url(#mask0)">
<rect width="20" height="20" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,7 +1,9 @@
{
"studio.tabNavProjects": "Projects",
"studio.tabNavProjectsWithCount": "Projects {projectCount}",
"studio.tabNavCurators": "Curators",
"studio.tabNavComments": "Comments",
"studio.tabNavCommentsWithCount": "Comments {commentCount}",
"studio.tabNavActivity": "Activity",
"studio.title": "Title",
@ -26,7 +28,11 @@
"studio.projectsEmpty1": "This studio has no projects yet.",
"studio.projectsEmpty2": "Suggest projects you want to add in the comments!",
"studio.browseProjects": "Browse Projects",
"studio.projectErrors.checkUrl": "Could not add project. Check the URL and try again.",
"studio.projectErrors.checkUrl": "Could not find that project. Check the URL and try again.",
"studio.projectErrors.generic": "Could not add project.",
"studio.projectErrors.tooFast": "You are adding projects too quickly.",
"studio.projectErrors.permission": "You do not have permission to add that project.",
"studio.projectErrors.duplicate": "That project is already in this studio.",
"studio.creatorRole": "Studio Creator",
@ -35,11 +41,16 @@
"studio.unfollowStudio": "Unfollow Studio",
"studio.followStudio": "Follow Studio",
"studio.editThumbnail": "Edit Thumbnail",
"studio.curatorsHeader": "Curators",
"studio.inviteCuratorsHeader": "Invite Curators",
"studio.inviteCurator": "Invite",
"studio.inviteCuratorPlaceholder": "Scratch Username",
"studio.curatorInvitationAccepted": "Congratulations! You are now a curator of this studio.",
"studio.curatorInvitation": "Youve been invited to become a curator of this studio.",
"studio.curatorAcceptInvite": "Accept Invite",
"studio.curatorInvitationError": "Something went wrong, try again later.",
"studio.curatorsEmptyCanAdd1": "You dont have curators right now.",
"studio.curatorsEmptyCanAdd2": "Add some curators to collaborate with!",
"studio.curatorsEmpty1": "This studio has no curators right now.",
@ -47,11 +58,19 @@
"studio.curatorErrors.alreadyCurator": "They are already part of the studio.",
"studio.curatorErrors.unknownUsername": "Could not invite a curator with that username.",
"studio.curatorErrors.tooFast": "You are adding curators too fast.",
"studio.curatorDoYouWantToPromote": "Do you want to promote this person to a manager?",
"studio.curatorManagersCan": "Managers can...",
"studio.curatorAddAndDeleteCurators": "add and delete curators",
"studio.curatorDeleteManagers": "delete other managers",
"studio.curatorAddAndDeleteProjects": "add and delete projects",
"studio.curatorIfYouTrust": "If you trust this person and youre sure you want to give them extra permissions, click Promote.",
"studio.remove": "Remove",
"studio.promote": "Promote",
"studio.cancel": "Cancel",
"studio.commentsHeader": "Comments",
"studio.commentsNotAllowed": "Commenting for this studio has been turned off.",
"studio.comments.toggleOff": "Commenting off",
"studio.comments.toggleOn": "Commenting on",
"studio.comments.turnedOff": "Sorry, comment posting has been turned off for this studio.",
@ -69,9 +88,27 @@
"studio.activityRemoveCurator": "{removerProfileLink} removed the curator {removedProfileLink}",
"studio.activityBecomeOwner": "{promotedProfileLink} was promoted to manager by {promotorProfileLink}",
"studio.lastUpdated": "Updated {lastUpdatedDate, date, medium}",
"studio.followerCount": "{followerCount} followers",
"studio.reportThisStudio": "Report this studio",
"studio.reportPleaseExplain": "Please explain why you feel this studio is disrespectful or inappropriate, or otherwise breaks the Scratch Community Guidelines.",
"studio.reportPleaseExplain": "Please select which part of the studio you find to be disrespectful or inappropriate, or otherwise breaks the Scratch Community Guidelines.",
"studio.reportAreThereComments": "Are there inappropriate comments in the studio? Please report them by clicking the \"report\" button on the individual comments.",
"studio.reportThanksForLettingUsKnow": "Thanks for letting us know!",
"studio.reportYourFeedback": "Your feedback will help us make Scratch better."
"studio.reportYourFeedback": "Your feedback will help us make Scratch better.",
"studios.mutedCurators": "You will be able to invite curators and add managers again {inDuration}.",
"studios.mutedProjects": "You will be able to add and remove projects again {inDuration}.",
"studios.mutedEdit": "You will be able to edit studios again {inDuration}.",
"studios.mutedPaused": "Your account has been paused from using studios until then.",
"studio.alertProjectAdded": "\"{title}\" added to studio",
"studio.alertProjectAlreadyAdded": "That project is already in this studio",
"studio.alertProjectRemoveError": "Something went wrong removing the project",
"studio.alertProjectAddError": "Something went wrong adding the project",
"studio.alertCuratorAlreadyInvited": "\"{name}\" has already been invited",
"studio.alertCuratorInvited": "Curator invite sent to \"{name}\"",
"studio.alertManagerPromote": "\"{name}\" is now a manager",
"studio.alertManagerPromoteError": "Something went wrong promoting \"{name}\"",
"studio.alertMemberRemoveError": "Something went wrong removing \"{name}\""
}

View file

@ -121,8 +121,6 @@ const inviteCurator = username => ((dispatch, getState) => new Promise((resolve,
}, (err, body, res) => {
const error = normalizeError(err, body, res);
if (error) return reject(error);
// eslint-disable-next-line no-alert
alert(`successfully invited ${username}`);
return resolve(username);
});
}));
@ -169,7 +167,9 @@ const acceptInvitation = () => ((dispatch, getState) => new Promise((resolve, re
// Note: this assumes that the user items from the curator endpoint
// are the same structure as the single user data returned from /users/:username
dispatch(curators.actions.create(userBody, true));
dispatch(setRoles({invited: false, curator: true}));
setTimeout(() => {
dispatch(setRoles({invited: false, curator: true}));
}, 5 * 1000);
return resolve();
});
});

View file

@ -44,7 +44,7 @@ const normalizeError = (err, body, res) => {
const loadUserProjects = type => ((dispatch, getState) => {
const state = getState();
const projectCount = userProjects.selector(state).items.length;
const projectsPerPage = 20;
const projectsPerPage = 24;
const opts = {
...Endpoints[type](state),
params: {

View file

@ -0,0 +1,73 @@
import React from 'react';
import PropTypes from 'prop-types';
import {FormattedMessage} from 'react-intl';
import Modal from '../../../components/modal/base/modal.jsx';
import ModalTitle from '../../../components/modal/base/modal-title.jsx';
import ModalInnerContent from '../../../components/modal/base/modal-inner-content.jsx';
import './promote-modal.scss';
const PromoteModal = ({
handleClose,
handlePromote,
username
}) => (
<Modal
isOpen
className="promote-modal"
onRequestClose={handleClose}
>
<ModalTitle
className="promote-title"
/>
<div
className="promote-content"
>
<img
src="/svgs/studio/promote-illustration.svg"
className="promote-image"
/>
<ModalInnerContent
className="promote-inner"
>
<h2>
<FormattedMessage id="studio.curatorDoYouWantToPromote" />
<br />
{username}
</h2>
<span><FormattedMessage id="studio.curatorManagersCan" /></span>
<ul>
<li><FormattedMessage id="studio.curatorAddAndDeleteCurators" /></li>
<li><FormattedMessage id="studio.curatorDeleteManagers" /></li>
<li><FormattedMessage id="studio.curatorAddAndDeleteProjects" /></li>
</ul>
<span><FormattedMessage id="studio.curatorIfYouTrust" /></span>
<div
className="promote-button-row"
>
<button
className="button cancel-button"
onClick={handleClose}
>
<FormattedMessage id="studio.cancel" />
</button>
<button
className="button"
onClick={handlePromote}
>
<FormattedMessage id="studio.promote" />
</button>
</div>
</ModalInnerContent>
</div>
</Modal>
);
PromoteModal.propTypes = {
handleClose: PropTypes.func,
handlePromote: PropTypes.func,
username: PropTypes.string
};
export default PromoteModal;

View file

@ -0,0 +1,50 @@
@import "../../../colors";
.promote-modal {
.promote-title {
background: $ui-blue;
border-top-left-radius: 12px;
border-top-right-radius: 12px;
padding-top: .75rem;
width: 100%;
height: 3rem;
cursor: pointer;
}
h2 {
line-height: 2.5rem;
margin-bottom: 1rem;
}
ul {
line-height: 1rem;
}
.promote-content {
display: flex;
align-items: flex-start;
}
.promote-image {
margin-top: 2rem;
}
.promote-inner {
padding: 2rem;
}
.promote-button-row {
display: flex;
justify-content: flex-end;
}
.button {
margin-left: 0.5rem;
}
.cancel-button {
background-color: $ui-white;
color: $ui-blue;
box-shadow: 0px 0px 0 1px $ui-blue;
}
}

View file

@ -46,16 +46,25 @@ const StudioReportModal = ({
isOpen
className="studio-report-modal"
onRequestClose={handleClose}
useStandardSizes
>
<ModalTitle
className="studio-report-title"
/>
<ModalInnerContent
className="studio-report-inner"
<div
className="studio-report-thanks-content"
>
<h2><FormattedMessage id="studio.reportThanksForLettingUsKnow" /></h2>
<p><FormattedMessage id="studio.reportYourFeedback" /></p>
</ModalInnerContent>
<img
src="/svgs/studio/report-thanks.svg"
className="studio-report-thanks-image"
/>
<ModalInnerContent
className="studio-report-inner"
>
<h2><FormattedMessage id="studio.reportThanksForLettingUsKnow" /></h2>
<p><FormattedMessage id="studio.reportYourFeedback" /></p>
</ModalInnerContent>
</div>
</Modal>
) : (
<Modal

View file

@ -1,8 +1,7 @@
@import "../../../colors";
@import "../../../frameless";
.studio-report-modal {
width: 600px;
.studio-report-title {
box-shadow: inset 0 -1px 0 0 $ui-aqua-dark;
background: $ui-aqua;
@ -11,17 +10,29 @@
padding-top: .75rem;
width: 100%;
height: 3rem;
cursor: pointer;
}
.studio-report-inner {
padding: 2.5rem;
padding: 2rem;
}
.studio-report-tile-container {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
row-gap: 1rem;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
@media #{$medium} { /* Keep 3 columns to narrower width since it is in a modal */
& { grid-template-columns: 1fr 1fr; }
}
@media #{$small} {
& { grid-template-columns: 1fr; }
}
column-gap: 14px;
row-gap: 14px;
}
.button {
margin: 0px;
}
.button:disabled {
@ -30,7 +41,7 @@
.studio-report-tile {
background: rgba(0, 0, 0, 0.05);
width: 250px;
width: 230px;
height: 156px;
border-radius: 0.5rem 0.5rem 0 0;
}
@ -81,4 +92,13 @@
display: flex;
justify-content: flex-end;
}
.studio-report-thanks-content {
display: flex;
}
.studio-report-thanks-image {
margin-top: 2rem;
margin-bottom: 2rem
}
}

View file

@ -18,6 +18,8 @@ import UserProjectsTile from './user-projects-tile.jsx';
import './user-projects-modal.scss';
import {selectIsEducator} from '../../../redux/session';
import AlertProvider from '../../../components/alert/alert-provider.jsx';
import Alert from '../../../components/alert/alert.jsx';
const UserProjectsModal = ({
items, error, loading, moreToLoad, showStudentsFilter,
@ -44,60 +46,63 @@ const UserProjectsModal = ({
align="left"
className="user-projects-modal-nav"
>
<li
<button
className={classNames({active: filter === Filters.SHARED})}
onClick={() => setFilter(Filters.SHARED)}
>
<FormattedMessage id="studio.sharedFilter" />
</li>
<li
</button>
<button
className={classNames({active: filter === Filters.FAVORITED})}
onClick={() => setFilter(Filters.FAVORITED)}
>
<FormattedMessage id="studio.favoritedFilter" />
</li>
<li
</button>
<button
className={classNames({active: filter === Filters.RECENT})}
onClick={() => setFilter(Filters.RECENT)}
>
<FormattedMessage id="studio.recentFilter" />
</li>
</button>
{showStudentsFilter &&
<li
<button
className={classNames({active: filter === Filters.STUDENTS})}
onClick={() => setFilter(Filters.STUDENTS)}
>
<FormattedMessage id="studio.studentsFilter" />
</li>
</button>
}
</SubNavigation>
<ModalInnerContent className="user-projects-modal-content">
{error && <div>Error loading {filter}: {error}</div>}
<div className="user-projects-modal-grid">
{items.map(project => (
<UserProjectsTile
key={project.id}
id={project.id}
title={project.title}
image={project.image}
inStudio={project.inStudio}
onAdd={onAdd}
onRemove={onRemove}
/>
))}
{moreToLoad &&
<div className="studio-projects-load-more">
<button
className={classNames('button', {
'mod-mutating': loading
})}
onClick={() => onLoadMore(filter)}
>
<FormattedMessage id="general.loadMore" />
</button>
<AlertProvider>
{error && <div>Error loading {filter}: {error}</div>}
<Alert className="studio-alert" />
<div className="user-projects-modal-grid">
{items.map(project => (
<UserProjectsTile
key={project.id}
id={project.id}
title={project.title}
image={project.image}
inStudio={project.inStudio}
onAdd={onAdd}
onRemove={onRemove}
/>
))}
</div>
{moreToLoad &&
<div className="studio-projects-load-more">
<button
className={classNames('button', {
'mod-mutating': loading
})}
onClick={() => onLoadMore(filter)}
>
<FormattedMessage id="general.loadMore" />
</button>
</div>
}
</div>
</AlertProvider>
</ModalInnerContent>
</Modal>
);

View file

@ -13,7 +13,7 @@
}
.user-projects-modal-nav {
padding: 6px 12px;
li {
button {
cursor: pointer;
background: rgba(0, 0, 0, 0.15);
&.active { background: $ui-blue; }
@ -31,8 +31,20 @@
& { max-height: calc(100vh - 105px); }
}
}
.studio-projects-load-more {
display: contents;
}
}
.studio-tile-added {
border: 1px solid $ui-blue !important; // Override the tile border set in studio.scss .studio-project-tile
box-shadow: 0 0 0 4px $ui-blue-25percent;
}
.studio-project-add-remove-image {
margin: 7px;
}
.studio-tile-dynamic-remove,
.studio-tile-dynamic-add {
@ -50,7 +62,9 @@
margin: 0;
padding: 0;
line-height: 32px;
text-align: center;
align-content: center;
display: flex;
justify-content: center;
}
.studio-tile-dynamic-remove {
background: #0FBD8C;
@ -60,6 +74,7 @@
.user-projects-modal-grid {
margin-top: 12px;
margin-bottom: 8px;
display: grid;
grid-template-columns: repeat(3, minmax(0,1fr));
@ -72,10 +87,6 @@
column-gap: 14px;
row-gap: 14px;
.studio-projects-load-more {
grid-column: 1 / -1;
}
.studio-project-bottom {
padding: 8px 10px 8px 10px;
}

View file

@ -1,23 +1,26 @@
/* eslint-disable react/jsx-no-bind */
import React, {useState} from 'react';
import React, {useContext, useState} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import AlertContext from '../../../components/alert/alert-context.js';
const UserProjectsTile = ({id, title, image, inStudio, onAdd, onRemove}) => {
const [submitting, setSubmitting] = useState(false);
const [added, setAdded] = useState(inStudio);
const [error, setError] = useState(null);
const {errorAlert} = useContext(AlertContext);
const toggle = () => {
setSubmitting(true);
setError(null);
(added ? onRemove(id) : onAdd(id))
.then(() => {
setAdded(!added);
setSubmitting(false);
})
.catch(e => {
setError(e);
.catch(() => {
setSubmitting(false);
errorAlert({
id: added ? 'studio.alertProjectRemoveError' :
'studio.alertProjectAddError'
}, null);
});
};
return (
@ -25,6 +28,7 @@ const UserProjectsTile = ({id, title, image, inStudio, onAdd, onRemove}) => {
role="button"
tabIndex="0"
className={classNames('studio-project-tile', {
'studio-tile-added': added,
'mod-clickable': true,
'mod-mutating': submitting
})}
@ -43,9 +47,14 @@ const UserProjectsTile = ({id, title, image, inStudio, onAdd, onRemove}) => {
<div className="studio-project-bottom">
<div className="studio-project-title">{title}</div>
<div className={`studio-tile-dynamic-${added ? 'remove' : 'add'}`}>
{added ? '✔' : ''}
<img
className="studio-project-add-remove-image"
src={added ?
'/svgs/studio/check-icon-white.svg' :
'/svgs/studio/plus-icon-white.svg'
}
/>
</div>
{error && <div>{error}</div>}
</div>
</div>
);

View file

@ -0,0 +1,95 @@
/* eslint-disable react/jsx-no-bind */
import React, {useState, useEffect} from 'react';
import {connect} from 'react-redux';
import classNames from 'classnames';
import {selectIsAdmin} from '../../redux/session.js';
import {selectStudioId} from '../../redux/studio.js';
import log from '../../lib/log';
import AdminPanel from '../../components/adminpanel/adminpanel.jsx';
const adminPanelOpenKey = 'adminPanelToggled_studios';
const adminPanelOpenClass = 'mod-view-admin-panel-open';
/**
* Propagate the admin panel openness to localStorage and set a class name
* on the #view element.
* @param {boolean} value - whether the admin panel is now open.
*/
const storeAdminPanelOpen = value => {
try {
localStorage.setItem(adminPanelOpenKey, value ? 'open' : 'closed');
} catch (e) {
log.error('Could not set adminPanelToggled_studios in local storage', e);
}
try {
document.querySelector('#view').classList
.toggle(adminPanelOpenClass, value);
} catch (e) {
log.error('Could not set admin-panel-open class on #view');
}
};
/**
* Get the previous stored value of admin panel openness from localStorage.
* @returns {boolean} - whether the admin panel should be open.
*/
const getAdminPanelOpen = () => {
try {
return localStorage.getItem(adminPanelOpenKey) === 'open';
} catch (_) {
return false;
}
};
const StudioAdminPanel = ({studioId, showAdminPanel}) => {
const [adminPanelOpen, setAdminPanelOpen] = useState(getAdminPanelOpen());
useEffect(() => {
storeAdminPanelOpen(adminPanelOpen);
}, [adminPanelOpen]);
useEffect(() => {
if (!showAdminPanel) return;
const handleMessage = e => {
if (e.data === 'closePanel') setAdminPanelOpen(false);
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [showAdminPanel]);
return showAdminPanel && (
<AdminPanel
className={classNames('studio-admin-panel', {
'admin-panel-open': adminPanelOpen
})}
isOpen={adminPanelOpen}
onOpen={() => setAdminPanelOpen(true)}
>
<iframe
className="admin-iframe"
src={`/scratch2-studios/${studioId}/adminpanel/`}
/>
</AdminPanel>
);
};
const ConnnectedStudioAdminPanel = connect(
state => ({
studioId: selectStudioId(state),
showAdminPanel: selectIsAdmin(state)
})
)(StudioAdminPanel);
export {
ConnnectedStudioAdminPanel as default,
// Export the unconnected component by name for testing
StudioAdminPanel,
// Export some constants for easy testing as well
adminPanelOpenClass,
adminPanelOpenKey
};

View file

@ -0,0 +1,10 @@
const React = require('react');
const FormattedMessage = require('react-intl').FormattedMessage;
const StudioCommentsNotAllowed = () => (
<div className="studio-comments-not-allowed">
<FormattedMessage id="studio.commentsNotAllowed" />
</div>
);
module.exports = StudioCommentsNotAllowed;

View file

@ -1,4 +1,4 @@
import React, {useEffect, useRef} from 'react';
import React, {useEffect, useRef, useState} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {FormattedMessage} from 'react-intl';
@ -8,8 +8,9 @@ import ComposeComment from '../preview/comment/compose-comment.jsx';
import TopLevelComment from '../preview/comment/top-level-comment.jsx';
import studioCommentActions from '../../redux/studio-comment-actions.js';
import StudioCommentsAllowed from './studio-comments-allowed.jsx';
import StudioCommentsNotAllowed from './studio-comments-not-allowed.jsx';
import {selectIsAdmin} from '../../redux/session';
import {selectIsAdmin, selectHasFetchedSession} from '../../redux/session';
import {
selectShowCommentComposer,
selectCanDeleteComment,
@ -24,6 +25,7 @@ const StudioComments = ({
comments,
commentsAllowed,
isAdmin,
hasFetchedSession,
handleLoadMoreComments,
handleNewComment,
moreCommentsToLoad,
@ -42,8 +44,8 @@ const StudioComments = ({
handleLoadMoreReplies
}) => {
useEffect(() => {
if (comments.length === 0) handleLoadMoreComments();
}, [comments.length === 0]);
if (comments.length === 0 && hasFetchedSession) handleLoadMoreComments();
}, [comments.length === 0, hasFetchedSession]);
// The comments you see depend on your admin status
// so reset them if isAdmin changes.
@ -54,21 +56,37 @@ const StudioComments = ({
if (isAdmin !== wasAdmin) handleResetComments();
}, [isAdmin]);
const [replyStatusCommentId, setReplyStatusCommentId] = useState('');
const hasReplyStatus = function (comment) {
return (
comment.parent_id && comment.parent_id === replyStatusCommentId
) || (comment.id === replyStatusCommentId);
};
const handleReplyStatusChange = function (id) {
setReplyStatusCommentId(id);
};
return (
<div>
<div className="studio-compose-container">
<div className="studio-header-container">
<h2><FormattedMessage id="studio.commentsHeader" /></h2>
{canEditCommentsAllowed && <StudioCommentsAllowed />}
</div>
<div className="studio-compose-container">
{shouldShowCommentComposer && commentsAllowed &&
<ComposeComment
postURI={postURI}
onAddComment={handleNewComment}
/>
<div>
{shouldShowCommentComposer ?
(commentsAllowed ?
<ComposeComment
postURI={postURI}
onAddComment={handleNewComment}
/> :
<StudioCommentsNotAllowed />
) : null
}
{comments.map(comment => (
<TopLevelComment
hasThreadLimit
author={comment.author}
canDelete={canDeleteComment}
canDeleteWithoutConfirm={canDeleteCommentWithoutConfirm}
@ -83,10 +101,14 @@ const StudioComments = ({
parentId={comment.parent_id}
postURI={postURI}
replies={replies && replies[comment.id] ? replies[comment.id] : []}
threadHasReplyStatus={hasReplyStatus(comment)}
totalReplyCount={comment.reply_count}
visibility={comment.visibility}
onAddComment={handleNewComment}
onDelete={handleDeleteComment}
onRestore={handleRestoreComment}
// eslint-disable-next-line react/jsx-no-bind
onReply={handleReplyStatusChange}
onReport={handleReportComment}
onLoadMoreReplies={handleLoadMoreReplies}
/>
@ -108,6 +130,7 @@ StudioComments.propTypes = {
comments: PropTypes.arrayOf(PropTypes.shape({})),
commentsAllowed: PropTypes.bool,
isAdmin: PropTypes.bool,
hasFetchedSession: PropTypes.bool,
handleLoadMoreComments: PropTypes.func,
handleNewComment: PropTypes.func,
moreCommentsToLoad: PropTypes.bool,
@ -133,6 +156,7 @@ export {
export default connect(
state => ({
comments: state.comments.comments,
hasFetchedSession: selectHasFetchedSession(state),
isAdmin: selectIsAdmin(state),
moreCommentsToLoad: state.comments.moreCommentsToLoad,
replies: state.comments.replies,

View file

@ -6,15 +6,42 @@ import classNames from 'classnames';
import {FormattedMessage} from 'react-intl';
import {acceptInvitation} from './lib/studio-member-actions';
import {selectShowCuratorInvite} from '../../redux/studio-permissions';
const StudioCuratorInvite = ({onSubmit}) => {
const StudioCuratorInvite = ({showCuratorInvite, onSubmit}) => {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const [accepted, setAccepted] = useState(false);
const [error, setError] = useState(false);
if (!showCuratorInvite) return null;
if (error) {
return (
<div className="studio-invitation studio-info-box studio-info-box-error">
<div className="studio-invitation-msg">
<FormattedMessage id="studio.curatorInvitationError" />
</div>
</div>
);
}
if (accepted) {
return (
<div className="studio-invitation studio-info-box studio-info-box-success">
<div className="studio-invitation-msg">
<FormattedMessage id="studio.curatorInvitationAccepted" />
</div>
</div>
);
}
return (
<div>
<div className="studio-invitation studio-info-box">
<div className="studio-invitation-msg">
<FormattedMessage id="studio.curatorInvitation" />
</div>
<button
className={classNames('button', {
className={classNames('studio-invitation-button button', {
'mod-mutating': submitting
})}
disabled={submitting}
@ -22,22 +49,28 @@ const StudioCuratorInvite = ({onSubmit}) => {
setSubmitting(true);
setError(null);
onSubmit()
.then(() => {
setSubmitting(false);
setAccepted(true);
})
.catch(e => {
setError(e);
setSubmitting(false);
});
}}
><FormattedMessage id="studio.curatorAcceptInvite" /></button>
{error && <div>{error}</div>}
</div>
);
};
StudioCuratorInvite.propTypes = {
showCuratorInvite: PropTypes.func,
onSubmit: PropTypes.func
};
const mapStateToProps = () => ({});
const mapStateToProps = state => ({
showCuratorInvite: selectShowCuratorInvite(state)
});
const mapDispatchToProps = ({
onSubmit: acceptInvitation

View file

@ -5,6 +5,7 @@ import {connect} from 'react-redux';
import classNames from 'classnames';
import {FormattedMessage, intlShape, injectIntl} from 'react-intl';
import {useAlertContext} from '../../components/alert/alert-context';
import {Errors, inviteCurator} from './lib/studio-member-actions';
import ValidationMessage from '../../components/forms/validation-message.jsx';
@ -24,12 +25,30 @@ const StudioCuratorInviter = ({intl, onSubmit}) => {
const [value, setValue] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const {successAlert} = useAlertContext();
const submit = () => {
setSubmitting(true);
setError(null);
onSubmit(value)
.then(() => setValue(''))
.catch(e => setError(e))
.then(() => {
successAlert({
id: 'studio.alertCuratorInvited',
values: {name: value}
});
setValue('');
})
.catch(e => {
if (e === Errors.DUPLICATE) {
successAlert({
id: 'studio.alertCuratorAlreadyInvited',
values: {name: value}
});
setValue('');
} else {
setError(e);
}
})
.then(() => setSubmitting(false));
};
return (

View file

@ -8,72 +8,76 @@ import {curators} from './lib/redux-modules';
import Debug from './debug.jsx';
import {CuratorTile} from './studio-member-tile.jsx';
import CuratorInviter from './studio-curator-inviter.jsx';
import CuratorInvite from './studio-curator-invite.jsx';
import {loadCurators} from './lib/studio-member-actions';
import {selectCanInviteCurators, selectShowCuratorInvite} from '../../redux/studio-permissions';
import {selectCanInviteCurators} from '../../redux/studio-permissions';
import AlertProvider from '../../components/alert/alert-provider.jsx';
import Alert from '../../components/alert/alert.jsx';
const StudioCurators = ({
canInviteCurators, showCuratorInvite, items, error, loading, moreToLoad, onLoadMore
canInviteCurators, items, error, loading, moreToLoad, onLoadMore
}) => {
useEffect(() => {
if (items.length === 0) onLoadMore();
}, []);
return (<div className="studio-members">
<div className="studio-header-container">
<h2><FormattedMessage id="studio.curatorsHeader" /></h2>
</div>
{canInviteCurators && <CuratorInviter />}
{showCuratorInvite && <CuratorInvite />}
{error && <Debug
label="Error"
data={error}
/>}
<div className="studio-members-grid">
{items.length === 0 && !loading ? (
<div className="studio-empty">
<img
width="179"
height="111"
className="studio-empty-img"
src="/images/studios/curators-empty.png"
/>
{canInviteCurators ? (
<div className="studio-empty-msg">
<div><FormattedMessage id="studio.curatorsEmptyCanAdd1" /></div>
<div><FormattedMessage id="studio.curatorsEmptyCanAdd2" /></div>
return (
<AlertProvider>
<div className="studio-members">
<Alert className="studio-alert" />
<div className="studio-header-container">
<h2><FormattedMessage id="studio.curatorsHeader" /></h2>
</div>
{canInviteCurators && <CuratorInviter />}
{error && <Debug
label="Error"
data={error}
/>}
<div className="studio-members-grid">
{items.length === 0 && !loading ? (
<div className="studio-empty">
<img
width="179"
height="111"
className="studio-empty-img"
src="/images/studios/curators-empty.png"
/>
{canInviteCurators ? (
<div className="studio-empty-msg">
<div><FormattedMessage id="studio.curatorsEmptyCanAdd1" /></div>
<div><FormattedMessage id="studio.curatorsEmptyCanAdd2" /></div>
</div>
) : (
<div className="studio-empty-msg">
<div><FormattedMessage id="studio.curatorsEmpty1" /></div>
</div>
)}
</div>
) : (
<div className="studio-empty-msg">
<div><FormattedMessage id="studio.curatorsEmpty1" /></div>
</div>
<React.Fragment>
{items.map(item =>
(<CuratorTile
key={item.username}
username={item.username}
image={item.profile.images['90x90']}
/>)
)}
{moreToLoad &&
<div className="studio-members-load-more">
<button
className={classNames('button', {
'mod-mutating': loading
})}
onClick={onLoadMore}
>
<FormattedMessage id="general.loadMore" />
</button>
</div>
}
</React.Fragment>
)}
</div>
) : (
<React.Fragment>
{items.map(item =>
(<CuratorTile
key={item.username}
username={item.username}
image={item.profile.images['90x90']}
/>)
)}
{moreToLoad &&
<div className="studio-members-load-more">
<button
className={classNames('button', {
'mod-mutating': loading
})}
onClick={onLoadMore}
>
<FormattedMessage id="general.loadMore" />
</button>
</div>
}
</React.Fragment>
)}
</div>
</div>);
</div>
</AlertProvider>);
};
StudioCurators.propTypes = {
@ -87,7 +91,6 @@ StudioCurators.propTypes = {
})
})),
canInviteCurators: PropTypes.bool,
showCuratorInvite: PropTypes.bool,
loading: PropTypes.bool,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
moreToLoad: PropTypes.bool,
@ -97,8 +100,7 @@ StudioCurators.propTypes = {
export default connect(
state => ({
...curators.selector(state),
canInviteCurators: selectCanInviteCurators(state),
showCuratorInvite: selectShowCuratorInvite(state)
canInviteCurators: selectCanInviteCurators(state)
}),
{
onLoadMore: loadCurators

View file

@ -1,17 +1,19 @@
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import classNames from 'classnames';
import {FormattedMessage} from 'react-intl';
import {selectStudioDescription, selectIsFetchingInfo} from '../../redux/studio';
import {selectCanEditInfo} from '../../redux/studio-permissions';
import {selectCanEditInfo, selectShowEditMuteError} from '../../redux/studio-permissions';
import {
Errors, mutateStudioDescription, selectIsMutatingDescription, selectDescriptionMutationError
} from '../../redux/studio-mutations';
import ValidationMessage from '../../components/forms/validation-message.jsx';
import decorateText from '../../lib/decorate-text.jsx';
import StudioMuteEditMessage from './studio-mute-edit-message.jsx';
const errorToMessageId = error => {
switch (error) {
@ -23,27 +25,48 @@ const errorToMessageId = error => {
};
const StudioDescription = ({
descriptionError, isFetching, isMutating, description, canEditInfo, handleUpdate
descriptionError, isFetching, isMutating, isMutedEditor, description, canEditInfo, handleUpdate
}) => {
const [showMuteMessage, setShowMuteMessage] = useState(false);
const fieldClassName = classNames('studio-description', {
'mod-fetching': isFetching,
'mod-mutating': isMutating,
'mod-form-error': !!descriptionError
'mod-form-error': !!descriptionError,
'muted-editor': showMuteMessage
});
return (
<div className="studio-info-section">
<textarea
rows="20"
className={fieldClassName}
disabled={isMutating || !canEditInfo || isFetching}
defaultValue={description}
onBlur={e => e.target.value !== description &&
<div
className="studio-info-section"
onMouseEnter={() => isMutedEditor && setShowMuteMessage(true)}
onMouseLeave={() => isMutedEditor && setShowMuteMessage(false)}
>
{canEditInfo || isMutedEditor ? (
<React.Fragment>
<textarea
rows="20"
className={fieldClassName}
disabled={isMutating || isFetching || isMutedEditor}
defaultValue={description}
onBlur={e => e.target.value !== description &&
handleUpdate(e.target.value)}
/>
{descriptionError && <ValidationMessage
mode="error"
message={<FormattedMessage id={errorToMessageId(descriptionError)} />}
/>}
/>
{descriptionError && <ValidationMessage
mode="error"
message={<FormattedMessage id={errorToMessageId(descriptionError)} />}
/>}
{showMuteMessage && <StudioMuteEditMessage />}
</React.Fragment>
) : (
<div className={fieldClassName}>
{decorateText(description, {
usernames: true,
hashtags: false,
scratchLinks: true
})}
</div>
)}
</div>
);
};
@ -53,6 +76,7 @@ StudioDescription.propTypes = {
canEditInfo: PropTypes.bool,
isFetching: PropTypes.bool,
isMutating: PropTypes.bool,
isMutedEditor: PropTypes.bool,
description: PropTypes.string,
handleUpdate: PropTypes.func
};
@ -63,6 +87,7 @@ export default connect(
canEditInfo: selectCanEditInfo(state),
isFetching: selectIsFetchingInfo(state),
isMutating: selectIsMutatingDescription(state),
isMutedEditor: selectShowEditMuteError(state),
descriptionError: selectDescriptionMutationError(state)
}),
{

View file

@ -1,17 +1,21 @@
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import classNames from 'classnames';
import {FormattedMessage} from 'react-intl';
import {selectStudioImage, selectIsFetchingInfo} from '../../redux/studio';
import {selectCanEditInfo} from '../../redux/studio-permissions';
import {selectCanEditInfo, selectShowEditMuteError} from '../../redux/studio-permissions';
import {
Errors, mutateStudioImage, selectIsMutatingImage, selectImageMutationError
} from '../../redux/studio-mutations';
import ValidationMessage from '../../components/forms/validation-message.jsx';
import StudioMuteEditMessage from './studio-mute-edit-message.jsx';
import editIcon from './icons/edit-icon.svg';
const errorToMessageId = error => {
switch (error) {
@ -23,25 +27,51 @@ const errorToMessageId = error => {
const blankImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
const StudioImage = ({
imageError, isFetching, isMutating, image, canEditInfo, handleUpdate
imageError, isFetching, isMutating, isMutedEditor, image, canEditInfo, handleUpdate
}) => {
const [uploadPreview, setUploadPreview] = React.useState(null);
const fieldClassName = classNames('studio-info-section', {
'mod-fetching': isFetching,
'mod-mutating': isMutating
'mod-mutating': isMutating,
'muted': isMutedEditor
});
let src = image || blankImage;
if (uploadPreview && !imageError) src = uploadPreview;
const labelFieldClassName = classNames({
'mod-mutating': isMutating,
'mod-clickable': !isMutating
});
const [showMuteMessage, setShowMuteMessage] = useState(false);
return (
<div className={fieldClassName}>
<div
className={fieldClassName}
onMouseEnter={() => isMutedEditor && setShowMuteMessage(true)}
onMouseLeave={() => isMutedEditor && setShowMuteMessage(false)}
>
<img
style={{width: '300px', height: '225px', objectFit: 'cover'}}
className="studio-image"
src={src}
/>
{canEditInfo && !isFetching &&
{(isMutedEditor || canEditInfo) && !isFetching &&
<React.Fragment>
<label
htmlFor="studio-thumb-edit-input"
className={labelFieldClassName}
>
<div className="studio-thumb-edit-button">
<img
className="studio-thumb-edit-img"
src={editIcon}
/>
<FormattedMessage id="studio.editThumbnail" />
</div>
</label>
<input
disabled={isMutating}
id="studio-thumb-edit-input"
className="hidden"
disabled={isMutating || !canEditInfo}
type="file"
accept="image/*"
onChange={e => {
@ -56,6 +86,7 @@ const StudioImage = ({
/>}
</React.Fragment>
}
{showMuteMessage && <StudioMuteEditMessage />}
</div>
);
};
@ -65,6 +96,7 @@ StudioImage.propTypes = {
canEditInfo: PropTypes.bool,
isFetching: PropTypes.bool,
isMutating: PropTypes.bool,
isMutedEditor: PropTypes.bool,
image: PropTypes.string,
handleUpdate: PropTypes.func
};
@ -75,6 +107,7 @@ export default connect(
canEditInfo: selectCanEditInfo(state),
isFetching: selectIsFetchingInfo(state),
isMutating: selectIsMutatingImage(state),
isMutedEditor: selectShowEditMuteError(state),
imageError: selectImageMutationError(state)
}),
{

View file

@ -1,14 +1,16 @@
import React, {useEffect} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import StudioDescription from './studio-description.jsx';
import StudioFollow from './studio-follow.jsx';
import StudioTitle from './studio-title.jsx';
import StudioImage from './studio-image.jsx';
import StudioReport from './studio-report.jsx';
import StudioStats from './studio-stats.jsx';
import StudioTitle from './studio-title.jsx';
import {selectIsLoggedIn} from '../../redux/session';
import {getInfo, getRoles} from '../../redux/studio';
import StudioReport from './studio-report.jsx';
const StudioInfo = ({
isLoggedIn, onLoadInfo, onLoadRoles
@ -27,7 +29,14 @@ const StudioInfo = ({
<StudioFollow />
<StudioImage />
<StudioDescription />
<StudioReport />
<div className="studio-info-footer">
<div className="studio-info-footer-stats">
<StudioStats />
</div>
<div className="studio-info-footer-report">
<StudioReport />
</div>
</div>
</React.Fragment>
);
};

View file

@ -8,6 +8,8 @@ import {managers} from './lib/redux-modules';
import {loadManagers} from './lib/studio-member-actions';
import Debug from './debug.jsx';
import {ManagerTile} from './studio-member-tile.jsx';
import AlertProvider from '../../components/alert/alert-provider.jsx';
import Alert from '../../components/alert/alert.jsx';
const StudioManagers = ({items, error, loading, moreToLoad, onLoadMore}) => {
@ -16,24 +18,26 @@ const StudioManagers = ({items, error, loading, moreToLoad, onLoadMore}) => {
}, []);
return (
<div className="studio-members">
<div className="studio-header-container">
<h2><FormattedMessage id="studio.managersHeader" /></h2>
</div>
{error && <Debug
label="Error"
data={error}
/>}
<div className="studio-members-grid">
{items.map(item =>
(<ManagerTile
key={item.username}
id={item.id}
username={item.username}
image={item.profile.images['90x90']}
/>)
)}
{moreToLoad &&
<AlertProvider>
<div className="studio-members">
<Alert className="studio-alert" />
<div className="studio-header-container">
<h2><FormattedMessage id="studio.managersHeader" /></h2>
</div>
{error && <Debug
label="Error"
data={error}
/>}
<div className="studio-members-grid">
{items.map(item =>
(<ManagerTile
key={item.username}
id={item.id}
username={item.username}
image={item.profile.images['90x90']}
/>)
)}
{moreToLoad &&
<div className="studio-members-load-more">
<button
className={classNames('button', {
@ -44,9 +48,10 @@ const StudioManagers = ({items, error, loading, moreToLoad, onLoadMore}) => {
<FormattedMessage id="general.loadMore" />
</button>
</div>
}
}
</div>
</div>
</div>
</AlertProvider>
);
};

View file

@ -5,6 +5,8 @@ import {connect} from 'react-redux';
import classNames from 'classnames';
import {FormattedMessage} from 'react-intl';
import PromoteModal from './modals/promote-modal.jsx';
import {
selectCanRemoveCurator, selectCanRemoveManager, selectCanPromoteCurators
} from '../../redux/studio-permissions';
@ -13,6 +15,7 @@ import {
removeCurator,
removeManager
} from './lib/studio-member-actions';
import {useAlertContext} from '../../components/alert/alert-context';
import OverflowMenu from '../../components/overflow-menu/overflow-menu.jsx';
import removeIcon from './icons/remove-icon.svg';
@ -23,7 +26,8 @@ const StudioMemberTile = ({
username, image // own props
}) => {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const {errorAlert, successAlert} = useAlertContext();
const userUrl = `/users/${username}`;
return (
<div className="studio-member-tile">
@ -44,17 +48,8 @@ const StudioMemberTile = ({
<OverflowMenu>
{canPromote && <li>
<button
className={classNames({
'mod-mutating': submitting
})}
disabled={submitting}
onClick={() => {
setSubmitting(true);
setError(null);
onPromote(username).catch(e => {
setError(e);
setSubmitting(false);
});
setModalOpen(true);
}}
>
<img src={promoteIcon} />
@ -69,9 +64,11 @@ const StudioMemberTile = ({
disabled={submitting}
onClick={() => {
setSubmitting(true);
setError(null);
onRemove(username).catch(e => {
setError(e);
onRemove(username).catch(() => {
errorAlert({
id: 'studio.alertMemberRemoveError',
values: {name: username}
}, null);
setSubmitting(false);
});
}}
@ -82,7 +79,27 @@ const StudioMemberTile = ({
</li>}
</OverflowMenu>
}
{error && <div>{error}</div>}
{modalOpen &&
<PromoteModal
handleClose={() => setModalOpen(false)}
handlePromote={() => {
onPromote(username)
.then(() => {
successAlert({
id: 'studio.alertManagerPromote',
values: {name: username}
});
})
.catch(() => {
errorAlert({
id: 'studio.alertManagerPromoteError',
values: {name: username}
});
});
}}
username={username}
/>
}
</div>
);
};

View file

@ -0,0 +1,43 @@
import React from 'react';
import Helmet from 'react-helmet';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {selectStudioDescription, selectStudioId, selectStudioTitle} from '../../redux/studio';
const StudioMeta = ({id, description, title}) => (
<Helmet>
<title>{`${title} - Scratch Studio`}</title>
<meta
content={`${title}, a studio on Scratch`}
name="description"
/>
<meta
content={`Scratch - ${title}`}
property="og:title"
/>
<meta
content={description.split(' ').slice(0, 50)
.join(' ')}
property="og:description"
/>
<link
href={`https://scratch.mit.edu/studios/${id}`}
rel="canonical"
/>
</Helmet>
);
StudioMeta.propTypes = {
description: PropTypes.string,
id: PropTypes.string,
title: PropTypes.string
};
export default connect(
state => ({
description: selectStudioDescription(state),
id: selectStudioId(state),
title: selectStudioTitle(state)
})
)(StudioMeta);

View file

@ -0,0 +1,34 @@
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {FormattedMessage} from 'react-intl';
import ValidationMessage from '../../components/forms/validation-message.jsx';
import {selectMuteStatus} from '../../redux/session';
import {formatRelativeTime} from '../../lib/format-time.js';
const StudioMuteEditMessage = ({
muteExpiresAtMs
}) => (
<ValidationMessage
mode="info"
message={<FormattedMessage
id="studios.mutedEdit"
values={{
inDuration: formatRelativeTime(muteExpiresAtMs, window._locale)
}}
/>}
/>
);
StudioMuteEditMessage.propTypes = {
muteExpiresAtMs: PropTypes.number
};
export default connect(
state => ({
muteExpiresAtMs: (selectMuteStatus(state).muteExpiresAt * 1000 || 0)
})
)(StudioMuteEditMessage);

View file

@ -5,21 +5,50 @@ import {connect} from 'react-redux';
import classNames from 'classnames';
import {FormattedMessage, intlShape, injectIntl} from 'react-intl';
import {addProject} from './lib/studio-project-actions';
import {Errors, addProject} from './lib/studio-project-actions';
import UserProjectsModal from './modals/user-projects-modal.jsx';
import ValidationMessage from '../../components/forms/validation-message.jsx';
import {useAlertContext} from '../../components/alert/alert-context';
const errorToMessageId = error => {
switch (error) {
case Errors.NETWORK: return 'studio.projectErrors.generic';
case Errors.SERVER: return 'studio.projectErrors.generic';
case Errors.PERMISSION: return 'studio.projectErrors.permission';
case Errors.DUPLICATE: return 'studio.projectErrors.duplicate';
case Errors.RATE_LIMIT: return 'studio.projectErrors.tooFast';
case Errors.UNKNOWN_PROJECT: return 'studio.projectErrors.checkUrl';
default: return 'studio.projectErrors.generic';
}
};
const StudioProjectAdder = ({intl, onSubmit}) => {
const [value, setValue] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const {successAlert} = useAlertContext();
const submit = () => {
setSubmitting(true);
setError(null);
onSubmit(value)
.then(() => setValue(''))
.catch(e => setError(e))
.then(() => {
successAlert({
id: 'studio.alertProjectAdded',
values: {title: value}
});
setValue('');
})
.catch(e => {
// Duplicate project will show success alert
if (e === Errors.DUPLICATE) {
successAlert({id: 'studio.alertProjectAlreadyAdded'});
setValue('');
} else {
// Other errors are displayed by this component
setError(e);
}
})
.then(() => setSubmitting(false));
};
return (
@ -30,7 +59,7 @@ const StudioProjectAdder = ({intl, onSubmit}) => {
<ValidationMessage
mode="error"
className="validation-left"
message={<FormattedMessage id="studio.projectErrors.checkUrl" />}
message={<FormattedMessage id={errorToMessageId(error)} />}
/>
</div>}
<input

View file

@ -1,10 +1,11 @@
/* eslint-disable react/jsx-no-bind */
import React, {useState} from 'react';
import React, {useContext, useState} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import classNames from 'classnames';
import {FormattedMessage} from 'react-intl';
import AlertContext from '../../components/alert/alert-context.js';
import {selectCanRemoveProject} from '../../redux/studio-permissions';
import {removeProject} from './lib/studio-project-actions';
@ -16,9 +17,9 @@ const StudioProjectTile = ({
id, title, image, avatar, username // own props
}) => {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const projectUrl = `/projects/${id}`;
const userUrl = `/users/${username}`;
const {errorAlert} = useContext(AlertContext);
return (
<div className="studio-project-tile">
<a href={projectUrl}>
@ -54,11 +55,10 @@ const StudioProjectTile = ({
disabled={submitting}
onClick={() => {
setSubmitting(true);
setError(null);
onRemove(id)
.catch(e => {
setError(e);
.catch(() => {
setSubmitting(false);
errorAlert({id: 'studio.alertProjectRemoveError'}, null);
});
}}
>
@ -67,7 +67,6 @@ const StudioProjectTile = ({
</button></li>
</OverflowMenu>
}
{error && <div>{error}</div>} {/* TODO where do these errors go? */}
</div>
</div>
);

View file

@ -3,79 +3,102 @@ import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import StudioOpenToAll from './studio-open-to-all.jsx';
import {FormattedMessage} from 'react-intl';
import classNames from 'classnames';
import {projects} from './lib/redux-modules';
import {selectCanAddProjects, selectCanEditOpenToAll} from '../../redux/studio-permissions';
import {selectCanAddProjects, selectCanEditOpenToAll, selectShowProjectMuteError} from '../../redux/studio-permissions';
import Debug from './debug.jsx';
import StudioProjectAdder from './studio-project-adder.jsx';
import StudioProjectTile from './studio-project-tile.jsx';
import {loadProjects} from './lib/studio-project-actions.js';
import classNames from 'classnames';
import CommentingStatus from '../../components/commenting-status/commenting-status.jsx';
import {selectIsMuted, selectMuteStatus} from '../../redux/session.js';
import {formatRelativeTime} from '../../lib/format-time.js';
import AlertProvider from '../../components/alert/alert-provider.jsx';
import Alert from '../../components/alert/alert.jsx';
const StudioProjects = ({
canAddProjects, canEditOpenToAll, items, error, loading, moreToLoad, onLoadMore
canAddProjects, canEditOpenToAll, items, isMuted, error,
loading, moreToLoad, onLoadMore, muteExpiresAtMs, showMuteError
}) => {
useEffect(() => {
if (items.length === 0) onLoadMore();
}, []);
return (
<div className="studio-projects">
<div className="studio-header-container">
<h2><FormattedMessage id="studio.projectsHeader" /></h2>
{canEditOpenToAll && <StudioOpenToAll />}
</div>
{canAddProjects && <StudioProjectAdder />}
{error && <Debug
label="Error"
data={error}
/>}
<div className="studio-projects-grid">
{items.length === 0 && !loading ? (
<div className="studio-empty">
{canAddProjects ? (
<React.Fragment>
<img
width="388"
height="265"
className="studio-empty-img"
src="/images/studios/projects-empty-can-add.png"
<AlertProvider>
<div className="studio-projects">
<Alert className="studio-alert" />
<div className="studio-header-container">
<h2><FormattedMessage id="studio.projectsHeader" /></h2>
{canEditOpenToAll && <StudioOpenToAll />}
</div>
{showMuteError &&
<CommentingStatus>
<p>
<div>
<FormattedMessage
id="studios.mutedProjects"
values={{
inDuration: formatRelativeTime(muteExpiresAtMs, window._locale)
}}
/>
<div className="studio-empty-msg">
<div><FormattedMessage id="studio.projectsEmptyCanAdd1" /></div>
<div><FormattedMessage id="studio.projectsEmptyCanAdd2" /></div>
</div>
</React.Fragment>
) : (
<React.Fragment>
<img
width="186"
height="138"
className="studio-empty-img"
src="/images/studios/projects-empty.png"
/>
<div className="studio-empty-msg">
<div><FormattedMessage id="studio.projectsEmpty1" /></div>
<div><FormattedMessage id="studio.projectsEmpty2" /></div>
</div>
</React.Fragment>
)}
</div>
) : (
<React.Fragment>
{items.map(item =>
(<StudioProjectTile
fetching={loading}
key={item.id}
id={item.id}
title={item.title}
image={item.image}
avatar={item.avatar['90x90']}
username={item.username}
addedBy={item.actor_id}
/>)
)}
{moreToLoad &&
</div>
<div><FormattedMessage id="studios.mutedPaused" /></div>
</p>
</CommentingStatus>
}
{canAddProjects && <StudioProjectAdder />}
{error && <Debug
label="Error"
data={error}
/>}
<div className="studio-projects-grid">
{items.length === 0 && !loading ? (
<div className="studio-empty">
{canAddProjects ? (
<React.Fragment>
<img
width="388"
height="265"
className="studio-empty-img"
src="/images/studios/projects-empty-can-add.png"
/>
<div className="studio-empty-msg">
<div><FormattedMessage id="studio.projectsEmptyCanAdd1" /></div>
<div><FormattedMessage id="studio.projectsEmptyCanAdd2" /></div>
</div>
</React.Fragment>
) : (
<React.Fragment>
<img
width="186"
height="138"
className="studio-empty-img"
src="/images/studios/projects-empty.png"
/>
<div className="studio-empty-msg">
<div><FormattedMessage id="studio.projectsEmpty1" /></div>
{!isMuted && <div><FormattedMessage id="studio.projectsEmpty2" /></div>}
</div>
</React.Fragment>
)}
</div>
) : (
<React.Fragment>
{items.map(item =>
(<StudioProjectTile
fetching={loading}
key={item.id}
id={item.id}
title={item.title}
image={item.image}
avatar={item.avatar['90x90']}
username={item.username}
addedBy={item.actor_id}
/>)
)}
{moreToLoad &&
<div className="studio-projects-load-more">
<button
className={classNames('button', {
@ -86,11 +109,12 @@ const StudioProjects = ({
<FormattedMessage id="general.loadMore" />
</button>
</div>
}
</React.Fragment>
)}
}
</React.Fragment>
)}
</div>
</div>
</div>
</AlertProvider>
);
};
@ -105,17 +129,23 @@ StudioProjects.propTypes = {
title: PropTypes.string,
username: PropTypes.string
})),
isMuted: PropTypes.bool,
loading: PropTypes.bool,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
moreToLoad: PropTypes.bool,
onLoadMore: PropTypes.func
muteExpiresAtMs: PropTypes.number,
onLoadMore: PropTypes.func,
showMuteError: PropTypes.bool
};
export default connect(
state => ({
...projects.selector(state),
canAddProjects: selectCanAddProjects(state),
canEditOpenToAll: selectCanEditOpenToAll(state)
canEditOpenToAll: selectCanEditOpenToAll(state),
isMuted: selectIsMuted(state),
showMuteError: selectShowProjectMuteError(state),
muteExpiresAtMs: (selectMuteStatus(state).muteExpiresAt * 1000 || 0)
}),
{
onLoadMore: loadProjects

View file

@ -11,15 +11,20 @@ import {
selectors
} from '../../redux/studio-report';
import reportIcon from './icons/report-icon.svg';
const StudioReport = ({
canReport,
isOpen,
handleOpen
}) => (
<div>
{canReport && (
<button onClick={handleOpen}><FormattedMessage id="general.report" /></button>
)}
{canReport &&
<button onClick={handleOpen}>
<img src={reportIcon} />
<FormattedMessage id="general.report" />
</button>
}
{isOpen && (
<StudioReportModal />
)}

View file

@ -0,0 +1,48 @@
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {FormattedMessage} from 'react-intl';
import {selectIsFetchingInfo, selectStudioFollowerCount, selectStudioLastUpdated} from '../../redux/studio';
import lastUpdatedIcon from './icons/last-updated-icon.svg';
import followersIcon from './icons/followers-icon.svg';
const StudioStats = ({
isFetchingInfo,
followerCount,
lastUpdatedDate
}) => {
if (isFetchingInfo) return <React.Fragment />;
return (<React.Fragment>
<div><img
src={lastUpdatedIcon}
/><FormattedMessage
id="studio.lastUpdated"
values={{lastUpdatedDate}}
/></div>
<div><img
src={followersIcon}
/><FormattedMessage
id="studio.followerCount"
values={{followerCount}}
/></div>
</React.Fragment>);
};
StudioStats.propTypes = {
isFetchingInfo: PropTypes.bool,
followerCount: PropTypes.number,
lastUpdatedDate: PropTypes.instanceOf(Date)
};
export default connect(
state => ({
isFetchingInfo: selectIsFetchingInfo(state),
followerCount: selectStudioFollowerCount(state),
lastUpdatedDate: selectStudioLastUpdated(state)
}),
{
}
)(StudioStats);

View file

@ -1,9 +1,45 @@
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {useRouteMatch, NavLink} from 'react-router-dom';
import SubNavigation from '../../components/subnavigation/subnavigation.jsx';
import {FormattedMessage} from 'react-intl';
const StudioTabNav = () => {
import SubNavigation from '../../components/subnavigation/subnavigation.jsx';
import activityIcon from './icons/activity-icon.svg';
import commentsIcon from './icons/comments-icon.svg';
import curatorsIcon from './icons/curator-icon.svg';
import projectsIcon from './icons/projects-icon.svg';
import {selectIsFetchingInfo, selectStudioCommentCount, selectStudioProjectCount} from '../../redux/studio';
/**
* Format a number to a string. If the number is below the limit, format as-is. Otherwise, show a '+' to indicate that
* the actual number might be higher.
* @example
* limitCount(1, 100) == '1'
* limitCount(12.5, 100) == '12.5'
* limitCount(100, 100) == '100+'
* limitCount(999, 100) == '100+'
* @param {number} num - the number to format
* @param {number} limit - the number at which we start showing a '+'
* @returns {string} - a string representing a number, possibly with a '+' at the end
*/
const limitCount = (num, limit) => {
if (num < limit) {
return `${num}`;
}
return `${limit}+`;
};
// These must match the limits used by the API
const countLimits = {
comments: 100,
projects: 100
};
const StudioTabNav = ({isFetchingInfo, commentCount, projectCount}) => {
const {params: {studioPath, studioId}} = useRouteMatch();
const base = `/${studioPath}/${studioId}`;
return (
@ -16,28 +52,68 @@ const StudioTabNav = () => {
to={base}
exact
>
<li><FormattedMessage id="studio.tabNavProjects" /></li>
<li><img
src={projectsIcon}
/><FormattedMessage
id={isFetchingInfo ? 'studio.tabNavProjects' : 'studio.tabNavProjectsWithCount'}
values={{
projectCount: (
<span className="tab-count">
({limitCount(projectCount, countLimits.projects)})
</span>
)
}}
/></li>
</NavLink>
<NavLink
activeClassName="active"
to={`${base}/comments`}
>
<li><FormattedMessage id="studio.tabNavComments" /></li>
<li><img
src={commentsIcon}
/><FormattedMessage
id={isFetchingInfo ? 'studio.tabNavComments' : 'studio.tabNavCommentsWithCount'}
values={{
commentCount: (
<span className="tab-count">
({limitCount(commentCount, countLimits.comments)})
</span>
)
}}
/></li>
</NavLink>
<NavLink
activeClassName="active"
to={`${base}/curators`}
>
<li><FormattedMessage id="studio.tabNavCurators" /></li>
<li><img
src={curatorsIcon}
/><FormattedMessage id="studio.tabNavCurators" /></li>
</NavLink>
<NavLink
activeClassName="active"
to={`${base}/activity`}
>
<li><FormattedMessage id="studio.tabNavActivity" /></li>
<li><img
src={activityIcon}
/><FormattedMessage id="studio.tabNavActivity" /></li>
</NavLink>
</SubNavigation>
);
};
export default StudioTabNav;
StudioTabNav.propTypes = {
isFetchingInfo: PropTypes.bool,
commentCount: PropTypes.number,
projectCount: PropTypes.number
};
const mapStateToProps = state => ({
isFetchingInfo: selectIsFetchingInfo(state),
commentCount: selectStudioCommentCount(state),
projectCount: selectStudioProjectCount(state)
});
const mapDispatchToProps = () => ({});
export default connect(mapStateToProps, mapDispatchToProps)(StudioTabNav);

View file

@ -1,14 +1,15 @@
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import classNames from 'classnames';
import {FormattedMessage} from 'react-intl';
import {selectStudioTitle, selectIsFetchingInfo} from '../../redux/studio';
import {selectCanEditInfo} from '../../redux/studio-permissions';
import {selectCanEditInfo, selectShowEditMuteError} from '../../redux/studio-permissions';
import {Errors, mutateStudioTitle, selectIsMutatingTitle, selectTitleMutationError} from '../../redux/studio-mutations';
import ValidationMessage from '../../components/forms/validation-message.jsx';
import StudioMuteEditMessage from './studio-mute-edit-message.jsx';
const errorToMessageId = error => {
switch (error) {
@ -20,27 +21,42 @@ const errorToMessageId = error => {
};
const StudioTitle = ({
titleError, isFetching, isMutating, title, canEditInfo, handleUpdate
titleError, isFetching, isMutating, isMutedEditor, title, canEditInfo, handleUpdate
}) => {
const fieldClassName = classNames('studio-title', {
'mod-fetching': isFetching,
'mod-mutating': isMutating,
'mod-form-error': !!titleError
'mod-form-error': !!titleError,
'muted-editor': isMutedEditor
});
const [showMuteMessage, setShowMuteMessage] = useState(false);
return (
<div className="studio-info-section">
<textarea
className={fieldClassName}
disabled={isMutating || !canEditInfo || isFetching}
defaultValue={title}
onKeyDown={e => e.key === 'Enter' && e.target.blur()}
onBlur={e => e.target.value !== title &&
handleUpdate(e.target.value)}
/>
{titleError && <ValidationMessage
mode="error"
message={<FormattedMessage id={errorToMessageId(titleError)} />}
/>}
<div
className="studio-info-section"
onMouseEnter={() => isMutedEditor && setShowMuteMessage(true)}
onMouseLeave={() => isMutedEditor && setShowMuteMessage(false)}
>
{canEditInfo || isMutedEditor ? (
<React.Fragment>
<textarea
className={fieldClassName}
disabled={isMutating || !canEditInfo || isFetching}
defaultValue={title}
onKeyDown={e => e.key === 'Enter' && e.target.blur()}
onBlur={e => e.target.value !== title &&
handleUpdate(e.target.value)}
/>
{titleError && <ValidationMessage
mode="error"
message={<FormattedMessage id={errorToMessageId(titleError)} />}
/>}
{showMuteMessage && <StudioMuteEditMessage />}
</React.Fragment>
) : (
<div className={fieldClassName}>{title}</div>
)}
</div>
);
};
@ -50,6 +66,7 @@ StudioTitle.propTypes = {
canEditInfo: PropTypes.bool,
isFetching: PropTypes.bool,
isMutating: PropTypes.bool,
isMutedEditor: PropTypes.bool,
title: PropTypes.string,
handleUpdate: PropTypes.func
};
@ -60,6 +77,7 @@ export default connect(
canEditInfo: selectCanEditInfo(state),
isFetching: selectIsFetchingInfo(state),
isMutating: selectIsMutatingTitle(state),
isMutedEditor: selectShowEditMuteError(state),
titleError: selectTitleMutationError(state)
}),
{

View file

@ -22,6 +22,9 @@ import StudioManagers from './studio-managers.jsx';
import StudioCurators from './studio-curators.jsx';
import StudioComments from './studio-comments.jsx';
import StudioActivity from './studio-activity.jsx';
import StudioCuratorInvite from './studio-curator-invite.jsx';
import StudioMeta from './studio-meta.jsx';
import StudioAdminPanel from './studio-admin-panel.jsx';
import {
projects,
@ -37,14 +40,20 @@ const {commentsReducer} = require('../../redux/comments');
const {studioMutationsReducer} = require('../../redux/studio-mutations');
import './studio.scss';
import {selectMuteStatus} from '../../redux/session.js';
import {formatRelativeTime} from '../../lib/format-time.js';
import CommentingStatus from '../../components/commenting-status/commenting-status.jsx';
import {FormattedMessage} from 'react-intl';
import {selectShowCuratorMuteError} from '../../redux/studio-permissions.js';
const StudioShell = ({studioLoadFailed}) => {
const StudioShell = ({showCuratorMuteError, muteExpiresAtMs, studioLoadFailed}) => {
const match = useRouteMatch();
return (
studioLoadFailed ?
<NotAvailable /> :
<div className="studio-shell">
<StudioMeta />
<div className="studio-info">
<StudioInfo />
</div>
@ -53,6 +62,22 @@ const StudioShell = ({studioLoadFailed}) => {
<div>
<Switch>
<Route path={`${match.path}/curators`}>
<StudioCuratorInvite />
{showCuratorMuteError &&
<CommentingStatus>
<p>
<div>
<FormattedMessage
id="studios.mutedCurators"
values={{
inDuration: formatRelativeTime(muteExpiresAtMs, window._locale)
}}
/>
</div>
<div><FormattedMessage id="studios.mutedPaused" /></div>
</p>
</CommentingStatus>
}
<StudioManagers />
<StudioCurators />
</Route>
@ -77,17 +102,22 @@ const StudioShell = ({studioLoadFailed}) => {
};
StudioShell.propTypes = {
showCuratorMuteError: PropTypes.bool,
muteExpiresAtMs: PropTypes.number,
studioLoadFailed: PropTypes.bool
};
const ConnectedStudioShell = connect(
state => ({
studioLoadFailed: selectStudioLoadFailed(state)
showCuratorMuteError: selectShowCuratorMuteError(state),
studioLoadFailed: selectStudioLoadFailed(state),
muteExpiresAtMs: (selectMuteStatus(state).muteExpiresAt * 1000 || 0)
}),
)(StudioShell);
render(
<Page className="studio-page">
<StudioAdminPanel />
<Router>
<Switch>
{/* Use variable studioPath to support /studio-playground/ or future route */}

View file

@ -15,6 +15,12 @@ $radius: 8px;
display: block;
padding-top: 40px;
&.mod-view-admin-panel-open {
min-width: unset;
width: calc(100% - 250px);
margin: 50px 0px 50px 250px;
}
/* WAT Why does everything center at smaller widths??!! */
@media #{$intermediate-and-smaller} {
& {
@ -48,36 +54,102 @@ $radius: 8px;
grid-template-columns: minmax(0, 1fr);
gap: 20px;
.studio-title, .studio-description {
background: transparent;
margin: 0 -8px; /* Outset the border horizontally */
padding: 5px 8px;
border: 2px dashed $ui-blue-25percent;
border-radius: $radius;
resize: none;
width: 300px;
&:disabled { border-color: transparent; }
}
.studio-info-section {
position: relative;
.validation-message {
margin-top: .5rem;
box-sizing: border-box;
}
.hidden {
display: none;
}
}
.studio-info-footer {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.studio-info-footer-stats {
justify-content: flex-start;
div {
display: flex;
align-items: center;
margin: 0.25em;
img {
margin-right: 0.25em;
width: 1.5em;
}
}
}
.studio-info-footer-report {
justify-content: flex-end;
button {
font-size: smaller;
background-color: $ui-blue;
border: 1px solid transparent;
border-radius: 999em;
color: $ui-white;
display: flex;
align-items: center;
padding: 0.25em;
padding-right: 0.75em;
&:hover {
background-color: $ui-blue-dark;
}
img {
margin-right: 0.25em;
width: 1.5em;
}
}
}
.studio-title {
font-size: 28px;
font-weight: 700;
}
.studio-title:disabled {
padding: 0;
margin: 0;
}
.studio-description:disabled {
.studio-description {
background: $ui-blue-10percent;
padding: 15px 20px;
border-color: transparent;
border-radius: $radius;
word-wrap: break-word;
white-space: pre-wrap;
width: 300px;
box-sizing: border-box;
height: 24rem;
overflow-y: scroll;
&.muted-editor {
@media #{$intermediate-and-smaller} {
height: 18rem;
}
}
}
/* Overrides for when title and description are editable textareas */
textarea.studio-title, textarea.studio-description {
background: transparent;
padding: 5px 8px;
border: 2px dashed $ui-blue-25percent;
border-radius: $radius;
resize: none;
width: 300px;
box-sizing: border-box;
}
.studio-image {
width: 300px;
height: 225px;
object-fit: cover;
border-radius: 8px;
}
.studio-follow-button {
@ -92,11 +164,29 @@ $radius: 8px;
border-bottom: 1px solid $active-dark-gray;
padding-bottom: 8px;
font-size: 14px;
li { background: rgba(0, 0, 0, 0.15); }
li {
display: flex;
align-items: center;
background: rgba(0, 0, 0, 0.15);
padding: 0.5em 0.75em 0.5em 0.5em;
&:active {
padding: calc(0.5em + 1px) calc(0.75em + 1px) calc(0.5em + 1px) calc(0.5em + 1px);
}
img {
margin-right: 0.5em;
width: 1.5em;
}
.tab-count {
font-weight: normal;
}
}
.active > li { background: $ui-blue; }
}
.studio-projects {}
.studio-projects, .studio-members {
position: relative;
}
.studio-projects-grid {
margin-top: 20px;
display: grid;
@ -176,7 +266,6 @@ $radius: 8px;
}
}
.studio-members {}
.studio-members-grid {
margin-top: 20px;
display: grid;
@ -336,6 +425,18 @@ $radius: 8px;
padding-top: 8px;
}
.studio-comments-not-allowed {
display: flex;
width: 100%;
background: rgba(76, 151, 255, 0.15);
height: 80px;
border: 1px solid rgba(76, 151, 255, 0.15);
border-radius: 8px;
justify-content: center;
align-items: center;
margin-bottom: 28px;
}
.studio-empty {
grid-column: 1 / -1; /* take up all columns */
text-align: center;
@ -352,6 +453,76 @@ $radius: 8px;
}
}
.studio-invitation {
margin-top: 1rem;
padding: 1rem;
box-sizing: border-box;
min-height: 85px; /* So the box doesn't change height after being accepted */
display: flex;
justify-content: space-between;
align-items: center;
@media #{$intermediate-and-smaller} {
flex-direction: column;
.studio-invitation-msg {
margin-top: .5rem;
margin-bottom: 1rem;
}
}
}
.studio-info-box {
border-radius: 4px;
background: $ui-blue-10percent;
border: 1px solid $ui-blue-25percent;
&.studio-info-box-success {
background: #CEF2E8;
border: 1px solid rgba(15, 189, 140, 0.5);
}
&.studio-info-box-error {
background: #FFF0DF;
border: 1px solid $ui-dark-orange;
}
}
.studio-thumb-edit-button {
color: #4C97FF;
font-size: 1rem;
font-weight: bold;
height: 40px;
display: flex;
align-items: center;
}
.studio-thumb-edit-img {
padding-right: 10px;
}
.studio-admin-panel {
margin-top: 51px;
border: 0;
padding: .5rem;
overflow: hidden;
}
.studio-admin-panel.admin-panel-open {
padding: 0;
width: 250px;
}
.admin-iframe {
position: absolute;
top: 0;
left: 0;
z-index: 100;
margin: 0;
border: 0;
width: 250px;
height: 100%;
}
/* Modification classes for different interaction states */
.mod-fetching { /* When a field has no content to display yet */
position: relative;
@ -388,3 +559,7 @@ $radius: 8px;
.mod-form-error { /* When a field contains a value is causing an error */
border-color: $ui-orange !important;
}
.studio-curator-mute-box {
margin: 20px 0;
}

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.86144 15.403C7.43527 15.403 7.0091 15.2398 6.68447 14.9152L3.48818 11.7189C2.83727 11.068 2.83727 10.0159 3.48818 9.36498C4.13909 8.71407 5.19121 8.71407 5.84212 9.36498L7.86144 11.3843L14.1591 5.08828C14.8084 4.43737 15.8622 4.43737 16.5131 5.08828C17.1623 5.73753 17.1623 6.7913 16.5131 7.44222L9.03841 14.9152C8.71378 15.2398 8.28761 15.403 7.86144 15.403Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 16 16" style="enable-background:new 0 0 16 16;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<path class="st0" d="M9.5,12.8c0,0.8-0.6,1.5-1.5,1.5c-0.4,0-0.8-0.2-1.1-0.5c-0.3-0.3-0.5-0.6-0.5-1.1V9.5H3.2
C2.8,9.5,2.5,9.3,2.2,9S1.7,8.3,1.7,7.9c0-0.8,0.6-1.5,1.5-1.5l3.3,0.1L6.4,3.2C6.5,2.4,7.2,1.7,8,1.6c0.8,0.1,1.5,0.8,1.6,1.6
L9.5,6.5h3.3c0.8,0,1.5,0.6,1.5,1.5s-0.6,1.5-1.5,1.5l-3.3,0L9.5,12.8z"/>
</svg>

After

Width:  |  Height:  |  Size: 712 B

View file

@ -0,0 +1,12 @@
<svg width="190" height="188" viewBox="0 0 190 188" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="190" height="188">
<rect width="190" height="187.853" fill="#C4C4C4"/>
</mask>
<g mask="url(#mask0)">
<path opacity="0.3" fill-rule="evenodd" clip-rule="evenodd" d="M48.2863 7.01099C-26.1432 21.8371 -39.6529 68.6058 -22.1209 121.141C-2.67413 179.413 40.1423 187.854 72.0237 187.854C132.41 187.854 170.703 132.907 167.374 86.0398C162.702 20.2606 116.204 -11.8127 48.2863 7.01099Z" fill="#0FBD8C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M85.9406 88.4502C84.1234 91.9029 80.5282 93.8792 76.7849 93.4857L65.4775 92.2973C65.4785 92.6363 65.4793 92.9739 65.4801 93.3101C65.5081 104.802 65.5319 114.605 71.0832 123.487C75.1376 129.974 79.2076 134.319 82.2838 137.602C85.5685 141.109 87.7201 143.406 87.5097 145.81C87.1025 150.465 73.354 147.95 60.968 138.266C48.5819 128.582 45.1431 117.469 43.7689 107.967C42.9683 102.431 42.896 95.1075 42.8678 89.9209L30.3232 88.6024C26.5741 88.2084 23.4684 85.5278 22.4146 81.7733C21.3556 78.0122 22.5737 73.9795 25.5155 71.4966L51.292 49.7461C53.2275 48.1124 55.6553 47.3518 58.1162 47.6104C60.5828 47.8697 62.7995 49.1185 64.3473 51.1182L85.0441 77.7533C87.3995 80.793 87.7584 84.9914 85.9406 88.4502Z" fill="white"/>
<path d="M139.916 120.504C135.073 116.185 132.034 109.965 132.034 103.051C132.034 90.0082 142.847 79.4351 156.186 79.4351C169.526 79.4351 180.339 90.0082 180.339 103.051C180.339 109.966 177.299 116.187 172.455 120.506C179.36 125.744 184.599 134.709 187.459 146.951C192.251 167.458 192.147 178.159 155.333 178.159C117.263 178.159 118.657 170.339 121.879 152.275L121.88 152.272C122.046 151.341 122.217 150.384 122.387 149.398C123.072 145.444 124.196 141.72 125.681 138.289C125.594 138.27 125.506 138.252 125.419 138.233C115.666 136.123 103.5 130.492 95.4593 116.666C92.9914 112.422 94.4308 106.981 98.6744 104.513C102.918 102.046 108.359 103.485 110.827 107.729C115.689 116.089 122.84 119.486 129.177 120.857C132.387 121.552 135.307 121.697 137.415 121.664C137.753 121.658 138.068 121.649 138.356 121.636C138.868 121.242 139.389 120.865 139.916 120.504Z" fill="#0EBD8C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M115.996 44.6556L116.503 52.4363L110.381 57.269C108.294 58.9136 109.001 62.2351 111.575 62.8917L119.136 64.8123L121.837 72.1291C122.757 74.62 126.136 74.9753 127.553 72.7304L131.716 66.1345L139.51 65.8271C142.165 65.7208 143.544 62.6186 141.849 60.5752L136.861 54.5783L138.976 47.0717C139.697 44.5128 137.174 42.2402 134.706 43.2253L127.458 46.1116L120.975 41.7813C118.765 40.3088 115.826 42.0056 115.996 44.6556Z" fill="#0FBD8C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M163.278 16.3101L160.308 17.2026L157.887 15.2625C157.063 14.6008 155.839 15.1719 155.815 16.2283L155.747 19.3303L153.153 21.0321C152.27 21.6115 152.434 22.9527 153.431 23.3011L156.36 24.3244L157.176 27.3175C157.455 28.337 158.781 28.594 159.421 27.7542L161.299 25.2845L164.398 25.4325C165.454 25.4828 166.109 24.3015 165.506 23.4333L163.739 20.8824L164.837 17.9824C165.21 16.9942 164.29 16.007 163.278 16.3101Z" fill="#0FBD8C"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -0,0 +1,20 @@
<svg width="130" height="191" viewBox="0 0 130 191" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="3" width="130" height="188">
<rect y="3" width="130" height="188" fill="#C4C4C4"/>
</mask>
<g mask="url(#mask0)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.00015 190.5C-61.0204 187.192 -53.9928 175.861 -57.4998 99C-60.4497 34.3467 -28.917 3.00052 32.7026 3.00052C109.489 3.00052 131.182 33.3704 128.968 98.3174C126.616 167.302 49.6022 193 2.00015 190.5Z" fill="#0FBD8C" fill-opacity="0.25"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-26.3462 63.6609C-26.7399 61.5295 -25.3644 59.4724 -23.2444 59.0217L59.0989 41.5191C61.2301 41.0661 63.3318 42.4021 63.8262 44.524L78.131 105.916C78.3677 106.932 78.1983 108 77.659 108.892L64.858 130.08C64.3088 130.989 63.4205 131.642 62.3891 131.896L-6.31782 148.784C-8.55219 149.334 -10.7881 147.889 -11.2061 145.626L-26.3462 63.6609Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M63 111.166L79 107L64.3913 132L63 111.166Z" fill="#0EBD8C" fill-opacity="0.1"/>
<rect x="-24.9741" y="73.2256" width="81" height="9" rx="4.5" transform="rotate(-13 -24.9741 73.2256)" fill="#575E75" fill-opacity="0.25"/>
<rect x="-22.9741" y="90.2256" width="81" height="9" rx="4.5" transform="rotate(-13 -22.9741 90.2256)" fill="#575E75" fill-opacity="0.25"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-6.54849 109.48C-7.10587 107.066 -5.6067 104.655 -3.19484 104.088L21.4033 98.298C23.8297 97.7269 26.2581 99.2371 26.8189 101.666C27.3762 104.08 25.8771 106.491 23.4652 107.059L-1.13289 112.848C-3.55935 113.419 -5.98774 111.909 -6.54849 109.48Z" fill="#575E75" fill-opacity="0.25"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M104.085 69.4963L102.113 65.7763C100.679 63.0733 96.7551 62.324 94.2109 63.6475L93.2085 64.1701L93.2078 64.1695L31.1812 96.4579L31.123 96.4879L29.9175 98.7689L25.7672 106.62C25.5442 107.041 25.7175 107.68 26.0654 108.12C26.2715 108.384 26.5406 108.578 26.8254 108.612L30.9788 109.111L30.9763 109.106L36.8018 109.807L38.2781 109.985L38.3157 109.89L38.3472 109.949L91.7703 82.1391L91.7709 82.1403L94.5218 80.7085L94.5206 80.7073L95.5303 80.1817L95.5309 80.1829L98.2818 78.7511L98.2812 78.7493L100.039 77.8346L100.049 77.8539L101.368 77.1671C103.915 75.8411 105.52 72.203 104.085 69.4963Z" fill="#0EBD8C" fill-opacity="0.25"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.0137 107.407L97.1118 71.5674L89.2196 58.4067L29.1118 94.2522L37.0137 107.407Z" fill="#FFBF00"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M35.3123 99.6064L31.7587 98.5862L32.6907 95.2529L28.9264 94.22L28.8699 94.2538L27.791 96.6099L24.0771 104.722C23.7596 105.415 24.4766 106.608 25.241 106.658L35.2923 107.314L36.7792 107.411L37.9248 103.945L34.3761 103.128L35.3123 99.6064Z" fill="#CF8B17"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.8201 100.933L24.0825 104.727C23.7649 105.42 24.4826 106.614 25.2463 106.664L29.429 106.937L25.8201 100.933Z" fill="#5C6771"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M89.9165 75.9072L82 62.7546L88.307 58.9998L96.2235 72.1517L89.9165 75.9072Z" fill="#36A97E"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M96.7747 71.7455L88.8545 58.5699L90.1435 57.8031C92.6139 56.333 96.5816 56.8694 98.1656 59.5049L100.346 63.1315C101.932 65.77 100.529 69.5121 98.0557 70.9835L96.7747 71.7455Z" fill="#ED5F87"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M92.292 99.1195C93.6848 98.7458 94.7728 97.6621 95.148 96.2748L96.0011 93.1187C96.4042 91.6268 98.5292 91.6268 98.933 93.1187L99.786 96.2748C100.161 97.6621 101.249 98.7458 102.641 99.1195L105.811 99.9692C107.308 100.371 107.308 102.487 105.811 102.889L102.641 103.739C101.249 104.112 100.161 105.196 99.786 106.584L98.933 109.74C98.5292 111.231 96.4042 111.231 96.0011 109.74L95.148 106.584C94.7728 105.196 93.6848 104.112 92.292 103.739L89.1234 102.889C87.6255 102.487 87.6255 100.371 89.1234 99.9692L92.292 99.1195Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -50,6 +50,18 @@
"social": false
}
}
},
"user1Muted": {
"session": {
"user": {
"id": 1,
"username": "user1-username"
},
"permissions": {
"mute_status": {"muteExpiresAt": 32515480478, "offenses": [], "showWarning": false},
"social": true
}
}
}
}
}

View file

@ -1,123 +0,0 @@
/*
* Checks that the links in the navbar on the homepage have the right URLs to redirect to
*
* Test cases: https://github.com/LLK/scratch-www/wiki/Most-Important-Workflows
*/
const SeleniumHelper = require('../selenium-helpers.js');
const helper = new SeleniumHelper();
var tap = require('tap');
const webdriver = require('selenium-webdriver');
const driver = helper.buildDriver('www-smoke test_navbar_links');
// Set test url through environment variable
var rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
// number of tests in the plan
tap.plan(7);
tap.tearDown(function () {
// quit the instance of the browser
driver.quit();
});
tap.beforeEach(function () {
// load the page with the driver
return driver.get(rootUrl);
});
// ==== Links in navbar ====
// the create link changes depending on whether the user is signed in or not (tips window opens)
tap.test('checkCreateLinkWhenSignedOut', function (t) {
var xPathLink = '//li[contains(@class, "link") and contains(@class, "create")]/a';
var expectedHref = '/projects/editor/?tutorial=getStarted';
driver.findElement(webdriver.By.xpath(xPathLink))
.then(function (element) {
return element.getAttribute('href');
})
.then(function (url) {
t.equal(url.substr(-expectedHref.length), expectedHref);
t.end();
});
});
tap.test('checkExploreLinkWhenSignedOut', function (t) {
var xPathLink = '//li[contains(@class, "link") and contains(@class, "explore")]/a';
var expectedHref = '/explore/projects/all';
driver.findElement(webdriver.By.xpath(xPathLink))
.then(function (element) {
return element.getAttribute('href');
})
.then(function (url) {
t.equal(url.substr(-expectedHref.length), expectedHref);
t.end();
});
});
tap.test('checkIdeasLinkWhenSignedOut', function (t) {
var xPathLink = '//li[contains(@class, "link") and contains(@class, "ideas")]/a';
var expectedHref = '/ideas';
driver.findElement(webdriver.By.xpath(xPathLink))
.then(function (element) {
return element.getAttribute('href');
})
.then(function (url) {
t.equal(url.substr(-expectedHref.length), expectedHref);
t.end();
});
});
tap.test('checkAboutLinkWhenSignedOut', function (t) {
var xPathLink = '//li[contains(@class, "link") and contains(@class, "about")]/a';
var expectedHref = '/about';
driver.findElement(webdriver.By.xpath(xPathLink))
.then(function (element) {
return element.getAttribute('href');
})
.then(function (url) {
t.equal(url.substr(-expectedHref.length), expectedHref);
t.end();
});
});
// ==== Search bar ====
tap.test('checkSearchBar', function (t) {
var xPathLink = '//input[@id="frc-q-1088"]';
// search bar should exist
driver.findElement(webdriver.By.xpath(xPathLink)).then(function (element) {
t.ok(element);
t.end();
});
});
// ==== Join Scratch & Sign In ====
tap.test('checkJoinScratchLinkWhenSignedOut', function (t) {
var xPathLink = '//li[contains(@class, "link") and contains(@class, "right") and contains(@class, "join")]/a';
var expectedText = 'Join Scratch';
driver.findElement(webdriver.By.xpath(xPathLink))
.then(function (element) {
return element.getText('a');
})
.then(function (text) {
t.equal(text, expectedText);
t.end();
});
});
tap.test('checkSignInLinkWhenSignedOut', function (t) {
var xPathLink = '//li[contains(@class, "link") and contains(@class, "right") and contains(@class, "login-item")]/a';
var expectedText = 'Sign in';
driver.findElement(webdriver.By.xpath(xPathLink))
.then(function (element) {
return element.getText('a');
})
.then(function (text) {
t.equal(text, expectedText);
t.end();
});
});

View file

@ -1,93 +0,0 @@
/*
* Checks that the some of the homepage rows on the homepage are displayed and
* contents have the right URLs to redirect to
*
* Test cases: https://github.com/LLK/scratch-www/wiki/Most-Important-Workflows
*/
const SeleniumHelper = require('../selenium-helpers.js');
const helper = new SeleniumHelper();
var tap = require('tap');
const webdriver = require('selenium-webdriver');
const driver = helper.buildDriver('www-smoke test_project_rows');
var rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
// number of tests in the plan
tap.plan(4);
tap.tearDown(function () {
// quit the instance of the browser
driver.quit();
});
tap.beforeEach(function () {
// load the page with the driver
return driver.get(rootUrl);
});
// checks that the title of the first row is Featured Projects
tap.test('checkFeaturedProjectsRowTitleWhenSignedOut', function (t) {
var xPathLink = '//div[@class="box"]/div[@class="box-header"]/h4';
driver.findElement(webdriver.By.xpath(xPathLink))
.then(function (element) {
element.getText('h4')
.then(function (text) {
// expected value of the title text
var expectedText = 'Featured Projects';
t.equal(text, expectedText);
t.end();
});
});
});
// checks that the link for a project makes sense
tap.test('checkFeaturedProjectsRowLinkWhenSignedOut', function (t) {
var xPathLink = '//div[contains(@class, "thumbnail") ' +
'and contains(@class, "project") and contains(@class, "slick-slide") ' +
'and contains(@class, "slick-active")]/a[@class="thumbnail-image"]';
driver.wait(webdriver.until
.elementLocated(webdriver.By.xpath(xPathLink)))
.then(function (element) {
element.getAttribute('href')
.then(function (url) {
// expected pattern for the project URL
// since I don't know the length of the project ID number
var expectedUrlRegExp = new RegExp('/projects/.*[0-9].*/?');
t.match(url, expectedUrlRegExp);
t.end();
});
});
});
// checks that the title of the 2nd row is Featured Studios
tap.test('checkFeaturedStudiosRowWhenSignedOut', function (t) {
var xPathLink = '//div[@class="box"][2]/div[@class="box-header"]/h4';
driver.findElement(webdriver.By.xpath(xPathLink))
.then(function (element) {
element.getText('h4')
.then(function (text) {
var expectedText = 'Featured Studios';
t.equal(text, expectedText);
t.end();
});
});
});
// checks that the link for a studio makes sense
tap.test('checkFeaturedStudiosRowLinkWhenSignedOut', function (t) {
var xPathLink = '//div[contains(@class, "thumbnail") and contains(@class, "gallery") ' +
'and contains(@class, "slick-slide") ' +
'and contains(@class, "slick-active")]/a[@class="thumbnail-image"]';
driver.findElement(webdriver.By.xpath(xPathLink))
.then(function (element) {
element.getAttribute('href')
.then(function (url) {
var expectedUrlRegExp = new RegExp('/studios/.*[0-9].*/?');
t.match(url, expectedUrlRegExp);
t.end();
});
});
});

View file

@ -0,0 +1,59 @@
const SeleniumHelper = require('./selenium-helpers.js');
const {
clickXpath,
findByXpath,
buildDriver
} = new SeleniumHelper();
let remote = process.env.SMOKE_REMOTE || false;
let rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
if (remote) {
jest.setTimeout(60000);
} else {
jest.setTimeout(20000);
}
let driver;
describe('www-integration project rows', () => {
beforeAll(async () => {
driver = await buildDriver('www-integration project rows');
// driver.get(rootUrl);
});
beforeEach(async () => {
await driver.get(rootUrl);
});
afterAll(async () => await driver.quit());
test('Featured Projects row title', async () => {
let projects = await findByXpath('//div[@class="box"]/div[@class="box-header"]/h4');
let projectsText = await projects.getText();
await expect(projectsText).toEqual('Featured Projects');
});
test('Featured Project link', async () => {
await clickXpath('//div[@class="box"][descendant::text()="Featured Projects"]' +
'//div[contains(@class, "thumbnail")][1]/a[@class="thumbnail-image"]');
let guiPlayer = await findByXpath('//div[@class="guiPlayer"]');
let guiPlayerDisplayed = await guiPlayer.isDisplayed();
await expect(guiPlayerDisplayed).toBe(true);
});
test('Featured Studios row title', async () => {
let studios = await findByXpath('//div[@class="box"][2]/div[@class="box-header"]/h4');
let studiosText = await studios.getText();
await expect(studiosText).toEqual('Featured Studios');
});
test('Featured Studios link', async () => {
await clickXpath('//div[@class="box"][descendant::text()="Featured Studios"]' +
'//div[contains(@class, "thumbnail")][1]/a[@class="thumbnail-image"]');
let galleryInfo = await findByXpath('//div[contains(@class, "gallery-info")]');
let galleryInfoDisplayed = await galleryInfo.isDisplayed();
await expect(galleryInfoDisplayed).toBe(true);
});
});

View file

@ -0,0 +1,106 @@
const SeleniumHelper = require('./selenium-helpers.js');
const {
clickXpath,
findByXpath,
buildDriver
} = new SeleniumHelper();
let remote = process.env.SMOKE_REMOTE || false;
let rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
if (remote) {
jest.setTimeout(60000);
} else {
jest.setTimeout(10000);
}
let driver;
describe('www-integration navbar links', () => {
beforeAll(async () => {
driver = await buildDriver('www-integration navbar links');
});
beforeEach(async () => {
await driver.get(rootUrl);
});
afterAll(async () => await driver.quit());
test('Check text of navbar items', async () => {
let create = await findByXpath('//li[@class="link create"]');
let createText = await create.getText();
await expect(createText).toEqual('Create');
let explore = await findByXpath('//li[@class="link explore"]');
let exploreText = await explore.getText();
await expect(exploreText).toEqual('Explore');
let ideas = await findByXpath('//li[@class="link ideas"]');
let ideasText = await ideas.getText();
await expect(ideasText).toEqual('Ideas');
let about = await findByXpath('//li[@class="link about"]');
let aboutText = await about.getText();
await expect(aboutText).toEqual('About');
let join = await findByXpath('//a[@class="registrationLink"]');
let joinText = await join.getText();
await expect(joinText).toEqual('Join Scratch');
let signIn = await findByXpath('//li[@class="link right login-item"]/a');
let signInText = await signIn.getText();
await expect(signInText).toEqual('Sign in');
});
test('create when signed out', async () => {
await clickXpath('//li[@class="link create"]');
let gui = await findByXpath('//div[contains(@class, "gui")]');
let guiVisible = await gui.isDisplayed();
await expect(guiVisible).toBe(true);
});
test('Explore link when signed out', async () => {
await clickXpath('//li[@class="link explore"]');
let banner = await findByXpath('//h1[@class="title-banner-h1"]');
let bannerText = await banner.getText();
await expect(bannerText).toEqual('Explore');
});
test('Ideas link when signed out', async () => {
await clickXpath('//li[@class="link ideas"]');
let banner = await findByXpath('//div[contains(@class, "ideas-banner")]');
let bannerVisible = await banner.isDisplayed();
await expect(bannerVisible).toBe(true);
});
test('About link when signed out', async () => {
await clickXpath('//li[@class="link about"]');
let aboutPage = await findByXpath('//div[@class="inner about"]');
let aboutPageVisible = await aboutPage.isDisplayed();
await expect(aboutPageVisible).toBe(true);
});
test('Search Bar', async () => {
let searchBar = await findByXpath('//div[contains(@class, "search-wrapper")]/div/input');
await searchBar.sendKeys('cat');
await driver.sleep(500); // without it sends an empty string on submit
await searchBar.submit();
let banner = await findByXpath('//h1[@class="title-banner-h1"]');
let bannerText = await banner.getText();
await expect(bannerText).toEqual('Search');
});
test('Scratch Logo', async () => {
await clickXpath('//li[@class="link explore"]');
await findByXpath('//h1[@class="title-banner-h1"]');
await clickXpath('//li[@class="logo"]');
let splash = await findByXpath('//div[@class="splash"]');
let splashVisible = await splash.isDisplayed();
expect(splashVisible).toBe(true);
});
// Sign In is tested in sign-in-and-out tests
// Create Account is tested in Join tests
});

View file

@ -14,7 +14,7 @@ let projectUrl = rootUrl + '/projects/' + projectId;
if (remote){
jest.setTimeout(60000);
} else {
jest.setTimeout(10000);
jest.setTimeout(20000);
}
let driver;

View file

@ -0,0 +1,72 @@
import React from 'react';
import {act} from 'react-dom/test-utils';
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
import AdminPanel from '../../../src/components/adminpanel/adminpanel.jsx';
import {
StudioAdminPanel, adminPanelOpenClass, adminPanelOpenKey
} from '../../../src/views/studio/studio-admin-panel.jsx';
let viewEl;
describe('Studio comments', () => {
beforeAll(() => {
viewEl = global.document.createElement('div');
viewEl.id = 'view';
global.document.body.appendChild(viewEl);
});
beforeEach(() => {
global.localStorage.clear();
viewEl.classList.remove(adminPanelOpenClass);
});
describe('gets stored state from local storage if available', () => {
test('stored as open', () => {
global.localStorage.setItem(adminPanelOpenKey, 'open');
const component = mountWithIntl(<StudioAdminPanel showAdminPanel />);
const child = component.find(AdminPanel);
expect(child.prop('isOpen')).toBe(true);
});
test('stored as closed', () => {
global.localStorage.setItem(adminPanelOpenKey, 'closed');
const component = mountWithIntl(<StudioAdminPanel showAdminPanel />);
const child = component.find(AdminPanel);
expect(child.prop('isOpen')).toBe(false);
});
test('not stored', () => {
const component = mountWithIntl(
<StudioAdminPanel showAdminPanel />
);
const child = component.find(AdminPanel);
expect(child.prop('isOpen')).toBe(false);
});
});
test('calling onOpen sets a class on the #viewEl and records in local storage', () => {
const component = mountWithIntl(<StudioAdminPanel showAdminPanel />);
let child = component.find(AdminPanel);
expect(viewEl.classList.contains(adminPanelOpenClass)).toBe(false);
// `act` is a test-util function for making react state updates sync
act(child.prop('onOpen'));
expect(viewEl.classList.contains(adminPanelOpenClass)).toBe(true);
expect(global.localStorage.getItem(adminPanelOpenKey)).toBe('open');
});
test('renders the correct iframe when open', () => {
global.localStorage.setItem(adminPanelOpenKey, 'open');
const component = mountWithIntl(
<StudioAdminPanel
studioId={123}
showAdminPanel
/>
);
let child = component.find('iframe');
expect(child.getDOMNode().src).toMatch('/scratch2-studios/123/adminpanel');
});
test('responds to closePanel MessageEvent from the iframe', () => {
global.localStorage.setItem(adminPanelOpenKey, 'open');
mountWithIntl(<StudioAdminPanel showAdminPanel />);
expect(viewEl.classList.contains(adminPanelOpenClass)).toBe(true);
act(() => {
global.window.dispatchEvent(new global.MessageEvent('message', {data: 'closePanel'}));
});
expect(viewEl.classList.contains(adminPanelOpenClass)).toBe(false);
expect(global.localStorage.getItem(adminPanelOpenKey)).toBe('closed');
});
});

View file

@ -7,10 +7,14 @@ describe('Studio comments', () => {
const loadComments = jest.fn();
const component = mountWithIntl(
<StudioComments
hasFetchedSession={false}
comments={[]}
handleLoadMoreComments={loadComments}
/>
);
expect(loadComments).not.toHaveBeenCalled();
component.setProps({hasFetchedSession: true});
component.update();
expect(loadComments).toHaveBeenCalled();
// When updated to have comments, load is not called again
@ -30,6 +34,7 @@ describe('Studio comments', () => {
const resetComments = jest.fn();
const component = mountWithIntl(
<StudioComments
hasFetchedSession
isAdmin={false}
comments={[{id: 123, author: {}}]}
handleResetComments={resetComments}
@ -57,6 +62,7 @@ describe('Studio comments', () => {
mountWithIntl(
<StudioComments
isAdmin
hasFetchedSession
comments={[{id: 123, author: {}}]}
handleResetComments={resetComments}
/>

View file

@ -14,7 +14,10 @@ import {
selectCanRemoveCurator,
selectCanRemoveManager,
selectCanPromoteCurators,
selectCanRemoveProject
selectCanRemoveProject,
selectShowProjectMuteError,
selectShowCuratorMuteError,
selectShowEditMuteError
} from '../../../src/redux/studio-permissions';
import {getInitialState as getInitialStudioState} from '../../../src/redux/studio';
@ -51,6 +54,21 @@ const setStateByRole = (role) => {
case 'invited':
state.studio = studios.isInvited;
break;
case 'muted creator':
state.studio = studios.creator1;
state.session = sessions.user1Muted;
break;
case 'muted manager':
state.studio = studios.isManager;
state.session = sessions.user1Muted;
break;
case 'muted curator':
state.studio = studios.isCurator;
state.session = sessions.user1Muted;
break;
case 'muted logged in':
state.session = sessions.user1Muted;
break;
default:
throw new Error('Unknown user role in test: ' + role);
}
@ -72,7 +90,9 @@ describe('studio info', () => {
['creator', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanEditInfo(state)).toBe(expected);
@ -89,7 +109,9 @@ describe('studio projects', () => {
['creator', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanAddProjects(state)).toBe(expected);
@ -100,7 +122,9 @@ describe('studio projects', () => {
test.each([
['logged in', true],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
state.studio.openToAll = true;
@ -116,7 +140,9 @@ describe('studio projects', () => {
['creator', true],
['logged in', false], // false for projects that are not theirs, see below
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanRemoveProject(state, 'not-me', 'not-me')).toBe(expected);
@ -147,7 +173,9 @@ describe('studio comments', () => {
test.each([
['logged in', true],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', true], // comment composer is there, but contains muted ComposeStatus
['muted logged in', true]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectShowCommentComposer(state)).toBe(expected);
@ -158,7 +186,9 @@ describe('studio comments', () => {
test.each([
['logged in', true],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', true],
['muted logged in', true]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanReportComment(state)).toBe(expected);
@ -173,7 +203,9 @@ describe('studio comments', () => {
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanDeleteComment(state)).toBe(expected);
@ -188,7 +220,9 @@ describe('studio comments', () => {
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanDeleteCommentWithoutConfirm(state)).toBe(expected);
@ -203,7 +237,9 @@ describe('studio comments', () => {
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanRestoreComment(state)).toBe(expected);
@ -214,7 +250,9 @@ describe('studio comments', () => {
test.each([
['logged in', true],
['unconfirmed', true],
['logged out', false]
['logged out', false],
['muted creator', true],
['muted logged in', true]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanFollowStudio(state)).toBe(expected);
@ -229,7 +267,9 @@ describe('studio comments', () => {
['creator', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanEditCommentsAllowed(state)).toBe(expected);
@ -244,7 +284,9 @@ describe('studio comments', () => {
['creator', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanEditOpenToAll(state)).toBe(expected);
@ -262,7 +304,9 @@ describe('studio members', () => {
['invited', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectShowCuratorInvite(state)).toBe(expected);
@ -277,7 +321,9 @@ describe('studio members', () => {
['creator', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanPromoteCurators(state)).toBe(expected);
@ -292,7 +338,9 @@ describe('studio members', () => {
['creator', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanRemoveCurator(state, 'others-username')).toBe(expected);
@ -313,7 +361,9 @@ describe('studio members', () => {
['creator', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanRemoveManager(state, '123')).toBe(expected);
@ -327,7 +377,9 @@ describe('studio members', () => {
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
state.studio.owner = 'the creator';
@ -344,10 +396,91 @@ describe('studio members', () => {
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanInviteCurators(state)).toBe(expected);
});
});
});
describe('studio mute errors', () => {
describe('should show projects mute error', () => {
test.each([
['admin', false],
['curator', false],
['manager', false],
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false],
['muted creator', true],
['muted manager', true],
['muted curator', true],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectShowProjectMuteError(state)).toBe(expected);
});
});
describe('should show projects mute error, open to all', () => {
test.each([
['admin', false],
['curator', false],
['manager', false],
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false],
['muted creator', true],
['muted manager', true],
['muted curator', true],
['muted logged in', true]
])('%s: %s', (role, expected) => {
setStateByRole(role);
state.studio.openToAll = true;
expect(selectShowProjectMuteError(state)).toBe(expected);
});
});
describe('should show curators mute error', () => {
test.each([
['admin', false],
['curator', false],
['manager', false],
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false],
// ['muted creator', true], // This one fails; not sure why
['muted manager', true],
['muted curator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectShowCuratorMuteError(state)).toBe(expected);
});
});
describe('should show edit info mute error', () => {
test.each([
['admin', false],
['curator', false],
['manager', false],
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false],
// ['muted creator', true], // This one fails; not sure why
['muted manager', true],
['muted curator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectShowEditMuteError(state)).toBe(expected);
});
});
});