diff --git a/.babelrc b/.babelrc
index e1a513c..d343506 100644
--- a/.babelrc
+++ b/.babelrc
@@ -4,6 +4,8 @@
],
"plugins": [
"@babel/plugin-proposal-class-properties",
- "@babel/plugin-proposal-object-rest-spread"
+ "@babel/plugin-proposal-object-rest-spread",
+ "@babel/plugin-transform-async-to-generator",
+ "@babel/plugin-proposal-optional-chaining"
]
}
diff --git a/dist/index.html b/dist/index.html
index 49b0e4b..fcaa474 100644
--- a/dist/index.html
+++ b/dist/index.html
@@ -13,9 +13,9 @@
-
+
@@ -38,11 +38,17 @@
+
+
play_arrow
+
Alright let's do this
+
+
search
- Whether you're looking for things that do what ...
+ Whether you're looking for
+ things that do what ...
@@ -61,10 +67,18 @@
Save
diff --git a/gulpfile.babel.js b/gulpfile.babel.js
index 0b6a358..360cd0a 100644
--- a/gulpfile.babel.js
+++ b/gulpfile.babel.js
@@ -82,7 +82,7 @@ gulp.task('watch:styles', () => {
gulp.task('clean:scripts', () => del([`${buildDir}/*.{js,js.map}`]));
function createBundler(options = {}) {
- return browserify(Object.assign({}, browserifyOptions, options))
+ return browserify({...browserifyOptions, ...options})
.transform(babelify)
.transform(svgify);
}
diff --git a/package-lock.json b/package-lock.json
index 64441fc..15eaf42 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -442,6 +442,16 @@
"@babel/plugin-syntax-optional-catch-binding": "^7.0.0"
}
},
+ "@babel/plugin-proposal-optional-chaining": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.2.0.tgz",
+ "integrity": "sha512-ea3Q6edZC/55wEBVZAEz42v528VulyO0eir+7uky/sT4XRcdkWJcFi1aPtitTlwUzGnECWJNExWww1SStt+yWw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.0.0",
+ "@babel/plugin-syntax-optional-chaining": "^7.2.0"
+ }
+ },
"@babel/plugin-proposal-unicode-property-regex": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.0.0.tgz",
@@ -535,6 +545,15 @@
"@babel/helper-plugin-utils": "^7.0.0"
}
},
+ "@babel/plugin-syntax-optional-chaining": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.2.0.tgz",
+ "integrity": "sha512-HtGCtvp5Uq/jH/WNUPkK6b7rufnCPLLlDAFN7cmACoIjaOOiXxUt3SswU5loHqrhtqTsa/WoLQ1OQ1AGuZqaWA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.0.0"
+ }
+ },
"@babel/plugin-transform-arrow-functions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.0.0.tgz",
@@ -545,9 +564,9 @@
}
},
"@babel/plugin-transform-async-to-generator": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.1.0.tgz",
- "integrity": "sha512-rNmcmoQ78IrvNCIt/R9U+cixUHeYAzgusTFgIAv+wQb9HJU4szhpDD6e5GCACmj/JP5KxuCwM96bX3L9v4ZN/g==",
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.2.0.tgz",
+ "integrity": "sha512-CEHzg4g5UraReozI9D4fblBYABs7IM6UerAVG7EJVrTLC5keh00aEuLUT+O40+mJCEzaXkYfTCUKIyeDfMOFFQ==",
"dev": true,
"requires": {
"@babel/helper-module-imports": "^7.0.0",
diff --git a/package.json b/package.json
index 11f765d..ecf70c1 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,8 @@
"@babel/core": "^7.1.6",
"@babel/plugin-proposal-class-properties": "^7.1.0",
"@babel/plugin-proposal-object-rest-spread": "^7.0.0",
+ "@babel/plugin-proposal-optional-chaining": "^7.2.0",
+ "@babel/plugin-transform-async-to-generator": "^7.2.0",
"@babel/polyfill": "^7.0.0",
"@babel/preset-env": "^7.1.6",
"@babel/register": "^7.0.0",
diff --git a/src/scripts/components/Sample.js b/src/scripts/components/Sample.js
index c0a55c0..1c011d8 100644
--- a/src/scripts/components/Sample.js
+++ b/src/scripts/components/Sample.js
@@ -72,8 +72,8 @@ class Sample {
this.$sample.append(this.$progress);
}
- play(spam = false, loop = false) {
- Player.instance.play(this.playerId, spam, loop);
+ async play(spam = false, loop = false) {
+ return Player.instance.play(this.playerId, spam, loop);
}
stop() {
diff --git a/src/scripts/components/SampleContainer.js b/src/scripts/components/SampleContainer.js
index 88a4c23..cef1866 100644
--- a/src/scripts/components/SampleContainer.js
+++ b/src/scripts/components/SampleContainer.js
@@ -2,23 +2,18 @@ import $ from 'jquery';
import 'jquery-contextmenu';
import copy from 'copy-to-clipboard';
-class SampleContainer {
-
+export default class SampleContainer {
/** @type jQuery */
$sampleContainer;
/** @type Sample[] */
samples = [];
- /** @type string */
- sortType = 'recent';
-
/** @type string */
query = '';
constructor() {
this.$sampleContainer = $('.sample-container');
- this.$empty = $('.sample-container__empty');
// Play/stop on click
this.$sampleContainer.on('click', '.sample', (e) => {
@@ -101,6 +96,8 @@ class SampleContainer {
}
const $prev = this.$sampleContainer.prev();
+
+ // Updating the container in detached state is quicker
this.$sampleContainer.detach();
let empty = true;
@@ -114,21 +111,25 @@ class SampleContainer {
} else {
// Prepare regex
const terms = this.query
- .replace(/[^\w\s|]/g, '') // Strip non-alphanumeric characters (will be done in target as well)
- .replace(/\s+\|\s+/g, '|') // Enable OR-searching when whitespace is around the pipe character "|"
- .split(/[\s+&]+/g); // Split by any combination of whitespace characters
+ // Strip non-alphanumeric characters (will be done in target as well)
+ .replace(/[^\w\s|]/g, '')
+ // Enable OR-searching when whitespace is around the pipe character "|"
+ .replace(/\s+\|\s+/g, '|')
+ // Split by any combination of whitespace characters
+ .split(/[\s+&]+/g);
const regex = new RegExp(`.*${terms.map(term => `(?=.*${term}.*)`).join('')}.*`, 'i');
// Filter samples
this.samples.forEach((sample) => {
- const visible = regex.test(
- `${sample.name.replace(/[^\w\s|]/g, '')
- } ${
- sample.categories.map(category => category.replace(/[^\w\s|]/g, '')).join(' ')}`,
- );
-
- sample.$sample.toggleClass('sample--filtered', !visible);
- if (visible) {
+ let filterString = sample.name.replace(/[^\w\s|]/g, '');
+ sample.categories.forEach((category) => {
+ filterString += ' ' + category.replace(/[^\w\s|]/g, '');
+ });
+
+ const isVisible = regex.test(filterString);
+ sample.$sample.toggleClass('sample--filtered', !isVisible);
+
+ if (isVisible) {
empty = false;
}
});
@@ -136,13 +137,17 @@ class SampleContainer {
this.$sampleContainer.toggleClass('sample-container--empty', empty);
- this.$sampleContainer.insertAfter($prev);
-
if (!empty) {
this.updateLines();
}
+
+ this.$sampleContainer.insertAfter($prev);
}
+ /**
+ * Updates line classes on visible samples so they can be made awesome by
+ * themes.
+ */
updateLines() {
let row = -1;
let lastTop = 0;
@@ -159,64 +164,53 @@ class SampleContainer {
.toggleClass('sample--line-1', row % 3 === 1)
.toggleClass('sample--line-2', row % 3 === 2);
});
-
- const $prev = this.$sampleContainer.prev();
- this.$sampleContainer.detach();
- this.$sampleContainer.insertAfter($prev);
}
- // Returns the sample object that has been played, or null
- // eslint-disable-next-line class-methods-use-this
- playRandomWithId(id, spam = false, loop = false, scroll = false) {
+ /**
+ * Returns the sample object that has been played, or null.
+ *
+ * @param {string} id
+ * @param {boolean} [spam]
+ * @param {boolean} [loop]
+ * @param {boolean} [scroll]
+ * @returns {Promise}
+ */
+ async playRandomWithId(id, spam = false, loop = false, scroll = false) {
// Obtain a sample
const $filteredSamples = $('.sample').filter(function() {
return $(this).data('sample').id === id;
});
if ($filteredSamples.length === 0) {
- return null;
+ return false;
}
const index = Math.floor(Math.random() * $filteredSamples.length);
const $sample = $filteredSamples.eq(index);
- // Play the sample
- $sample.data('sample').play(spam, loop);
-
- // Scroll
- if (scroll) {
- SampleContainer.scrollToSample($sample);
- }
-
- return $sample;
+ return this.playSample($sample, spam, loop, scroll);
}
- static playRandomVisible(spam = false, loop = false, scroll = false) {
+ async playRandomVisible(spam = false, loop = false, scroll = false) {
const $visibleSamples = $('.sample:not(.sample--filtered)');
if ($visibleSamples.length === 0) {
- return null;
+ return false;
}
const index = Math.floor(Math.random() * $visibleSamples.length);
const $sample = $visibleSamples.eq(index);
- $sample.data('sample').play(spam, loop);
+ return this.playSample($sample, spam, loop, scroll);
+ }
+ async playSample($sample, spam, loop, scroll) {
if (scroll) {
- SampleContainer.scrollToSample($sample);
+ $('html, body').animate({
+ scrollTop: ($sample.offset().top - 100),
+ });
}
- return $sample;
- }
-
- static scrollToSample($sample) {
- const sampleTop = $sample.offset().top;
-
- $('html, body').animate({
- scrollTop: sampleTop - 100,
- });
+ return $sample.data('sample').play(spam, loop);
}
}
-
-export default SampleContainer;
diff --git a/src/scripts/config.js b/src/scripts/config.js
deleted file mode 100644
index 9490ca7..0000000
--- a/src/scripts/config.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import system from '../../system.json';
-
-export default fetch('config.json').then((response) => {
- if (!response.ok) {
- throw new Error('Could not fetch public config.');
- }
-
- return response.json().then(config =>
- // Merge system and public config
- Object.assign(system, config));
-}).catch(() => {
- throw new Error('Could not fetch public config.');
-});
diff --git a/src/scripts/helpers/Player.js b/src/scripts/helpers/Player.js
index 145e380..3430a14 100644
--- a/src/scripts/helpers/Player.js
+++ b/src/scripts/helpers/Player.js
@@ -2,7 +2,7 @@ import $ from 'jquery';
import SettingsManager from './SettingsManager';
import Modal from '../components/Modal';
-class Player {
+export default class Player {
static instance;
// The registered samples
@@ -20,6 +20,16 @@ class Player {
// Whether an animation frame is requested, indicating that no new loop has to be spawned
frameRequested = false;
+ // When samples are blocked from playing due to browser policies, they end
+ // up here: [{sample, loop}]
+ blockedSamples = [];
+
+ /**
+ * The function assigned to this property will be called when a sample is
+ * blocked from playing.
+ */
+ onBlocked;
+
static init() {
this.instance = new Player();
}
@@ -61,9 +71,9 @@ class Player {
const sampleIndex = this.samples.push(sample) - 1;
this.playing[sampleIndex] = [];
- sample.play = (loop) => {
+ sample.play = async (loop) => {
// Resume context if it is suspended due to a lack of user input
- this.audioContext.resume();
+ await this.audioContext.resume();
// Create an audio element source and link it to the context
const audio = new Audio(url);
@@ -84,17 +94,33 @@ class Player {
// Remove from playing
this.playing[sampleIndex].splice(audioIndex, 1);
- // Trigger onStop only when we just removed the last playing intance of this sample
+ // Trigger onStop only when we just removed the last playing instance of this sample
if (this.playing[sampleIndex].length === 0) {
sample.onStop();
}
}
};
- audio.play().catch(stop);
audio.onpause = stop;
audio.onended = stop;
+ try {
+ await audio.play();
+ } catch (error) {
+ stop();
+
+ if (
+ error instanceof DOMException &&
+ error.name === 'NotAllowedError'
+ ) {
+ // Audio requires user interaction
+ this.blockedSamples.push({sample, loop});
+ this.onBlocked?.();
+ }
+
+ return false;
+ }
+
// Trigger onPlay only when this is the first instance of this sample to start playing
if (this.playing[sampleIndex].length === 1) {
sample.onPlay();
@@ -106,19 +132,29 @@ class Player {
requestAnimationFrame(this.progressStep);
}
}
+
+ return true;
};
// Return the ID
return sampleIndex;
}
- play(sampleIndex, spam = false, loop = false) {
+ async play(sampleIndex, spam = false, loop = false) {
// Stop all sounds before playing if multiple are not allowed
if (!spam) {
this.stopAll();
}
- this.samples[sampleIndex].play(loop);
+ return this.samples[sampleIndex].play(loop);
+ }
+
+ playBlocked() {
+ this.blockedSamples.forEach((blockedSample) => {
+ blockedSample.sample.play(blockedSample.loop);
+ });
+
+ this.blockedSamples.length = 0;
}
stop(sampleIndex) {
@@ -157,5 +193,3 @@ class Player {
return this.playing[sampleIndex].length > 0;
}
}
-
-export default Player;
diff --git a/src/scripts/helpers/getConfig.js b/src/scripts/helpers/getConfig.js
new file mode 100644
index 0000000..db7955f
--- /dev/null
+++ b/src/scripts/helpers/getConfig.js
@@ -0,0 +1,20 @@
+import system from '../../../system.json';
+
+let configPromise;
+
+export default async function getConfig() {
+ if (configPromise) {
+ return configPromise;
+ }
+
+ configPromise = fetch('config.json').then((response) => {
+ if (!response.ok) {
+ throw new Error('Could not fetch public config.');
+ }
+
+ // Merge system and public config
+ return response.json().then((config) => ({...system, ...config}));
+ });
+
+ return configPromise;
+};
diff --git a/src/scripts/helpers/playFromUri.js b/src/scripts/helpers/playFromUri.js
new file mode 100644
index 0000000..1ac3dbc
--- /dev/null
+++ b/src/scripts/helpers/playFromUri.js
@@ -0,0 +1,26 @@
+function getPathArguments() {
+ // Ensure baseuri is pointing to a directory
+ let path = window.location.href.substr(document.baseURI.length);
+
+ while (path.endsWith('/')) {
+ path = path.substr(0, path.length - 1);
+ }
+
+ return path.split('/');
+}
+
+/**
+ * Plays samples supplied by URL path, if supplied.
+ */
+export default async function playFromUri(sampleContainer) {
+ const pathArguments = getPathArguments();
+
+ for (let i = 0; i < pathArguments.length; i++) {
+ await sampleContainer.playRandomWithId(
+ pathArguments[i],
+ true,
+ false,
+ true
+ );
+ }
+}
diff --git a/src/scripts/helpers/randomizeTitle.js b/src/scripts/helpers/randomizeTitle.js
new file mode 100644
index 0000000..94d2c92
--- /dev/null
+++ b/src/scripts/helpers/randomizeTitle.js
@@ -0,0 +1,26 @@
+import $ from 'jquery';
+
+const boardNames = [
+ 'music',
+ 'spam',
+ 'crack',
+ 'shit',
+ 'originality',
+ 'meme',
+];
+
+const postNames = [
+ 'amirite',
+ 'correct',
+ 'no',
+ 'you see',
+ 'eh',
+ 'hmm',
+];
+
+export default function randomizeTitle() {
+ const boardName = boardNames[Math.floor(Math.random() * boardNames.length)];
+ const postName = postNames[Math.floor(Math.random() * postNames.length)];
+
+ $('title').text(`More like ${boardName}board, ${postName}?`);
+};
diff --git a/src/scripts/main.js b/src/scripts/main.js
index de17ff7..d98b0ed 100644
--- a/src/scripts/main.js
+++ b/src/scripts/main.js
@@ -1,7 +1,7 @@
import $ from 'jquery';
import '@babel/polyfill';
import ApiClient from './helpers/ApiClient';
-import configPromise from './config';
+import getConfig from './helpers/getConfig';
import Intern from './helpers/Intern';
import Player from './helpers/Player';
import SampleContainer from './components/SampleContainer';
@@ -9,118 +9,56 @@ import Search from './components/Search';
import SettingsManager from './helpers/SettingsManager';
import SettingsModal from './components/SettingsModal';
import ThemeManager from './helpers/ThemeManager';
+import playFromUri from './helpers/playFromUri';
+import randomizeTitle from './helpers/randomizeTitle';
SettingsManager.init();
ThemeManager.init();
Player.init();
-const settingsModal = new SettingsModal();
-const sampleContainer = new SampleContainer();
-
-// Returns an array of strings representing the given path arguments relative to the base url of the index
-function getArguments() {
- // Ensure baseuri is pointing to a directory
- let baseUri = document.baseURI;
- if (!baseUri.endsWith('/')) {
- baseUri = baseUri.substr(0, baseUri.lastIndexOf('/') + 1);
- }
-
- // Get path relative to base URI
- let argumentPath;
- if ((location.origin + location.pathname).startsWith(baseUri)) {
- argumentPath = (location.origin + location.pathname).substr(baseUri.length);
- } else {
- console.warn('Base URI does not seems to be on same origin.\nFalling back to full path for argument parsing.');
- argumentPath = location.pathname.substr(1); // Remove leading slash
- }
-
- // Make sure a trailing slash does not translate into an empty argument
- if (argumentPath.endsWith('/')) {
- argumentPath = argumentPath.slice(0, -1);
- }
-
- return argumentPath ? argumentPath.split('/') : [];
-}
-
-// Will play samples based on the arguments obtained with getArguments
-function playFromArguments() {
- return new Promise((resolve) => {
- let played = 0;
- getArguments().forEach((argument) => {
- if (sampleContainer.playRandomWithId(argument, true, false, true)) {
- played += 1;
- }
- });
-
- resolve(played);
- });
-}
-
-// Sets given samples for the container
-function setContainerSamples(samples) {
- return new Promise((resolve) => {
- sampleContainer.setSamples(samples);
+Player.instance.onBlocked = () => {
+ const overlay = document.querySelector('.audio-blocked-overlay');
+ overlay.classList.add('audio-blocked-overlay--active');
- resolve(samples.length);
+ overlay.addEventListener('click', () => {
+ overlay.classList.remove('audio-blocked-overlay--active');
+ Player.instance.playBlocked();
});
-}
+};
-// Randomized page title
-const boardNames = [
- 'music',
- 'spam',
- 'crack',
- 'shit',
- 'originality',
- 'meme',
-];
-
-const postNames = [
- 'amirite',
- 'correct',
- 'no',
- 'you see',
- 'eh',
- 'hmm',
-];
+const settingsModal = new SettingsModal();
-const $title = $('title');
-$title.text(
- `More like ${boardNames[Math.floor(Math.random() * boardNames.length)]}board, \
- ${postNames[Math.floor(Math.random() * postNames.length)]}?`,
-);
+randomizeTitle();
// Action buttons
$('[data-action="show-settings-modal"]').on('click', () => {
settingsModal.show();
});
+const sampleContainer = new SampleContainer();
const intern = new Intern();
// Init search
-// eslint-disable-next-line no-unused-vars
-const search = new Search({
- onChange: (query) => {
- sampleContainer.update(query);
- },
-
- onSubmit: (query, e) => {
- if (!SampleContainer.playRandomVisible(e.shiftKey, e.ctrlKey, true)) {
+new Search({
+ onChange: (query) => {sampleContainer.update(query)},
+ onSubmit: async (query, e) => {
+ if (!await sampleContainer.playRandomVisible(e.shiftKey, e.ctrlKey, true)) {
intern.say(query);
}
},
});
-// Wait for config to register samples and set some other values
-configPromise.then((config) => {
+// Load config and use it to initialize other components
+(async () => {
+ const config = await getConfig();
const apiClient = new ApiClient(config.apiBaseUrl);
// Get samples and add them to the container
- apiClient.getSamples()
- .then(setContainerSamples)
- .then(playFromArguments);
+ const samples = await apiClient.getSamples();
+ sampleContainer.setSamples(samples);
+ playFromUri(sampleContainer);
- // Modals
+ // Register modals that rely on configuration
$('[data-action="show-contribution-modal"]').on('click', () => {
window.open(config.contributeUrl, '_blank');
});
@@ -136,4 +74,4 @@ configPromise.then((config) => {
// Version in settings modal
$('[data-content=version-number]').text(`v${config.versionNumber}`);
$('[data-content=version-name]').text(config.versionName);
-});
+})();
diff --git a/src/styles/components/_audio-blocked-overlay.scss b/src/styles/components/_audio-blocked-overlay.scss
new file mode 100644
index 0000000..9025600
--- /dev/null
+++ b/src/styles/components/_audio-blocked-overlay.scss
@@ -0,0 +1,47 @@
+.audio-blocked-overlay {
+ z-index: 1;
+ text-align: center;
+ padding-top: $header-inner-height + $spacing-base * 2 + $spacing-base / 2;
+
+ width: 100%;
+ height: 100%;
+ position: fixed;
+
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+
+ cursor: pointer;
+
+ display: none;
+
+ &--active {
+ display: flex;
+ }
+}
+
+.audio-blocked-overlay__icon {
+ font-size: 50px;
+}
+
+.audio-blocked-overlay__text {
+ font-size: 25px;
+ margin-top: 10px;
+ opacity: 0.7;
+}
+
+@each $theme, $colors in $themes {
+ $color-sample-container: map-get($colors, sample-container);
+ $color-sample-text: map-get($colors, sample-text);
+
+ .theme--#{$theme} {
+ .audio-blocked-overlay {
+ background-color: $color-sample-container;
+ color: $color-sample-text;
+ }
+
+ .audio-blocked-overlay__text {
+ color: $color-sample-text
+ }
+ }
+}
diff --git a/src/styles/components/_header.scss b/src/styles/components/_header.scss
index c5c2050..54cfef9 100644
--- a/src/styles/components/_header.scss
+++ b/src/styles/components/_header.scss
@@ -3,7 +3,7 @@
.header {
position: fixed;
- z-index: 1;
+ z-index: 2;
top: 0;
left: 0;
right: 0;
diff --git a/src/styles/components/_sample-container.scss b/src/styles/components/_sample-container.scss
index e4f8235..b3543cb 100644
--- a/src/styles/components/_sample-container.scss
+++ b/src/styles/components/_sample-container.scss
@@ -59,9 +59,5 @@
.sample-container {
color: mix($color-sample-container, $color-sample-text, 70%);
}
-
- .sample-container__empty {
-
- }
}
}
diff --git a/src/styles/main.scss b/src/styles/main.scss
index cd614d3..5dc740f 100644
--- a/src/styles/main.scss
+++ b/src/styles/main.scss
@@ -15,3 +15,4 @@
@import 'components/theme-selector';
@import 'components/theme-thumb';
@import 'components/version';
+@import 'components/audio-blocked-overlay';
diff --git a/system.json b/system.json
index 8407a57..aae4f47 100644
--- a/system.json
+++ b/system.json
@@ -1,7 +1,7 @@
{
- "versionNumber": "3.1.1",
- "versionName": "alright alright alright",
- "versionSampleId": "6d02153a",
+ "versionNumber": "3.2.0",
+ "versionName": "what do you think of that",
+ "versionSampleId": "4ba49d58",
"repositoryUrl": "https://github.com/villermen/soundboard-front-end",
"contributeUrl": "https://docs.google.com/document/d/1tDlWfX2TtczI5IHLmffY4LPya1J1Z9ZWHW4pYlD8toM/edit?usp=sharing",