Browse Source

Fix line endings, update bot code to build

ghorsington 3 years ago
parent
commit
5d6d5fc554
73 changed files with 2753 additions and 2757 deletions
  1. 19 19
      .dockerignore
  2. 14 14
      .env.template
  3. 38 38
      .vscode/launch.json
  4. 15 15
      .vscode/tasks.json
  5. 23 23
      Makefile
  6. 15 15
      bot/ormconfig.json
  7. 266 266
      bot/src/bbcode-parser/bbcode-js.ts
  8. 486 486
      bot/src/commands/contest.ts
  9. 10 10
      bot/src/commands/facemorph.ts
  10. 36 36
      bot/src/commands/file_only_channel_checker.ts
  11. 5 5
      bot/src/commands/forums_news_checker.ts
  12. 6 6
      bot/src/commands/news_aggregator.ts
  13. 34 34
      bot/src/commands/random_react.ts
  14. 4 4
      bot/src/commands/react.ts
  15. 0 4
      bot/src/main.ts
  16. 56 56
      bot/src/model/command.ts
  17. 51 51
      bot/src/rpc_service.ts
  18. 31 31
      bot/src/typedefs/html2bbcode.d.ts
  19. 1 1
      bot/src/util.ts
  20. 7 7
      db.env.template
  21. 37 37
      shared/src/db/entities.ts
  22. 23 23
      shared/src/db/entity/AggroNewsItem.ts
  23. 41 41
      shared/src/db/entity/Contest.ts
  24. 17 17
      shared/src/db/entity/ContestEntry.ts
  25. 20 20
      shared/src/db/entity/ContestVote.ts
  26. 11 11
      shared/src/db/entity/DeadChatReply.ts
  27. 16 16
      shared/src/db/entity/FaceCaptionMessage.ts
  28. 10 10
      shared/src/db/entity/FileOnlyChannel.ts
  29. 40 40
      shared/src/db/entity/Guide.ts
  30. 17 17
      shared/src/db/entity/KnownChannel.ts
  31. 24 24
      shared/src/db/entity/KnownUser.ts
  32. 11 11
      shared/src/db/entity/MessageReaction.ts
  33. 26 26
      shared/src/db/entity/PostVerifyMessage.ts
  34. 14 14
      shared/src/db/entity/Quote.ts
  35. 17 17
      shared/src/db/entity/RandomMesssageReaction.ts
  36. 19 19
      shared/src/db/entity/ReactionEmote.ts
  37. 10 10
      shared/src/rpc/backend.ts
  38. 6 6
      web/.gitignore
  39. 26 26
      web/Dockerfile
  40. 3 3
      web/cypress.json
  41. 4 4
      web/cypress/fixtures/example.json
  42. 18 18
      web/cypress/integration/spec.js
  43. 17 17
      web/cypress/plugins/index.js
  44. 25 25
      web/cypress/support/commands.js
  45. 20 20
      web/cypress/support/index.js
  46. 64 64
      web/package.json
  47. 131 131
      web/rollup.config.js
  48. 4 4
      web/src/client.js
  49. 54 54
      web/src/components/Nav.svelte
  50. 40 40
      web/src/routes/_error.svelte
  51. 16 16
      web/src/routes/_layout.svelte
  52. 6 6
      web/src/routes/about.svelte
  53. 48 48
      web/src/routes/dashboard/_layout.svelte
  54. 66 66
      web/src/routes/dashboard/contest/_components/AddContestForm.svelte
  55. 53 53
      web/src/routes/dashboard/contest/_components/ContestEntry.svelte
  56. 23 23
      web/src/routes/dashboard/contest/_components/ContestTable.svelte
  57. 26 26
      web/src/routes/dashboard/contest/_components/DurationInput.svelte
  58. 55 55
      web/src/routes/dashboard/contest/_components/EmojiSelector.svelte
  59. 63 63
      web/src/routes/dashboard/contest/index.svelte
  60. 8 8
      web/src/routes/dashboard/index.svelte
  61. 14 14
      web/src/routes/index.svelte
  62. 71 71
      web/src/routes/login/discord/callback.ts
  63. 9 9
      web/src/routes/login/discord/do.ts
  64. 60 60
      web/src/routes/login/index.svelte
  65. 52 52
      web/src/server.ts
  66. 82 82
      web/src/service-worker.js
  67. 76 76
      web/src/style/main.css
  68. 35 35
      web/src/template.html
  69. 38 38
      web/src/typedefs/sapper.d.ts
  70. 5 5
      web/src/util/rpc_client.ts
  71. 20 20
      web/static/manifest.json
  72. 11 11
      web/tailwind.config.js
  73. 34 34
      web/tsconfig.json

+ 19 - 19
.dockerignore

@@ -1,20 +1,20 @@
-**/node_modules
-**/npm-debug.log
-**/Dockerfile*
-data/
-bot/build
-docker-compose*
-.dockerignore
-.git
-.gitignore
-.env
-*/bin
-*/obj
-README.md
-LICENSE
-.vscode
-__sapper__/
-.rpt2_cache/
-*.sql
-*.sqlite
+**/node_modules
+**/npm-debug.log
+**/Dockerfile*
+data/
+bot/build
+docker-compose*
+.dockerignore
+.git
+.gitignore
+.env
+*/bin
+*/obj
+README.md
+LICENSE
+.vscode
+__sapper__/
+.rpt2_cache/
+*.sql
+*.sqlite
 db*.json

+ 14 - 14
.env.template

@@ -1,15 +1,15 @@
-BOT_TOKEN=
-FORUM_PASS=
-FORUM_API_KEY=
-IGNORE_CHANGED_NEWS=
-GOOGLE_APPLICATION_CREDENTIALS=gcloud_key.json
-GOOGLE_APP_ID=
-
-DB_USERNAME=
-DB_PASSWORD=
-DB_NAME=
-
-BOT_CLIENT_ID=
-BOT_CLIENT_SECRET=
-
+BOT_TOKEN=
+FORUM_PASS=
+FORUM_API_KEY=
+IGNORE_CHANGED_NEWS=
+GOOGLE_APPLICATION_CREDENTIALS=gcloud_key.json
+GOOGLE_APP_ID=
+
+DB_USERNAME=
+DB_PASSWORD=
+DB_NAME=
+
+BOT_CLIENT_ID=
+BOT_CLIENT_SECRET=
+
 ADMIN_URL=

+ 38 - 38
.vscode/launch.json

@@ -1,39 +1,39 @@
-{
-    // Use IntelliSense to learn about possible attributes.
-    // Hover to view descriptions of existing attributes.
-    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
-    "version": "0.2.0",
-    "configurations": [
-        {
-            "type": "node",
-            "request": "launch",
-            "name": "Launch NoctBot",
-            "program": "${workspaceFolder}/bot/src/main.ts",
-            "preLaunchTask": "tsc-build",
-            "cwd": "${workspaceFolder}/bot",
-            "outFiles": [
-                "${workspaceFolder}/bot/build/**/*.js"
-            ],
-            "env": {
-                "NODE_ENV": "dev"
-            }
-        },
-        {
-            "type": "node",
-            "request": "launch",
-            "name": "Launch WebServer",
-            "program": "${workspaceFolder}/web/node_modules/sapper/dist/cli.js",
-            "args": [
-                "dev"
-            ],
-            "cwd": "${workspaceFolder}/web",
-            "autoAttachChildProcesses": true
-        }
-    ],
-    "compounds": [
-        {
-            "name": "Bot+WebServer",
-            "configurations": ["Launch NoctBot", "Launch WebServer"]
-        }
-    ]
+{
+    // Use IntelliSense to learn about possible attributes.
+    // Hover to view descriptions of existing attributes.
+    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+    "version": "0.2.0",
+    "configurations": [
+        {
+            "type": "node",
+            "request": "launch",
+            "name": "Launch NoctBot",
+            "program": "${workspaceFolder}/bot/src/main.ts",
+            "preLaunchTask": "tsc-build",
+            "cwd": "${workspaceFolder}/bot",
+            "outFiles": [
+                "${workspaceFolder}/bot/build/**/*.js"
+            ],
+            "env": {
+                "NODE_ENV": "dev"
+            }
+        },
+        {
+            "type": "node",
+            "request": "launch",
+            "name": "Launch WebServer",
+            "program": "${workspaceFolder}/web/node_modules/sapper/dist/cli.js",
+            "args": [
+                "dev"
+            ],
+            "cwd": "${workspaceFolder}/web",
+            "autoAttachChildProcesses": true
+        }
+    ],
+    "compounds": [
+        {
+            "name": "Bot+WebServer",
+            "configurations": ["Launch NoctBot", "Launch WebServer"]
+        }
+    ]
 }

+ 15 - 15
.vscode/tasks.json

@@ -1,16 +1,16 @@
-{
-    // See https://go.microsoft.com/fwlink/?LinkId=733558 
-    // for the documentation about the tasks.json format
-    "version": "2.0.0",
-    "tasks": [
-        {
-            "label": "tsc-build",
-            "command": ["npm"],
-            "args": ["run", "build"],
-            "type": "shell",
-            "options": {
-                "cwd": "${workspaceFolder}/bot"
-            }
-        },
-    ]
+{
+    // See https://go.microsoft.com/fwlink/?LinkId=733558 
+    // for the documentation about the tasks.json format
+    "version": "2.0.0",
+    "tasks": [
+        {
+            "label": "tsc-build",
+            "command": ["npm"],
+            "args": ["run", "build"],
+            "type": "shell",
+            "options": {
+                "cwd": "${workspaceFolder}/bot"
+            }
+        },
+    ]
 }

+ 23 - 23
Makefile

@@ -1,24 +1,24 @@
-
-build_shared:
-	cd shared && npm run build
-
-build_bot: build_shared
-	cd bot && npm run build
-
-build_web: build_shared
-	cd web && npm run build
-
-build:
-	docker-compose build
-
-start_db: build
-	docker-compose up db adminer
-
-start: build
-	docker-compose up db adminer noctbot web
-
-start_bot: build
-	docker-compose up db adminer noctbot
-
-start_web: build
+
+build_shared:
+	cd shared && npm run build
+
+build_bot: build_shared
+	cd bot && npm run build
+
+build_web: build_shared
+	cd web && npm run build
+
+build:
+	docker-compose build
+
+start_db: build
+	docker-compose up db adminer
+
+start: build
+	docker-compose up db adminer noctbot web
+
+start_bot: build
+	docker-compose up db adminer noctbot
+
+start_web: build
 	docker-compose up db adminer web

+ 15 - 15
bot/ormconfig.json

@@ -1,16 +1,16 @@
-{
-   "entities": [
-      "build/entity/**/*.js"
-   ],
-   "migrations": [
-      "build/migration/**/*.js"
-   ],
-   "subscribers": [
-      "build/subscriber/**/*.js"
-   ],
-   "cli": {
-      "entitiesDir": "src/entity",
-      "migrationsDir": "src/migration",
-      "subscribersDir": "src/subscriber"
-   }
+{
+   "entities": [
+      "build/entity/**/*.js"
+   ],
+   "migrations": [
+      "build/migration/**/*.js"
+   ],
+   "subscribers": [
+      "build/subscriber/**/*.js"
+   ],
+   "cli": {
+      "entitiesDir": "src/entity",
+      "migrationsDir": "src/migration",
+      "subscribersDir": "src/subscriber"
+   }
 }

+ 266 - 266
bot/src/bbcode-parser/bbcode-js.ts

@@ -1,266 +1,266 @@
-import { Dict } from "src/util";
-
-var VERSION = '0.4.0';
-
-export interface BBCodeConfig {
-    showQuotePrefix?: boolean;
-    classPrefix?: string;
-    mentionPrefix?: string;
-}
-
-// default options
-const defaults: BBCodeConfig = {
-    showQuotePrefix: true,
-    classPrefix: 'bbcode_',
-    mentionPrefix: '@'
-};
-
-export var version = VERSION;
-
-// copied from here:
-// http://blog.mattheworiordan.com/post/13174566389/url-regular-expression-for-links-with-or-without-the had to make an
-// update to allow / in the query string, since some sites will have a / there made another update to support colons in
-// the query string made another update to disallow an ending dot(.)
-var URL_PATTERN = new RegExp("(" // overall match
-    + "(" // brackets covering match for protocol (optional) and domain
-    + "([A-Za-z]{3,9}:(?:\\/\\/)?)" // allow something@ for email addresses
-    + "(?:[\\-;:&=\\+\\$,\\w]+@)?[A-Za-z0-9\\.\\-]+[A-Za-z0-9\\-]"
-    // anything looking at all like a domain, non-unicode domains
-    + "|" // or instead of above
-    + "(?:www\\.|[\\-;:&=\\+\\$,\\w]+@)" // starting with something@ or www.
-    + "[A-Za-z0-9\\.\\-]+[A-Za-z0-9\\-]" // anything looking at all like a domain
-    + ")" // end protocol/domain
-    + "(" // brackets covering match for path, query string and anchor
-    + "(?:\\/[\\+~%\\/\\.\\w\\-_]*)?" // allow optional /path
-    + "\\??(?:[\\-\\+=&;%@\\.\\w_\\/:]*)" // allow optional query string starting with ?
-    + "#?(?:[\\.\\!\\/\\\\\\w]*)" // allow optional anchor #anchor
-    + ")?" // make URL suffix optional
-    + ")");
-
-function doReplace(content: string, matches: Replacement[], options: BBCodeConfig) {
-    var i, obj, regex, hasMatch, tmp;
-    // match/replace until we don't change the input anymore
-    do {
-        hasMatch = false;
-        for (i = 0; i < matches.length; ++i) {
-            obj = matches[i];
-            regex = new RegExp(obj.e, 'gi');
-            tmp = content.replace(regex, obj.func.bind(undefined, options));
-            if (tmp !== content) {
-                content = tmp;
-                hasMatch = true;
-            }
-        }
-    } while (hasMatch);
-    return content;
-}
-
-function listItemReplace(options: BBCodeConfig, fullMatch: string, tag: string, value: string) {
-    return '<li>' + doReplace(value.trim(), BBCODE_PATTERN, options) + '</li>';
-}
-
-export var extractQuotedText = function (value: string, parts?: string[]) {
-    var quotes = ["\"", "'"], i, quote, nextPart;
-
-    for (i = 0; i < quotes.length; ++i) {
-        quote = quotes[i];
-        if (value && value[0] === quote) {
-            value = value.slice(1);
-            if (value[value.length - 1] !== quote) {
-                while (parts && parts.length) {
-                    nextPart = parts.shift();
-                    value += " " + nextPart;
-                    if (nextPart[nextPart.length - 1] === quote) {
-                        break;
-                    }
-                }
-            }
-            value = value.replace(new RegExp("[" + quote + "]+$"), '');
-            break;
-        }
-    }
-    return [value, parts];
-};
-
-export var parseParams = function (tagName: string, params: string) {
-    let paramMap: Dict<string> = {};
-
-    if (!params) {
-        return paramMap;
-    }
-
-    // first, collapse spaces next to equals
-    params = params.replace(/\s*[=]\s*/g, "=");
-    let parts = params.split(/\s+/);
-
-    while (parts.length) {
-        let part = parts.shift();
-        // check if the param itself is a valid url
-        if (!URL_PATTERN.exec(part)) {
-            let index = part.indexOf('=');
-            if (index > 0) {
-                let rv = extractQuotedText(part.slice(index + 1), parts);
-                paramMap[part.slice(0, index).toLowerCase()] = rv[0] as string;
-                parts = rv[1] as string[];
-            }
-            else {
-                let rv = extractQuotedText(part, parts);
-                paramMap[tagName] = rv[0] as string;
-                parts = rv[1] as string[];
-            }
-        } else {
-            let rv = extractQuotedText(part, parts);
-            paramMap[tagName] = rv[0] as string;
-            parts = rv[1] as string[];
-        }
-    }
-    return paramMap;
-};
-
-const BBCODE_PATTERN = [{ e: '\\[(\\w+)(?:[= ]([^\\]]+))?]((?:.|[\r\n])*?)\\[/\\1]', func: tagReplace }];
-
-function tagReplace(options: BBCodeConfig, fullMatch: string, tag: string, params: string, value: string) {
-    let val: string;
-    tag = tag.toLowerCase();
-    let paramsObj = parseParams(tag, params || undefined);
-    let inlineValue = paramsObj[tag];
-
-    switch (tag) {
-        case 'attach':
-            return '';
-        case 'spoiler':
-            return '';
-        case 'center':
-            return doReplace(value, BBCODE_PATTERN, options);
-        case 'size':
-            return doReplace(value, BBCODE_PATTERN, options);
-        case 'quote':
-            val = '<div class="' + options.classPrefix + 'quote"';
-            for (let i in paramsObj) {
-                let tmp = paramsObj[i];
-                if (!inlineValue && (i === 'author' || i === 'name')) {
-                    inlineValue = tmp;
-                } else if (i !== tag) {
-                    val += ' data-' + i + '="' + tmp + '"';
-                }
-            }
-            return val + '>' + (inlineValue ? inlineValue + ' wrote:' : (options.showQuotePrefix ? 'Quote:' : '')) + '<blockquote>' + value + '</blockquote></div>';
-        case 'url':
-            return '<a class="' + options.classPrefix + 'link" target="_blank" href="' + (inlineValue || value) + '">' + value + '</a>';
-        case 'email':
-            return '<a class="' + options.classPrefix + 'link" target="_blank" href="mailto:' + (inlineValue || value) + '">' + value + '</a>';
-        case 'anchor':
-            return '<a name="' + (inlineValue || paramsObj.a || value) + '">' + value + '</a>';
-        case 'b':
-            return '<strong>' + value + '</strong>';
-        case 'i':
-            return '<em>' + value + '</em>';
-        case 'u':
-            return '<span style="text-decoration:underline">' + value + '</span>';
-        case 's':
-            return '<span style="text-decoration:line-through">' + value + '</span>';
-        case 'indent':
-            return '<blockquote>' + value + '</blockquote>';
-        case 'list':
-            tag = 'ul';
-            let className = options.classPrefix + 'list';
-            if (inlineValue && /[1Aa]/.test(inlineValue)) {
-                tag = 'ol';
-                if (/1/.test(inlineValue)) {
-                    className += '_numeric';
-                }
-                else if (/A/.test(inlineValue)) {
-                    className += '_alpha';
-                }
-                else if (/a/.test(inlineValue)) {
-                    className += '_alpha_lower';
-                }
-            }
-            val = '<' + tag + ' class="' + className + '">';
-            //  parse the value
-            val += doReplace(value, [{ e: '\\[([*])\\]([^\r\n]+)', func: listItemReplace }], options);
-            return val + '</' + tag + '>';
-        case 'code':
-        case 'php':
-        case 'java':
-        case 'javascript':
-        case 'cpp':
-        case 'ruby':
-        case 'python':
-            return '<pre class="' + options.classPrefix + (tag === 'code' ? '' : 'code_') + tag + '">' + value + '</pre>';
-        case 'highlight':
-            return '<span class="' + options.classPrefix + tag + '">' + value + '</span>';
-        case 'html':
-            return value;
-        case 'mention':
-            val = '<span class="' + options.classPrefix + 'mention"';
-            if (inlineValue) {
-                val += ' data-mention-id="' + inlineValue + '"';
-            }
-            return val + '>' + (options.mentionPrefix || '') + value + '</span>';
-        case 'span':
-        case 'h1':
-        case 'h2':
-        case 'h3':
-        case 'h4':
-        case 'h5':
-        case 'h6':
-            return '<' + tag + '>' + value + '</' + tag + '>';
-        case 'youtube':
-            return '<object class="' + options.classPrefix + 'video" width="425" height="350"><param name="movie" value="http://www.youtube.com/v/' + value + '"></param><embed src="http://www.youtube.com/v/' + value + '" type="application/x-shockwave-flash" width="425" height="350"></embed></object>';
-        case 'gvideo':
-            return '<embed class="' + options.classPrefix + 'video" style="width:400px; height:325px;" id="VideoPlayback" type="application/x-shockwave-flash" src="http://video.google.com/googleplayer.swf?docId=' + value + '&amp;hl=en">';
-        case 'google':
-            return '<a class="' + options.classPrefix + 'link" target="_blank" href="http://www.google.com/search?q=' + (inlineValue || value) + '">' + value + '</a>';
-        case 'wikipedia':
-            return '<a class="' + options.classPrefix + 'link" target="_blank" href="http://www.wikipedia.org/wiki/' + (inlineValue || value) + '">' + value + '</a>';
-        case 'img':
-            var dims = new RegExp('^(\\d+)x(\\d+)$').exec(inlineValue || '');
-            if (!dims || (dims.length !== 3)) {
-                dims = new RegExp('^width=(\\d+)\\s+height=(\\d+)$').exec(inlineValue || '');
-            }
-            if (dims && dims.length === 3) {
-                params = undefined;
-            }
-            val = '<img class="' + options.classPrefix + 'image" src="' + value + '"';
-            if (dims && dims.length === 3) {
-                val += ' width="' + dims[1] + '" height="' + dims[2] + '"';
-            } else {
-                for (let i in paramsObj) {
-                    let tmp = paramsObj[i];
-                    if (i === 'img') {
-                        i = 'alt';
-                    }
-                    val += ' ' + i + '="' + tmp + '"';
-                }
-            }
-            return val + '/>';
-    }
-    // return the original
-    return fullMatch;
-}
-
-interface Replacement {
-    e: string;
-    func: (options: BBCodeConfig, fullMatch: string, tag: string, params: string, value: string) => string;
-}
-
-/**
- * Renders the content as html
- * @param content   the given content to render
- * @param options   optional object with control parameters
- * @returns rendered html
- */
-export var render = function (content: string, options?: BBCodeConfig) {
-    options = options || {};
-
-    if (!options.classPrefix)
-        options.classPrefix = defaults.classPrefix;
-    if (!options.mentionPrefix)
-        options.mentionPrefix = defaults.mentionPrefix;
-    if (!options.showQuotePrefix)
-        options.showQuotePrefix = defaults.showQuotePrefix;
-
-    // for now, only one rule
-    return doReplace(content, BBCODE_PATTERN, options);
-};
+import { Dict } from "src/util";
+
+var VERSION = '0.4.0';
+
+export interface BBCodeConfig {
+    showQuotePrefix?: boolean;
+    classPrefix?: string;
+    mentionPrefix?: string;
+}
+
+// default options
+const defaults: BBCodeConfig = {
+    showQuotePrefix: true,
+    classPrefix: 'bbcode_',
+    mentionPrefix: '@'
+};
+
+export var version = VERSION;
+
+// copied from here:
+// http://blog.mattheworiordan.com/post/13174566389/url-regular-expression-for-links-with-or-without-the had to make an
+// update to allow / in the query string, since some sites will have a / there made another update to support colons in
+// the query string made another update to disallow an ending dot(.)
+var URL_PATTERN = new RegExp("(" // overall match
+    + "(" // brackets covering match for protocol (optional) and domain
+    + "([A-Za-z]{3,9}:(?:\\/\\/)?)" // allow something@ for email addresses
+    + "(?:[\\-;:&=\\+\\$,\\w]+@)?[A-Za-z0-9\\.\\-]+[A-Za-z0-9\\-]"
+    // anything looking at all like a domain, non-unicode domains
+    + "|" // or instead of above
+    + "(?:www\\.|[\\-;:&=\\+\\$,\\w]+@)" // starting with something@ or www.
+    + "[A-Za-z0-9\\.\\-]+[A-Za-z0-9\\-]" // anything looking at all like a domain
+    + ")" // end protocol/domain
+    + "(" // brackets covering match for path, query string and anchor
+    + "(?:\\/[\\+~%\\/\\.\\w\\-_]*)?" // allow optional /path
+    + "\\??(?:[\\-\\+=&;%@\\.\\w_\\/:]*)" // allow optional query string starting with ?
+    + "#?(?:[\\.\\!\\/\\\\\\w]*)" // allow optional anchor #anchor
+    + ")?" // make URL suffix optional
+    + ")");
+
+function doReplace(content: string, matches: Replacement[], options: BBCodeConfig) {
+    var i, obj, regex, hasMatch, tmp;
+    // match/replace until we don't change the input anymore
+    do {
+        hasMatch = false;
+        for (i = 0; i < matches.length; ++i) {
+            obj = matches[i];
+            regex = new RegExp(obj.e, 'gi');
+            tmp = content.replace(regex, obj.func.bind(undefined, options));
+            if (tmp !== content) {
+                content = tmp;
+                hasMatch = true;
+            }
+        }
+    } while (hasMatch);
+    return content;
+}
+
+function listItemReplace(options: BBCodeConfig, fullMatch: string, tag: string, value: string) {
+    return '<li>' + doReplace(value.trim(), BBCODE_PATTERN, options) + '</li>';
+}
+
+export var extractQuotedText = function (value: string, parts?: string[]) {
+    var quotes = ["\"", "'"], i, quote, nextPart;
+
+    for (i = 0; i < quotes.length; ++i) {
+        quote = quotes[i];
+        if (value && value[0] === quote) {
+            value = value.slice(1);
+            if (value[value.length - 1] !== quote) {
+                while (parts && parts.length) {
+                    nextPart = parts.shift();
+                    value += " " + nextPart;
+                    if (nextPart[nextPart.length - 1] === quote) {
+                        break;
+                    }
+                }
+            }
+            value = value.replace(new RegExp("[" + quote + "]+$"), '');
+            break;
+        }
+    }
+    return [value, parts];
+};
+
+export var parseParams = function (tagName: string, params: string) {
+    let paramMap: Dict<string> = {};
+
+    if (!params) {
+        return paramMap;
+    }
+
+    // first, collapse spaces next to equals
+    params = params.replace(/\s*[=]\s*/g, "=");
+    let parts = params.split(/\s+/);
+
+    while (parts.length) {
+        let part = parts.shift();
+        // check if the param itself is a valid url
+        if (!URL_PATTERN.exec(part)) {
+            let index = part.indexOf('=');
+            if (index > 0) {
+                let rv = extractQuotedText(part.slice(index + 1), parts);
+                paramMap[part.slice(0, index).toLowerCase()] = rv[0] as string;
+                parts = rv[1] as string[];
+            }
+            else {
+                let rv = extractQuotedText(part, parts);
+                paramMap[tagName] = rv[0] as string;
+                parts = rv[1] as string[];
+            }
+        } else {
+            let rv = extractQuotedText(part, parts);
+            paramMap[tagName] = rv[0] as string;
+            parts = rv[1] as string[];
+        }
+    }
+    return paramMap;
+};
+
+const BBCODE_PATTERN = [{ e: '\\[(\\w+)(?:[= ]([^\\]]+))?]((?:.|[\r\n])*?)\\[/\\1]', func: tagReplace }];
+
+function tagReplace(options: BBCodeConfig, fullMatch: string, tag: string, params: string, value: string) {
+    let val: string;
+    tag = tag.toLowerCase();
+    let paramsObj = parseParams(tag, params || undefined);
+    let inlineValue = paramsObj[tag];
+
+    switch (tag) {
+        case 'attach':
+            return '';
+        case 'spoiler':
+            return '';
+        case 'center':
+            return doReplace(value, BBCODE_PATTERN, options);
+        case 'size':
+            return doReplace(value, BBCODE_PATTERN, options);
+        case 'quote':
+            val = '<div class="' + options.classPrefix + 'quote"';
+            for (let i in paramsObj) {
+                let tmp = paramsObj[i];
+                if (!inlineValue && (i === 'author' || i === 'name')) {
+                    inlineValue = tmp;
+                } else if (i !== tag) {
+                    val += ' data-' + i + '="' + tmp + '"';
+                }
+            }
+            return val + '>' + (inlineValue ? inlineValue + ' wrote:' : (options.showQuotePrefix ? 'Quote:' : '')) + '<blockquote>' + value + '</blockquote></div>';
+        case 'url':
+            return '<a class="' + options.classPrefix + 'link" target="_blank" href="' + (inlineValue || value) + '">' + value + '</a>';
+        case 'email':
+            return '<a class="' + options.classPrefix + 'link" target="_blank" href="mailto:' + (inlineValue || value) + '">' + value + '</a>';
+        case 'anchor':
+            return '<a name="' + (inlineValue || paramsObj.a || value) + '">' + value + '</a>';
+        case 'b':
+            return '<strong>' + value + '</strong>';
+        case 'i':
+            return '<em>' + value + '</em>';
+        case 'u':
+            return '<span style="text-decoration:underline">' + value + '</span>';
+        case 's':
+            return '<span style="text-decoration:line-through">' + value + '</span>';
+        case 'indent':
+            return '<blockquote>' + value + '</blockquote>';
+        case 'list':
+            tag = 'ul';
+            let className = options.classPrefix + 'list';
+            if (inlineValue && /[1Aa]/.test(inlineValue)) {
+                tag = 'ol';
+                if (/1/.test(inlineValue)) {
+                    className += '_numeric';
+                }
+                else if (/A/.test(inlineValue)) {
+                    className += '_alpha';
+                }
+                else if (/a/.test(inlineValue)) {
+                    className += '_alpha_lower';
+                }
+            }
+            val = '<' + tag + ' class="' + className + '">';
+            //  parse the value
+            val += doReplace(value, [{ e: '\\[([*])\\]([^\r\n]+)', func: listItemReplace }], options);
+            return val + '</' + tag + '>';
+        case 'code':
+        case 'php':
+        case 'java':
+        case 'javascript':
+        case 'cpp':
+        case 'ruby':
+        case 'python':
+            return '<pre class="' + options.classPrefix + (tag === 'code' ? '' : 'code_') + tag + '">' + value + '</pre>';
+        case 'highlight':
+            return '<span class="' + options.classPrefix + tag + '">' + value + '</span>';
+        case 'html':
+            return value;
+        case 'mention':
+            val = '<span class="' + options.classPrefix + 'mention"';
+            if (inlineValue) {
+                val += ' data-mention-id="' + inlineValue + '"';
+            }
+            return val + '>' + (options.mentionPrefix || '') + value + '</span>';
+        case 'span':
+        case 'h1':
+        case 'h2':
+        case 'h3':
+        case 'h4':
+        case 'h5':
+        case 'h6':
+            return '<' + tag + '>' + value + '</' + tag + '>';
+        case 'youtube':
+            return '<object class="' + options.classPrefix + 'video" width="425" height="350"><param name="movie" value="http://www.youtube.com/v/' + value + '"></param><embed src="http://www.youtube.com/v/' + value + '" type="application/x-shockwave-flash" width="425" height="350"></embed></object>';
+        case 'gvideo':
+            return '<embed class="' + options.classPrefix + 'video" style="width:400px; height:325px;" id="VideoPlayback" type="application/x-shockwave-flash" src="http://video.google.com/googleplayer.swf?docId=' + value + '&amp;hl=en">';
+        case 'google':
+            return '<a class="' + options.classPrefix + 'link" target="_blank" href="http://www.google.com/search?q=' + (inlineValue || value) + '">' + value + '</a>';
+        case 'wikipedia':
+            return '<a class="' + options.classPrefix + 'link" target="_blank" href="http://www.wikipedia.org/wiki/' + (inlineValue || value) + '">' + value + '</a>';
+        case 'img':
+            var dims = new RegExp('^(\\d+)x(\\d+)$').exec(inlineValue || '');
+            if (!dims || (dims.length !== 3)) {
+                dims = new RegExp('^width=(\\d+)\\s+height=(\\d+)$').exec(inlineValue || '');
+            }
+            if (dims && dims.length === 3) {
+                params = undefined;
+            }
+            val = '<img class="' + options.classPrefix + 'image" src="' + value + '"';
+            if (dims && dims.length === 3) {
+                val += ' width="' + dims[1] + '" height="' + dims[2] + '"';
+            } else {
+                for (let i in paramsObj) {
+                    let tmp = paramsObj[i];
+                    if (i === 'img') {
+                        i = 'alt';
+                    }
+                    val += ' ' + i + '="' + tmp + '"';
+                }
+            }
+            return val + '/>';
+    }
+    // return the original
+    return fullMatch;
+}
+
+interface Replacement {
+    e: string;
+    func: (options: BBCodeConfig, fullMatch: string, tag: string, params: string, value: string) => string;
+}
+
+/**
+ * Renders the content as html
+ * @param content   the given content to render
+ * @param options   optional object with control parameters
+ * @returns rendered html
+ */
+export var render = function (content: string, options?: BBCodeConfig) {
+    options = options || {};
+
+    if (!options.classPrefix)
+        options.classPrefix = defaults.classPrefix;
+    if (!options.mentionPrefix)
+        options.mentionPrefix = defaults.mentionPrefix;
+    if (!options.showQuotePrefix)
+        options.showQuotePrefix = defaults.showQuotePrefix;
+
+    // for now, only one rule
+    return doReplace(content, BBCODE_PATTERN, options);
+};

+ 486 - 486
bot/src/commands/contest.ts

@@ -1,487 +1,487 @@
-import { Dict, compareNumbers, isAuthorisedAsync } from "../util";
-import { Message, TextChannel, RichEmbed, MessageReaction, User, Collector, Collection, SnowflakeUtil } from "discord.js";
-import yaml from "yaml";
-import { getRepository, getManager } from "typeorm";
-import { Contest } from "@shared/db/entity/Contest";
-import emoji_regex from "emoji-regex";
-import { client } from "../client";
-import { scheduleJob } from "node-schedule";
-import { ContestEntry } from "@shared/db/entity/ContestEntry";
-import { ContestVote } from "@shared/db/entity/ContestVote";
-import { CommandSet, Command, Action, ActionType } from "src/model/command";
-
-const CHANNEL_ID_PATTERN = /<#(\d+)>/;
-
-interface ActiveContest {
-    id: number;
-    voteReaction: string;
-}
-
-interface ContestCreationOptions {
-    in?: string;
-    duration?: string;
-    announce_winners?: boolean;
-    vote_reaction?: string;
-    max_winners?: number;
-    unique_winners?: boolean
-}
-
-const CONTEST_DEFAULTS: ContestCreationOptions = {
-    duration: "1d",
-    announce_winners: false,
-    vote_reaction: "❤",
-    max_winners: 1,
-    unique_winners: true
-};
-
-const DURATION_MULTIPLIERS: Dict<number> = {
-    "s": 1000,
-    "min": 60 * 1000,
-    "h": 60 * 60 * 1000,
-    "d": 24 * 60 * 60 * 1000,
-    "mon": 30 * 24 * 60 * 60 * 1000,
-    "y": 365 * 24 * 60 * 60 * 1000
-};
-
-const DURATION_REGEX_STR = `(\\d+) ?(${Object.keys(DURATION_MULTIPLIERS).join("|")})`;
-const DURATION_REGEX = new RegExp(DURATION_REGEX_STR, "i");
-
-function parseDuration(duration: string): number | undefined {
-    let match = DURATION_REGEX.exec(duration);
-
-    if (match.length == 0)
-        return undefined;
-
-    let num = match[1];
-    let unit = match[2];
-
-    return +num * DURATION_MULTIPLIERS[unit];
-}
-
-type ContestEntryWithMessage = ContestEntry & { message: Message };
-
-function numberToOrdered(num: number) {
-    const prefixes = ["st", "nd", "rd"];
-    let s = num % 10;
-    return 0 < s && s <= prefixes.length ? `${num}${prefixes[s - 1]}` : `${num}th`;
-}
-
-@CommandSet
-/*export*/ class ContestCommands {
-
-    activeContests: Dict<ActiveContest> = {};
-
-    diffEntryVotes(votes: ContestVote[], currentUsers: Collection<string, User>) {
-        let votedUsersIds = new Set(votes.map(v => v.userId));
-        let currentUsersIds = new Set(currentUsers.keys());
-
-        for (let currentUserId of currentUsersIds)
-            if (votedUsersIds.has(currentUserId))
-                votedUsersIds.delete(currentUserId);
-
-        for (let votedUserId of votedUsersIds)
-            if (currentUsersIds.has(votedUserId))
-                currentUsersIds.delete(votedUserId);
-
-        return [currentUsersIds, votedUsersIds];
-    }
-
-    async updateContestStatus(contest: Contest) {
-        let voteRepo = getRepository(ContestVote);
-        let entryRepo = getRepository(ContestEntry);
-
-        let channel = client.channels.get(contest.channel) as TextChannel;
-        if (!channel) {
-            console.log(`Channel ${contest.channel} has been deleted! Removing contest ${contest.id}...`);
-            await this.removeContest(contest.id);
-            return;
-        }
-
-        let newestEntry: Date = null;
-        let contestEntryMessageIds = new Set<string>(contest.entries.map(e => e.msgId));
-        for (let entry of contest.entries) {
-            try {
-                let msg = await channel.fetchMessage(entry.msgId);
-                let existingVotes = await voteRepo.find({ where: { contest: contest, contestEntry: entry } });
-
-                let voteReaction = msg.reactions.find(r => r.emoji.toString() == contest.voteReaction);
-                if (!voteReaction) {
-                    await voteRepo.remove(existingVotes);
-                    continue;
-                }
-
-                let users = await voteReaction.fetchUsers();
-                let [newVotes, removedVotes] = this.diffEntryVotes(existingVotes, users);
-                voteRepo.remove(existingVotes.filter(v => removedVotes.has(v.userId)));
-                let newVoteEntries = [...newVotes].map(i => voteRepo.create({
-                    userId: i,
-                    contest: contest,
-                    contestEntry: entry
-                }));
-                await voteRepo.save(newVoteEntries);
-
-                entry.votes = [
-                    ...newVoteEntries,
-                    ...existingVotes.filter(v => !removedVotes.has(v.userId))
-                ];
-                await entryRepo.save(entry);
-
-                if (!newestEntry || msg.createdAt > newestEntry)
-                    newestEntry = msg.createdAt;
-            } catch (err) {
-                console.log(`Failed to update entry ${entry.msgId} for contest ${contest.id} because ${err}!`);
-
-                await voteRepo.delete({ contestEntry: entry });
-                await entryRepo.delete({ msgId: entry.msgId });
-                contestEntryMessageIds.delete(entry.msgId);
-            }
-        }
-
-        let newEntries = (await channel.fetchMessages({
-            after: SnowflakeUtil.generate(newestEntry || contest.startDate)
-        })).filter(m => m.attachments.size != 0 && !contestEntryMessageIds.has(m.id));
-
-        for (let [_, msg] of newEntries)
-            await this.registerEntry(msg, contest);
-
-        if (contest.endDate < new Date()) {
-            await this.stopContest(contest.id);
-        } else {
-            scheduleJob(contest.endDate, this.stopContest.bind(this, contest.id));
-            this.activeContests[channel.id] = {
-                id: contest.id,
-                voteReaction: contest.voteReaction
-            };
-        }
-    }
-
-    async registerEntry(msg: Message, contest: Contest) {
-        let entryRepo = getRepository(ContestEntry);
-        let voteRepo = getRepository(ContestVote);
-
-        let entry = entryRepo.create({
-            msgId: msg.id,
-            contest: contest
-        });
-
-        await entryRepo.save(entry);
-
-        let voteReaction = msg.reactions.find(r => r.emoji.toString() == contest.voteReaction);
-
-        if (!voteReaction)
-            return;
-
-        let votedUsers = await voteReaction.fetchUsers();
-
-        await voteRepo.save(votedUsers.map(u => voteRepo.create({
-            userId: u.id,
-            contest: contest,
-            contestEntry: entry
-        })));
-    }
-
-    async removeContest(contestId: number) {
-        await getManager().transaction(async em => {
-            let contestRepo = em.getRepository(Contest);
-            let contestEntryRepo = em.getRepository(ContestEntry);
-            let contestVoteRepo = em.getRepository(ContestVote);
-
-            let contest = contestRepo.create({
-                id: contestId
-            });
-
-            await contestRepo.delete({
-                id: contestId
-            });
-            await contestVoteRepo.delete({
-                contest: contest
-            });
-            await contestEntryRepo.delete({
-                contest: contest
-            });
-        });
-    }
-
-    async pickValidEntries(channel: TextChannel, contestEntries: ContestEntry[], max: number, unique = true) {
-        let addedUsers = new Set<string>();
-        let result: ContestEntryWithMessage[] = [];
-        let maxResults = Math.min(max, contestEntries.length);
-
-        for (let entry of contestEntries) {
-            try {
-                let msg = await channel.fetchMessage(entry.msgId);
-                if (unique && addedUsers.has(msg.author.id))
-                    continue;
-
-                result.push({ ...entry, message: msg });
-                addedUsers.add(msg.author.id);
-                if (result.length == maxResults)
-                    break;
-            } catch (err) { }
-        }
-
-        return result;
-    }
-
-    async printResults(contest: Contest, channel: TextChannel) {
-        let entryRepo = getRepository(ContestEntry);
-
-        let entries = await entryRepo.find({
-            where: { contest: contest },
-            relations: ["votes"]
-        });
-
-        if (entries.length == 0) {
-            // Hmmm... maybe rich embeds?
-            await channel.send("No entries were sent into this contest! Therefore I declare myself a winner!");
-            return;
-        }
-
-        let winningEntries = await this.pickValidEntries(channel, entries.sort(compareNumbers(o => o.votes.length)), contest.maxWinners, contest.uniqueWinners);
-        let totalVotes = entries.reduce((p, c) => p + c.votes.length, 0);
-
-        let embed = new RichEmbed({
-            title: "🎆 Contest results 🎆",
-            color: 0x3b8dc4,
-            timestamp: new Date(),
-            description: `The contest has ended!\nCollected ${totalVotes} votes.\nHere are the results:`,
-            fields: winningEntries.map((e, i) => ({
-                name: `${numberToOrdered(i + 1)} place (${e.votes.length} votes)`,
-                value: `${e.message.author.toString()} ([View entry](${e.message.url}))`
-            }))
-        });
-
-        await channel.send(embed);
-    }
-
-    async stopContest(contestId: number) {
-        let repo = getRepository(Contest);
-        let contest = await repo.findOne(contestId);
-
-        let channel = client.channels.get(contest.channel) as TextChannel;
-        if (!channel) {
-            // TODO: Don't remove; instead report in web manager
-            console.log(`Channel ${contest.channel} has been deleted! Removing contest ${contest.id}...`);
-            await this.removeContest(contestId);
-            return;
-        }
-
-        await channel.send(`Current contest has ended! Thank you for your participation!`);
-
-        if (contest.announceWinners)
-            await this.printResults(contest, channel);
-
-        await repo.update(contestId, { active: false });
-    }
-
-    async createContest(msg: Message, info: ContestCreationOptions) {
-        if (info.in) {
-            let matches = CHANNEL_ID_PATTERN.exec(info.in);
-            if (matches.length == 0) {
-                await msg.channel.send(`${msg.author.toString()} I can't see such a channel!`);
-                return;
-            }
-
-            let channelId = matches[1];
-            if (!msg.guild.channels.exists("id", channelId)) {
-                await msg.channel.send(`${msg.author.toString()} This channel is not in the current guild!`);
-                return;
-            }
-
-            info.in = channelId;
-        } else
-            info.in = msg.channel.id;
-
-        if (info.max_winners < 1) {
-            await msg.channel.send(`${msg.author.toString()} The contest must have at least one possible winner!`);
-            return;
-        }
-
-        let dur = parseDuration(info.duration);
-        if (!dur) {
-            await msg.channel.send(`${msg.author.toString()} Duration format is invalid!`);
-            return;
-        }
-
-        if (!msg.guild.emojis.find(e => e.toString() == info.vote_reaction) && !emoji_regex().exec(info.vote_reaction)) {
-            await msg.channel.send(`${msg.author.toString()} The vote emote must be accessible by everyone on the server!`);
-            return;
-        }
-
-        let repo = getRepository(Contest);
-
-        let contest = await repo.findOne({
-            where: { channel: info.in, active: true }
-        });
-
-        if (contest) {
-            await msg.channel.send(`${msg.author.toString()} The channel already has a contest running!`);
-            return;
-        }
-
-        contest = repo.create({
-            channel: info.in,
-            startDate: new Date(),
-            endDate: new Date(Date.now() + dur),
-            announceWinners: info.announce_winners,
-            voteReaction: info.vote_reaction,
-            maxWinners: info.max_winners,
-            uniqueWinners: info.unique_winners,
-            active: true
-        });
-
-        await repo.save(contest);
-        await msg.channel.send(`${msg.author.toString()} Started contest (ID: ${contest.id})`);
-
-        scheduleJob(contest.endDate, this.stopContest.bind(this, contest.id));
-        this.activeContests[contest.channel] = {
-            id: contest.id,
-            voteReaction: contest.voteReaction
-        };
-    }
-
-    async onReact(reaction: MessageReaction, user: User) {
-        if (user.bot)
-            return;
-
-        let channel = reaction.message.channel;
-        let activeContest = this.activeContests[channel.id];
-        if (!activeContest || reaction.emoji.toString() != activeContest.voteReaction)
-            return;
-
-        let entryRepo = getRepository(ContestEntry);
-        let voteRepo = getRepository(ContestVote);
-
-        let entry = await entryRepo.findOne({
-            where: { msgId: reaction.message.id }
-        });
-        if (!entry)
-            return;
-
-        let vote = await voteRepo.findOne({
-            where: { userId: user.id, contestId: activeContest.id }
-        });
-        if (!vote)
-            vote = voteRepo.create({ userId: user.id, contestId: activeContest.id });
-
-        vote.contestEntry = entry;
-        await voteRepo.save(vote);
-    }
-
-    @Action(ActionType.MESSAGE)
-    async addEntry(actionsDone: boolean, m: Message, content: string) {
-        if (m.attachments.size == 0)
-            return false;
-
-        let channel = m.channel;
-
-        let contestRepo = getRepository(Contest);
-
-        let contest = await contestRepo.findOne({
-            where: {
-                channel: channel.id,
-                active: true
-            },
-            select: ["id", "voteReaction"]
-        });
-
-        if (!contest)
-            return false;
-
-        await this.registerEntry(m, contest);
-
-        // Don't prevent further actions
-        return false;
-    }
-
-    async onStart() {
-        let contestRepo = getRepository(Contest);
-        let contests = await contestRepo.find({
-            where: { active: true },
-            relations: ["entries"]
-        });
-
-        for (let contest of contests)
-            await this.updateContestStatus(contest);
-
-        client.on("messageReactionAdd", this.onReact);
-    }
-
-    @Command({ pattern: "create contest", auth: true })
-    async startContest(m: Message) {
-        if (!await isAuthorisedAsync(m.member)) {
-            m.channel.send(`${m.author.toString()} You're not authorised to create contests!`);
-            return;
-        }
-
-        let message = m.content.trim().substr(client.user.toString().length).trim().substr("create contest".length).trim();
-        let contestData: ContestCreationOptions = { ...CONTEST_DEFAULTS, ...yaml.parse(message) };
-        await this.createContest(m, contestData);
-    }
-
-    @Command({ pattern: "contests" })
-    async listContests(m: Message) {
-        let repo = getRepository(Contest);
-        let contests = await repo.find({ where: { active: true } });
-
-        let contestsData = contests.map(c => ({
-            contest: c,
-            channel: client.channels.get(c.channel) as TextChannel
-        })).filter(c => c.channel);
-
-        if (contestsData.length == 0)
-            await m.channel.send(`${m.author.toString()} There are no currently running contests!`);
-        else
-            await m.channel.send(`${m.author.toString()} Currently there are contests active in the following channels:\n${contestsData.map((c, i) => `${i + 1}. ${c.channel.toString()}`).join("\n")}`);
-    }
-
-    @Command({ pattern: /end contest( (\d*))?/, auth: true })
-    async endContest(m: Message, contents: string, matches: RegExpMatchArray) {
-        if (!await isAuthorisedAsync(m.member)) {
-            m.channel.send(`${m.author.toString()} You're not authorised to create contests!`);
-            return;
-        }
-
-        let repo = getRepository(Contest);
-        let contestId = +matches[1];
-
-        let contest = await repo.findOne({
-            where: { id: contestId },
-            select: ["id", "active"]
-        });
-
-        if (!contest) {
-            await m.channel.send(`${m.author.toString()} Can't find contest with ID ${contestId}`);
-            return;
-        }
-
-        if (!contest.active) {
-            await m.channel.send(`${m.author.toString()} The contest with ID ${contestId} is already inactive!`);
-            return;
-        }
-
-        await this.stopContest(contest.id);
-    }
-
-    @Command({ pattern: "announce winners", auth: true })
-    async announceWinners(m: Message) {
-        let repo = getRepository(Contest);
-
-        let contest = await repo.findOne({
-            where: { channel: m.channel.id },
-            order: { endDate: "DESC" }
-        });
-
-        if (!contest) {
-            await m.channel.send(`${m.author.toString()} There have never been any contests!`);
-            return;
-        }
-
-        if (contest.active) {
-            await this.stopContest(contest.id);
-            if (contest.announceWinners)
-                return;
-        }
-        await this.printResults(contest, m.channel as TextChannel);
-    }
+import { Dict, compareNumbers, isAuthorisedAsync } from "../util";
+import { Message, TextChannel, RichEmbed, MessageReaction, User, Collector, Collection, SnowflakeUtil } from "discord.js";
+import yaml from "yaml";
+import { getRepository, getManager } from "typeorm";
+import { Contest } from "@shared/db/entity/Contest";
+import emoji_regex from "emoji-regex";
+import { client } from "../client";
+import { scheduleJob } from "node-schedule";
+import { ContestEntry } from "@shared/db/entity/ContestEntry";
+import { ContestVote } from "@shared/db/entity/ContestVote";
+import { CommandSet, Command, Action, ActionType } from "src/model/command";
+
+const CHANNEL_ID_PATTERN = /<#(\d+)>/;
+
+interface ActiveContest {
+    id: number;
+    voteReaction: string;
+}
+
+interface ContestCreationOptions {
+    in?: string;
+    duration?: string;
+    announce_winners?: boolean;
+    vote_reaction?: string;
+    max_winners?: number;
+    unique_winners?: boolean
+}
+
+const CONTEST_DEFAULTS: ContestCreationOptions = {
+    duration: "1d",
+    announce_winners: false,
+    vote_reaction: "❤",
+    max_winners: 1,
+    unique_winners: true
+};
+
+const DURATION_MULTIPLIERS: Dict<number> = {
+    "s": 1000,
+    "min": 60 * 1000,
+    "h": 60 * 60 * 1000,
+    "d": 24 * 60 * 60 * 1000,
+    "mon": 30 * 24 * 60 * 60 * 1000,
+    "y": 365 * 24 * 60 * 60 * 1000
+};
+
+const DURATION_REGEX_STR = `(\\d+) ?(${Object.keys(DURATION_MULTIPLIERS).join("|")})`;
+const DURATION_REGEX = new RegExp(DURATION_REGEX_STR, "i");
+
+function parseDuration(duration: string): number | undefined {
+    let match = DURATION_REGEX.exec(duration);
+
+    if (match.length == 0)
+        return undefined;
+
+    let num = match[1];
+    let unit = match[2];
+
+    return +num * DURATION_MULTIPLIERS[unit];
+}
+
+type ContestEntryWithMessage = ContestEntry & { message: Message };
+
+function numberToOrdered(num: number) {
+    const prefixes = ["st", "nd", "rd"];
+    let s = num % 10;
+    return 0 < s && s <= prefixes.length ? `${num}${prefixes[s - 1]}` : `${num}th`;
+}
+
+@CommandSet
+/*export*/ class ContestCommands {
+
+    activeContests: Dict<ActiveContest> = {};
+
+    diffEntryVotes(votes: ContestVote[], currentUsers: Collection<string, User>) {
+        let votedUsersIds = new Set(votes.map(v => v.userId));
+        let currentUsersIds = new Set(currentUsers.keys());
+
+        for (let currentUserId of currentUsersIds)
+            if (votedUsersIds.has(currentUserId))
+                votedUsersIds.delete(currentUserId);
+
+        for (let votedUserId of votedUsersIds)
+            if (currentUsersIds.has(votedUserId))
+                currentUsersIds.delete(votedUserId);
+
+        return [currentUsersIds, votedUsersIds];
+    }
+
+    async updateContestStatus(contest: Contest) {
+        let voteRepo = getRepository(ContestVote);
+        let entryRepo = getRepository(ContestEntry);
+
+        let channel = client.channels.get(contest.channel) as TextChannel;
+        if (!channel) {
+            console.log(`Channel ${contest.channel} has been deleted! Removing contest ${contest.id}...`);
+            await this.removeContest(contest.id);
+            return;
+        }
+
+        let newestEntry: Date = null;
+        let contestEntryMessageIds = new Set<string>(contest.entries.map(e => e.msgId));
+        for (let entry of contest.entries) {
+            try {
+                let msg = await channel.fetchMessage(entry.msgId);
+                let existingVotes = await voteRepo.find({ where: { contest: contest, contestEntry: entry } });
+
+                let voteReaction = msg.reactions.find(r => r.emoji.toString() == contest.voteReaction);
+                if (!voteReaction) {
+                    await voteRepo.remove(existingVotes);
+                    continue;
+                }
+
+                let users = await voteReaction.fetchUsers();
+                let [newVotes, removedVotes] = this.diffEntryVotes(existingVotes, users);
+                voteRepo.remove(existingVotes.filter(v => removedVotes.has(v.userId)));
+                let newVoteEntries = [...newVotes].map(i => voteRepo.create({
+                    userId: i,
+                    contest: contest,
+                    contestEntry: entry
+                }));
+                await voteRepo.save(newVoteEntries);
+
+                entry.votes = [
+                    ...newVoteEntries,
+                    ...existingVotes.filter(v => !removedVotes.has(v.userId))
+                ];
+                await entryRepo.save(entry);
+
+                if (!newestEntry || msg.createdAt > newestEntry)
+                    newestEntry = msg.createdAt;
+            } catch (err) {
+                console.log(`Failed to update entry ${entry.msgId} for contest ${contest.id} because ${err}!`);
+
+                await voteRepo.delete({ contestEntry: entry });
+                await entryRepo.delete({ msgId: entry.msgId });
+                contestEntryMessageIds.delete(entry.msgId);
+            }
+        }
+
+        let newEntries = (await channel.fetchMessages({
+            after: SnowflakeUtil.generate(newestEntry || contest.startDate)
+        })).filter(m => m.attachments.size != 0 && !contestEntryMessageIds.has(m.id));
+
+        for (let [_, msg] of newEntries)
+            await this.registerEntry(msg, contest);
+
+        if (contest.endDate < new Date()) {
+            await this.stopContest(contest.id);
+        } else {
+            scheduleJob(contest.endDate, this.stopContest.bind(this, contest.id));
+            this.activeContests[channel.id] = {
+                id: contest.id,
+                voteReaction: contest.voteReaction
+            };
+        }
+    }
+
+    async registerEntry(msg: Message, contest: Contest) {
+        let entryRepo = getRepository(ContestEntry);
+        let voteRepo = getRepository(ContestVote);
+
+        let entry = entryRepo.create({
+            msgId: msg.id,
+            contest: contest
+        });
+
+        await entryRepo.save(entry);
+
+        let voteReaction = msg.reactions.find(r => r.emoji.toString() == contest.voteReaction);
+
+        if (!voteReaction)
+            return;
+
+        let votedUsers = await voteReaction.fetchUsers();
+
+        await voteRepo.save(votedUsers.map(u => voteRepo.create({
+            userId: u.id,
+            contest: contest,
+            contestEntry: entry
+        })));
+    }
+
+    async removeContest(contestId: number) {
+        await getManager().transaction(async em => {
+            let contestRepo = em.getRepository(Contest);
+            let contestEntryRepo = em.getRepository(ContestEntry);
+            let contestVoteRepo = em.getRepository(ContestVote);
+
+            let contest = contestRepo.create({
+                id: contestId
+            });
+
+            await contestRepo.delete({
+                id: contestId
+            });
+            await contestVoteRepo.delete({
+                contest: contest
+            });
+            await contestEntryRepo.delete({
+                contest: contest
+            });
+        });
+    }
+
+    async pickValidEntries(channel: TextChannel, contestEntries: ContestEntry[], max: number, unique = true) {
+        let addedUsers = new Set<string>();
+        let result: ContestEntryWithMessage[] = [];
+        let maxResults = Math.min(max, contestEntries.length);
+
+        for (let entry of contestEntries) {
+            try {
+                let msg = await channel.fetchMessage(entry.msgId);
+                if (unique && addedUsers.has(msg.author.id))
+                    continue;
+
+                result.push({ ...entry, message: msg });
+                addedUsers.add(msg.author.id);
+                if (result.length == maxResults)
+                    break;
+            } catch (err) { }
+        }
+
+        return result;
+    }
+
+    async printResults(contest: Contest, channel: TextChannel) {
+        let entryRepo = getRepository(ContestEntry);
+
+        let entries = await entryRepo.find({
+            where: { contest: contest },
+            relations: ["votes"]
+        });
+
+        if (entries.length == 0) {
+            // Hmmm... maybe rich embeds?
+            await channel.send("No entries were sent into this contest! Therefore I declare myself a winner!");
+            return;
+        }
+
+        let winningEntries = await this.pickValidEntries(channel, entries.sort(compareNumbers(o => o.votes.length)), contest.maxWinners, contest.uniqueWinners);
+        let totalVotes = entries.reduce((p, c) => p + c.votes.length, 0);
+
+        let embed = new RichEmbed({
+            title: "🎆 Contest results 🎆",
+            color: 0x3b8dc4,
+            timestamp: new Date(),
+            description: `The contest has ended!\nCollected ${totalVotes} votes.\nHere are the results:`,
+            fields: winningEntries.map((e, i) => ({
+                name: `${numberToOrdered(i + 1)} place (${e.votes.length} votes)`,
+                value: `${e.message.author.toString()} ([View entry](${e.message.url}))`
+            }))
+        });
+
+        await channel.send(embed);
+    }
+
+    async stopContest(contestId: number) {
+        let repo = getRepository(Contest);
+        let contest = await repo.findOne(contestId);
+
+        let channel = client.channels.get(contest.channel) as TextChannel;
+        if (!channel) {
+            // TODO: Don't remove; instead report in web manager
+            console.log(`Channel ${contest.channel} has been deleted! Removing contest ${contest.id}...`);
+            await this.removeContest(contestId);
+            return;
+        }
+
+        await channel.send(`Current contest has ended! Thank you for your participation!`);
+
+        if (contest.announceWinners)
+            await this.printResults(contest, channel);
+
+        await repo.update(contestId, { active: false });
+    }
+
+    async createContest(msg: Message, info: ContestCreationOptions) {
+        if (info.in) {
+            let matches = CHANNEL_ID_PATTERN.exec(info.in);
+            if (matches.length == 0) {
+                await msg.channel.send(`${msg.author.toString()} I can't see such a channel!`);
+                return;
+            }
+
+            let channelId = matches[1];
+            if (!msg.guild.channels.exists("id", channelId)) {
+                await msg.channel.send(`${msg.author.toString()} This channel is not in the current guild!`);
+                return;
+            }
+
+            info.in = channelId;
+        } else
+            info.in = msg.channel.id;
+
+        if (info.max_winners < 1) {
+            await msg.channel.send(`${msg.author.toString()} The contest must have at least one possible winner!`);
+            return;
+        }
+
+        let dur = parseDuration(info.duration);
+        if (!dur) {
+            await msg.channel.send(`${msg.author.toString()} Duration format is invalid!`);
+            return;
+        }
+
+        if (!msg.guild.emojis.find(e => e.toString() == info.vote_reaction) && !emoji_regex().exec(info.vote_reaction)) {
+            await msg.channel.send(`${msg.author.toString()} The vote emote must be accessible by everyone on the server!`);
+            return;
+        }
+
+        let repo = getRepository(Contest);
+
+        let contest = await repo.findOne({
+            where: { channel: info.in, active: true }
+        });
+
+        if (contest) {
+            await msg.channel.send(`${msg.author.toString()} The channel already has a contest running!`);
+            return;
+        }
+
+        contest = repo.create({
+            channel: info.in,
+            startDate: new Date(),
+            endDate: new Date(Date.now() + dur),
+            announceWinners: info.announce_winners,
+            voteReaction: info.vote_reaction,
+            maxWinners: info.max_winners,
+            uniqueWinners: info.unique_winners,
+            active: true
+        });
+
+        await repo.save(contest);
+        await msg.channel.send(`${msg.author.toString()} Started contest (ID: ${contest.id})`);
+
+        scheduleJob(contest.endDate, this.stopContest.bind(this, contest.id));
+        this.activeContests[contest.channel] = {
+            id: contest.id,
+            voteReaction: contest.voteReaction
+        };
+    }
+
+    async onReact(reaction: MessageReaction, user: User) {
+        if (user.bot)
+            return;
+
+        let channel = reaction.message.channel;
+        let activeContest = this.activeContests[channel.id];
+        if (!activeContest || reaction.emoji.toString() != activeContest.voteReaction)
+            return;
+
+        let entryRepo = getRepository(ContestEntry);
+        let voteRepo = getRepository(ContestVote);
+
+        let entry = await entryRepo.findOne({
+            where: { msgId: reaction.message.id }
+        });
+        if (!entry)
+            return;
+
+        let vote = await voteRepo.findOne({
+            where: { userId: user.id, contestId: activeContest.id }
+        });
+        if (!vote)
+            vote = voteRepo.create({ userId: user.id, contestId: activeContest.id });
+
+        vote.contestEntry = entry;
+        await voteRepo.save(vote);
+    }
+
+    @Action(ActionType.MESSAGE)
+    async addEntry(actionsDone: boolean, m: Message, content: string) {
+        if (m.attachments.size == 0)
+            return false;
+
+        let channel = m.channel;
+
+        let contestRepo = getRepository(Contest);
+
+        let contest = await contestRepo.findOne({
+            where: {
+                channel: channel.id,
+                active: true
+            },
+            select: ["id", "voteReaction"]
+        });
+
+        if (!contest)
+            return false;
+
+        await this.registerEntry(m, contest);
+
+        // Don't prevent further actions
+        return false;
+    }
+
+    async onStart() {
+        let contestRepo = getRepository(Contest);
+        let contests = await contestRepo.find({
+            where: { active: true },
+            relations: ["entries"]
+        });
+
+        for (let contest of contests)
+            await this.updateContestStatus(contest);
+
+        client.on("messageReactionAdd", this.onReact);
+    }
+
+    @Command({ pattern: "create contest", auth: true })
+    async startContest(m: Message) {
+        if (!await isAuthorisedAsync(m.member)) {
+            m.channel.send(`${m.author.toString()} You're not authorised to create contests!`);
+            return;
+        }
+
+        let message = m.content.trim().substr(client.user.toString().length).trim().substr("create contest".length).trim();
+        let contestData: ContestCreationOptions = { ...CONTEST_DEFAULTS, ...yaml.parse(message) };
+        await this.createContest(m, contestData);
+    }
+
+    @Command({ pattern: "contests" })
+    async listContests(m: Message) {
+        let repo = getRepository(Contest);
+        let contests = await repo.find({ where: { active: true } });
+
+        let contestsData = contests.map(c => ({
+            contest: c,
+            channel: client.channels.get(c.channel) as TextChannel
+        })).filter(c => c.channel);
+
+        if (contestsData.length == 0)
+            await m.channel.send(`${m.author.toString()} There are no currently running contests!`);
+        else
+            await m.channel.send(`${m.author.toString()} Currently there are contests active in the following channels:\n${contestsData.map((c, i) => `${i + 1}. ${c.channel.toString()}`).join("\n")}`);
+    }
+
+    @Command({ pattern: /end contest( (\d*))?/, auth: true })
+    async endContest(m: Message, contents: string, matches: RegExpMatchArray) {
+        if (!await isAuthorisedAsync(m.member)) {
+            m.channel.send(`${m.author.toString()} You're not authorised to create contests!`);
+            return;
+        }
+
+        let repo = getRepository(Contest);
+        let contestId = +matches[1];
+
+        let contest = await repo.findOne({
+            where: { id: contestId },
+            select: ["id", "active"]
+        });
+
+        if (!contest) {
+            await m.channel.send(`${m.author.toString()} Can't find contest with ID ${contestId}`);
+            return;
+        }
+
+        if (!contest.active) {
+            await m.channel.send(`${m.author.toString()} The contest with ID ${contestId} is already inactive!`);
+            return;
+        }
+
+        await this.stopContest(contest.id);
+    }
+
+    @Command({ pattern: "announce winners", auth: true })
+    async announceWinners(m: Message) {
+        let repo = getRepository(Contest);
+
+        let contest = await repo.findOne({
+            where: { channel: m.channel.id },
+            order: { endDate: "DESC" }
+        });
+
+        if (!contest) {
+            await m.channel.send(`${m.author.toString()} There have never been any contests!`);
+            return;
+        }
+
+        if (contest.active) {
+            await this.stopContest(contest.id);
+            if (contest.announceWinners)
+                return;
+        }
+        await this.printResults(contest, m.channel as TextChannel);
+    }
 };

+ 10 - 10
bot/src/commands/facemorph.ts

@@ -37,8 +37,8 @@ export class Facemorph {
         let jimpImage = await Jimp.read(data);
         let emojiKeys = process.env.FOOLS != "TRUE" ? [
             ...client.guilds
-                .get(EMOTE_GUILD)
-                .emojis.filter(e => !e.animated && e.name.startsWith("PADORU") == padoru)
+                .resolve(EMOTE_GUILD)
+                .emojis.cache.filter(e => !e.animated && e.name.startsWith("PADORU") == padoru)
                 .keys()
         ]: 
         [
@@ -54,7 +54,7 @@ export class Facemorph {
             let dx = rect.x + rect.width / 2;
             let dy = rect.y + rect.height / 2;
             let emojiKey = emojiKeys[Math.floor(Math.random() * emojiKeys.length)];
-            let emoji = client.emojis.get(emojiKey);
+            let emoji = client.emojis.resolve(emojiKey);
             let emojiImage = await Jimp.read(emoji.url);
             let ew = emojiImage.getWidth();
             let eh = emojiImage.getHeight();
@@ -202,7 +202,7 @@ export class Facemorph {
         let buffer = await jimpImage.getBufferAsync(Jimp.MIME_JPEG);
         let messageContents =
             successMessage ||
-            `I noticed a face in the image. I think this looks better ${client.emojis.get("505076258753740810").toString()}`;
+            `I noticed a face in the image. I think this looks better ${client.emojis.resolve("505076258753740810").toString()}`;
 
         message.channel.send(messageContents, {
             files: [buffer]
@@ -210,16 +210,16 @@ export class Facemorph {
     }
 
     processLastImage(msg: Message, processor: ImageProcessor) {
-        let lastImagedMessage = msg.channel.messages.filter(m => !m.author.bot && m.attachments.find(v => isValidImage(v.filename) !== undefined) != undefined).last();
+        let lastImagedMessage = msg.channel.messages.cache.filter(m => !m.author.bot && m.attachments.find(v => isValidImage(v.name) !== undefined) != undefined).last();
 
         if (!lastImagedMessage) {
             msg.channel.send(`${msg.author.toString()} Sorry, I couldn't find any recent messages with images.`);
             return;
         }
 
-        let image = lastImagedMessage.attachments.find(v => isValidImage(v.filename));
+        let image = lastImagedMessage.attachments.find(v => isValidImage(v.name));
 
-        let replyEmoji = client.emojis.get("505076258753740810");
+        let replyEmoji = client.emojis.resolve("505076258753740810");
         let emojiText = replyEmoji ? replyEmoji.toString() : "Jiiii~";
 
         this.processFaceSwap(
@@ -238,7 +238,7 @@ export class Facemorph {
         if (msg.mentions.users.size > 0 && msg.mentions.users.first().id == client.user.id)
             return false;
 
-        let imageAttachment = msg.attachments.find(v => isValidImage(v.filename));
+        let imageAttachment = msg.attachments.find(v => isValidImage(v.name));
 
         if (imageAttachment) {
 
@@ -265,7 +265,7 @@ export class Facemorph {
     async morphProvidedImage(actionsDone: boolean, msg: Message, content: string) {
         if (actionsDone) return false;
 
-        let image = msg.attachments.find(v => isValidImage(v.filename));
+        let image = msg.attachments.find(v => isValidImage(v.name));
         if (!image) {
             if (msg.attachments.size > 0) {
                 msg.channel.send(
@@ -282,7 +282,7 @@ export class Facemorph {
         else
             processor = this.morphFaces;
 
-        let replyEmoji = client.emojis.get("505076258753740810");
+        let replyEmoji = client.emojis.resolve("505076258753740810");
         let emojiText = replyEmoji ? replyEmoji.toString() : "Jiiii~";
 
         this.processFaceSwap(

File diff suppressed because it is too large
+ 36 - 36
bot/src/commands/file_only_channel_checker.ts


+ 5 - 5
bot/src/commands/forums_news_checker.ts

@@ -109,7 +109,7 @@ export class ForumsNewsChecker {
     }
 
     async initPendingReactors() {
-        let verifyChannel = client.channels.get(this.verifyChannelId);
+        let verifyChannel = client.channels.resolve(this.verifyChannelId);
 
         let repo = getRepository(PostedForumNewsItem);
         let verifyMessageRepo = getRepository(PostVerifyMessage);
@@ -137,7 +137,7 @@ export class ForumsNewsChecker {
     }
 
     async addVerifyMessage(item: PostedForumNewsItem) {
-        let verifyChannel = client.channels.get(this.verifyChannelId) as TextChannel;
+        let verifyChannel = client.channels.resolve(this.verifyChannelId) as TextChannel;
 
         if (!verifyChannel) {
             console.log(`Skipping adding item ${item.id} because no verify channel is set up!`);
@@ -216,7 +216,7 @@ export class ForumsNewsChecker {
         try {
             if (!(channel instanceof TextChannel))
                 return null;
-            return await channel.fetchMessage(messageId);
+            return await channel.messages.fetch(messageId);
         } catch (error) {
             return null;
         }
@@ -228,7 +228,7 @@ export class ForumsNewsChecker {
 
     async postNewsItem(channel: string, item: PostedForumNewsItem): Promise<Message | null> {
         let newsMessage = this.toNewsString(item.verifyMessage);
-        let ch = client.channels.get(channel);
+        let ch = client.channels.resolve(channel);
 
         if (!(ch instanceof TextChannel))
             return null;
@@ -284,7 +284,7 @@ React with ✅ (approve) or ❌ (deny).`;
             return;
         }
 
-        let editMsg = await this.tryFetchMessage(client.channels.get(this.verifyChannelId), post.verifyMessage.messageId);
+        let editMsg = await this.tryFetchMessage(client.channels.resolve(this.verifyChannelId), post.verifyMessage.messageId);
 
         if (!editMsg) {
             msg.channel.send(`${msg.author.toString()} No verify message found for ${id}! This is a bug: report to horse.`);

+ 6 - 6
bot/src/commands/news_aggregator.ts

@@ -7,7 +7,7 @@ import * as fs from "fs";
 import { HTML2BBCode } from "html2bbcode";
 import { Dict } from "../util";
 import { IAggregator, NewsPostItem } from "./aggregators/aggregator";
-import { RichEmbed, TextChannel, Message, Channel, ReactionCollector, MessageReaction, User, Collector } from "discord.js";
+import { TextChannel, Message, Channel, ReactionCollector, MessageReaction, User, Collector, MessageEmbed } from "discord.js";
 import { getRepository, IsNull, Not } from "typeorm";
 import { KnownChannel } from "@shared/db/entity/KnownChannel";
 import { AggroNewsItem } from "@shared/db/entity/AggroNewsItem";
@@ -77,7 +77,7 @@ export class NewsAggregator {
     async addNewsItem(item: NewsPostItem) {
         let repo = getRepository(AggroNewsItem);
 
-        let ch = client.channels.get(this.aggregateChannelID);
+        let ch = client.channels.resolve(this.aggregateChannelID);
 
         if (!(ch instanceof TextChannel))
             return;
@@ -136,7 +136,7 @@ export class NewsAggregator {
         }
 
 
-        let msg = await ch.send(new RichEmbed({
+        let msg = await ch.send(new MessageEmbed({
             title: item.title,
             url: item.link,
             color: item.embedColor,
@@ -200,7 +200,7 @@ export class NewsAggregator {
     };
 
     async deleteCacheMessage(messageId: string) {
-        let ch = client.channels.get(this.aggregateChannelID);
+        let ch = client.channels.resolve(this.aggregateChannelID);
         if (!(ch instanceof TextChannel))
             return;
 
@@ -214,7 +214,7 @@ export class NewsAggregator {
         try {
             if (!(channel instanceof TextChannel))
                 return null;
-            return await channel.fetchMessage(messageId);
+            return await channel.messages.fetch(messageId);
         } catch (error) {
             return null;
         }
@@ -244,7 +244,7 @@ export class NewsAggregator {
     }
 
     async initPendingReactors() {
-        let verifyChannel = client.channels.get(this.aggregateChannelID);
+        let verifyChannel = client.channels.resolve(this.aggregateChannelID);
 
         let repo = getRepository(AggroNewsItem);
 

+ 34 - 34
bot/src/commands/random_react.ts

@@ -1,35 +1,35 @@
-import { CommandSet, Action, ActionType } from "src/model/command";
-import { Message } from "discord.js";
-import { getRepository } from "typeorm";
-import { RandomMessageReaction } from "@shared/db/entity/RandomMesssageReaction";
-import { client } from "src/client";
-
-const timeout = (ms: number) => new Promise(r => setTimeout(r, ms));
-
-@CommandSet
-export class RandomReact {
-    @Action(ActionType.MESSAGE)
-    async showHelp(actionsDone: boolean, msg: Message) {
-        if(actionsDone)
-            return false;
-
-        let repo = getRepository(RandomMessageReaction);
-
-        let reactInfo = await repo.findOne({ where: { userId: msg.author.id } });
-
-        if(!reactInfo)
-            return false;
-
-        let emote = client.emojis.get(reactInfo.reactionEmoteId);
-
-        if(!emote)
-            return false;
-
-        if(Math.random() < reactInfo.reactProbability) {
-            await timeout(Math.random() * reactInfo.maxWaitMs);
-            await msg.react(emote);
-        }
-        
-        return false;
-    }
+import { CommandSet, Action, ActionType } from "src/model/command";
+import { Message } from "discord.js";
+import { getRepository } from "typeorm";
+import { RandomMessageReaction } from "@shared/db/entity/RandomMesssageReaction";
+import { client } from "src/client";
+
+const timeout = (ms: number) => new Promise(r => setTimeout(r, ms));
+
+@CommandSet
+export class RandomReact {
+    @Action(ActionType.MESSAGE)
+    async showHelp(actionsDone: boolean, msg: Message) {
+        if(actionsDone)
+            return false;
+
+        let repo = getRepository(RandomMessageReaction);
+
+        let reactInfo = await repo.findOne({ where: { userId: msg.author.id } });
+
+        if(!reactInfo)
+            return false;
+
+        let emote = client.emojis.resolve(reactInfo.reactionEmoteId);
+
+        if(!emote)
+            return false;
+
+        if(Math.random() < reactInfo.reactProbability) {
+            await timeout(Math.random() * reactInfo.maxWaitMs);
+            await msg.react(emote);
+        }
+        
+        return false;
+    }
 }

+ 4 - 4
bot/src/commands/react.ts

@@ -38,7 +38,7 @@ export class ReactCommands {
             let reactable = contents[1].trim().toLowerCase();
             let reactionEmoji = contents[2];
 
-            if (!client.emojis.has(reactionEmoji)) {
+            if (!client.emojis.cache.has(reactionEmoji)) {
                 msg.channel.send(`${msg.author.toString()} I cannot react with this emoji :(`);
                 return;
             }
@@ -96,7 +96,7 @@ export class ReactCommands {
         let message = await reactionRepo.findOne({ message: lowerContent });
 
         if (message) {
-            msg.react(client.emojis.get(message.reactionEmoteId));
+            msg.react(client.emojis.resolve(message.reactionEmoteId));
             return true;
         }
 
@@ -128,7 +128,7 @@ export class ReactCommands {
             return false;
 
         for (let emote of randomEmotes)
-            await msg.react(client.emojis.find(e => e.id == emote.reactionId));
+            await msg.react(client.emojis.cache.find(e => e.id == emote.reactionId));
 
         return true;
     }
@@ -159,7 +159,7 @@ export class ReactCommands {
         if (emotes.length != 1)
             return false;
 
-        let emote = client.emojis.find(e => e.id == emotes[0].reactionId);
+        let emote = client.emojis.cache.find(e => e.id == emotes[0].reactionId);
 
         if (!emote) {
             console.log(`WARNING: Emote ${emotes[0]} no longer is valid. Deleting invalid emojis from the list...`);

+ 0 - 4
bot/src/main.ts

@@ -24,8 +24,6 @@ import { createConnection, getConnectionOptions } from "typeorm";
 import { getNumberEnums } from "./util";
 import { DB_ENTITIES } from "@shared/db/entities";
 import { BOT_COMMAND_DESCRIPTOR } from "./model/command";
-import { startServer as startRPCServer } from "./rpc_service";
-
 
 const REACT_PROBABILITY = 0.3;
 
@@ -156,8 +154,6 @@ async function main() {
         entities: DB_ENTITIES
     });
 
-    startRPCServer();
-
     let commandsPath = path.resolve(path.dirname(module.filename), "commands");
     let files = fs.readdirSync(commandsPath);
 

+ 56 - 56
bot/src/model/command.ts

@@ -1,57 +1,57 @@
-import { Message } from "discord.js";
-
-export interface CommandDocumentation {
-    description: string;
-    example: string;
-}
-
-export interface CommandOptions {
-    pattern: string | RegExp;
-    documentation?: CommandDocumentation;
-    auth?: boolean;
-};
-
-export type BotAction = (actionsDone: boolean, m : Message, content: string) => boolean | Promise<boolean>;
-
-export interface ICommand {
-    onStart?(): void | Promise<void>;
-    _botCommands?: IBotCommand[];
-    _botEvents?: { [action in ActionType]?: BotAction };
-    BOT_COMMAND?: string;
-};
-
-export interface IBotCommand extends CommandOptions {
-    action(message: Message, strippedContents: string, matches?: RegExpMatchArray) : void;
-};
-
-export const BOT_COMMAND_DESCRIPTOR = "BOT_COMMAND";
-export function CommandSet<T extends {new(...params: any[]): {}}>(base: T) {
-    base.prototype.BOT_COMMAND = BOT_COMMAND_DESCRIPTOR;
-}
-
-export enum ActionType {
-    MESSAGE,
-    INDIRECT_MENTION,
-    DIRECT_MENTION,
-    POST_MESSAGE
-}
-export function Action(type: ActionType) {
-    return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
-        if(!target._botEvents)
-            target._botEvents= {};
-
-        target._botEvents[type] = descriptor.value;
-    };
-}
-
-export function Command(opts: CommandOptions) {
-    return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
-        if(!target._botCommands)
-            target._botCommands = [];
-
-        target._botCommands.push(<IBotCommand>{
-            action: descriptor.value,
-            ...opts
-        });
-    };
+import { Message } from "discord.js";
+
+export interface CommandDocumentation {
+    description: string;
+    example: string;
+}
+
+export interface CommandOptions {
+    pattern: string | RegExp;
+    documentation?: CommandDocumentation;
+    auth?: boolean;
+};
+
+export type BotAction = (actionsDone: boolean, m : Message, content: string) => boolean | Promise<boolean>;
+
+export interface ICommand {
+    onStart?(): void | Promise<void>;
+    _botCommands?: IBotCommand[];
+    _botEvents?: { [action in ActionType]?: BotAction };
+    BOT_COMMAND?: string;
+};
+
+export interface IBotCommand extends CommandOptions {
+    action(message: Message, strippedContents: string, matches?: RegExpMatchArray) : void;
+};
+
+export const BOT_COMMAND_DESCRIPTOR = "BOT_COMMAND";
+export function CommandSet<T extends {new(...params: any[]): {}}>(base: T) {
+    base.prototype.BOT_COMMAND = BOT_COMMAND_DESCRIPTOR;
+}
+
+export enum ActionType {
+    MESSAGE,
+    INDIRECT_MENTION,
+    DIRECT_MENTION,
+    POST_MESSAGE
+}
+export function Action(type: ActionType) {
+    return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
+        if(!target._botEvents)
+            target._botEvents= {};
+
+        target._botEvents[type] = descriptor.value;
+    };
+}
+
+export function Command(opts: CommandOptions) {
+    return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
+        if(!target._botCommands)
+            target._botCommands = [];
+
+        target._botCommands.push(<IBotCommand>{
+            action: descriptor.value,
+            ...opts
+        });
+    };
 }

+ 51 - 51
bot/src/rpc_service.ts

@@ -1,52 +1,52 @@
-import { createServerRouter } from "typescript-rest-rpc/lib/server";
-import Koa from "koa";
-import KoaRouter from "koa-router";
-import koaBody from "koa-body";
-import { Backend, UserInfo } from "@shared/rpc/backend";
-import { getRepository } from "typeorm";
-import { KnownUser } from "@shared/db/entity/KnownUser";
-import { client } from "./client";
-import { GuildMember } from "discord.js";
-import { isAuthorisedAsync } from "./util";
-
-class BackendImpl implements Backend {
-    async getModeratorUserInfo({ id }: { id: string }): Promise<UserInfo> {
-        let repo = getRepository(KnownUser);
-        
-        let member: GuildMember;
-        for(let [_, guild] of client.guilds) {
-            try {
-                member = await guild.fetchMember(id);
-                break;
-            } catch(e) {
-                member = null;
-            }
-        }
-
-        if(!member || !(await isAuthorisedAsync(member)))
-            throw new Error("User not authorised!");
-
-        return <UserInfo>{
-            userId: member.user.id,
-            username: member.user.tag,
-            avatarURL: member.user.displayAvatarURL,
-            guildId: member.guild.id,
-            timestamp: new Date()
-        };
-    }
-}
-
-const PORT = 3010;
-let app : Koa;
-let router : KoaRouter;
-export function startServer() {
-    app = new Koa();
-    app.use(koaBody({ multipart: true }));
-
-    router = createServerRouter("/rpc", new BackendImpl());
-    app.use(router.routes());
-    app.use(router.allowedMethods());
-
-    app.listen(PORT);
-    console.log(`Started RPC service at ${PORT}`);
+import { createServerRouter } from "typescript-rest-rpc/lib/server";
+import Koa from "koa";
+import KoaRouter from "koa-router";
+import koaBody from "koa-body";
+import { Backend, UserInfo } from "@shared/rpc/backend";
+import { getRepository } from "typeorm";
+import { KnownUser } from "@shared/db/entity/KnownUser";
+import { client } from "./client";
+import { GuildMember } from "discord.js";
+import { isAuthorisedAsync } from "./util";
+
+class BackendImpl implements Backend {
+    async getModeratorUserInfo({ id }: { id: string }): Promise<UserInfo> {
+        let repo = getRepository(KnownUser);
+        
+        let member: GuildMember;
+        for(let [_, guild] of client.guilds) {
+            try {
+                member = await guild.fetchMember(id);
+                break;
+            } catch(e) {
+                member = null;
+            }
+        }
+
+        if(!member || !(await isAuthorisedAsync(member)))
+            throw new Error("User not authorised!");
+
+        return <UserInfo>{
+            userId: member.user.id,
+            username: member.user.tag,
+            avatarURL: member.user.displayAvatarURL,
+            guildId: member.guild.id,
+            timestamp: new Date()
+        };
+    }
+}
+
+const PORT = 3010;
+let app : Koa;
+let router : KoaRouter;
+export function startServer() {
+    app = new Koa();
+    app.use(koaBody({ multipart: true }));
+
+    router = createServerRouter("/rpc", new BackendImpl());
+    app.use(router.routes());
+    app.use(router.allowedMethods());
+
+    app.listen(PORT);
+    console.log(`Started RPC service at ${PORT}`);
 }

+ 31 - 31
bot/src/typedefs/html2bbcode.d.ts

@@ -1,32 +1,32 @@
-declare module "html2bbcode" {
-
-    export interface BBCodeMapping {
-        section: string;
-        attr?: string;
-        data?: string;
-        newline?: number;
-        extend?: string[];
-        empty?: boolean;
-        ignore?: boolean;
-    }
-
-    export class BBCode {
-        static maps: { [htmlNode: string] : BBCodeMapping };
-        constructor();
-        toString() : string;
-    }
-
-    export interface HTML2BBCodeOptions {
-        imagescale?: boolean;
-        transsize?: boolean;
-        nolist?: boolean;
-        noalign?: boolean;
-        noheadings?: boolean;
-        debug?: boolean;
-    }
-
-    export class HTML2BBCode {
-        constructor(opts?: HTML2BBCodeOptions);
-        feed(html: string) : BBCode;
-    }
+declare module "html2bbcode" {
+
+    export interface BBCodeMapping {
+        section: string;
+        attr?: string;
+        data?: string;
+        newline?: number;
+        extend?: string[];
+        empty?: boolean;
+        ignore?: boolean;
+    }
+
+    export class BBCode {
+        static maps: { [htmlNode: string] : BBCodeMapping };
+        constructor();
+        toString() : string;
+    }
+
+    export interface HTML2BBCodeOptions {
+        imagescale?: boolean;
+        transsize?: boolean;
+        nolist?: boolean;
+        noalign?: boolean;
+        noheadings?: boolean;
+        debug?: boolean;
+    }
+
+    export class HTML2BBCode {
+        constructor(opts?: HTML2BBCodeOptions);
+        feed(html: string) : BBCode;
+    }
 }

+ 1 - 1
bot/src/util.ts

@@ -38,7 +38,7 @@ export async function isAuthorisedAsync(member: GuildMember) {
     let role = await repo.findOne({
         select: ["userID"],
         where: {
-            userID: In(member.roles.keyArray()),
+            userID: In(member.roles.cache.keyArray()),
             canModerate: true
         }
     });

+ 7 - 7
db.env.template

@@ -1,8 +1,8 @@
-TYPEORM_CONNECTION=
-TYPEORM_HOST=
-TYPEORM_PORT=
-TYPEORM_SYNCHRONIZE=
-TYPEORM_LOGGING=
-TYPEORM_ENTITIES=
-TYPEORM_MIGRATIONS=
+TYPEORM_CONNECTION=
+TYPEORM_HOST=
+TYPEORM_PORT=
+TYPEORM_SYNCHRONIZE=
+TYPEORM_LOGGING=
+TYPEORM_ENTITIES=
+TYPEORM_MIGRATIONS=
 TYPEORM_SUBSCRIBERS=

+ 37 - 37
shared/src/db/entities.ts

@@ -1,38 +1,38 @@
-import { AggroNewsItem } from "./entity/AggroNewsItem";
-import { DeadChatReply } from "./entity/DeadChatReply";
-import { FaceCaptionMessage } from "./entity/FaceCaptionMessage";
-import { Guide, GuideKeyword } from "./entity/Guide";
-import { KnownChannel } from "./entity/KnownChannel";
-import { KnownUser, User, UserRole } from "./entity/KnownUser";
-import { MessageReaction } from "./entity/MessageReaction";
-import { PostedForumNewsItem } from "./entity/PostedForumsNewsItem";
-import { PostVerifyMessage } from "./entity/PostVerifyMessage";
-import { Quote } from "./entity/Quote";
-import { ReactionEmote } from "./entity/ReactionEmote";
-import { Contest } from "./entity/Contest";
-import { ContestEntry } from "./entity/ContestEntry";
-import { ContestVote } from "./entity/ContestVote";
-import { FileOnlyChannel } from "./entity/FileOnlyChannel";
-import { RandomMessageReaction } from "./entity/RandomMesssageReaction";
-
-export const DB_ENTITIES = [
-    AggroNewsItem,
-    DeadChatReply,
-    FaceCaptionMessage,
-    Guide,
-    GuideKeyword,
-    KnownChannel,
-    KnownUser,
-    User,
-    UserRole,
-    MessageReaction,
-    PostedForumNewsItem,
-    PostVerifyMessage,
-    Quote,
-    ReactionEmote,
-    Contest,
-    ContestEntry,
-    ContestVote,
-    FileOnlyChannel,
-    RandomMessageReaction
+import { AggroNewsItem } from "./entity/AggroNewsItem";
+import { DeadChatReply } from "./entity/DeadChatReply";
+import { FaceCaptionMessage } from "./entity/FaceCaptionMessage";
+import { Guide, GuideKeyword } from "./entity/Guide";
+import { KnownChannel } from "./entity/KnownChannel";
+import { KnownUser, User, UserRole } from "./entity/KnownUser";
+import { MessageReaction } from "./entity/MessageReaction";
+import { PostedForumNewsItem } from "./entity/PostedForumsNewsItem";
+import { PostVerifyMessage } from "./entity/PostVerifyMessage";
+import { Quote } from "./entity/Quote";
+import { ReactionEmote } from "./entity/ReactionEmote";
+import { Contest } from "./entity/Contest";
+import { ContestEntry } from "./entity/ContestEntry";
+import { ContestVote } from "./entity/ContestVote";
+import { FileOnlyChannel } from "./entity/FileOnlyChannel";
+import { RandomMessageReaction } from "./entity/RandomMesssageReaction";
+
+export const DB_ENTITIES = [
+    AggroNewsItem,
+    DeadChatReply,
+    FaceCaptionMessage,
+    Guide,
+    GuideKeyword,
+    KnownChannel,
+    KnownUser,
+    User,
+    UserRole,
+    MessageReaction,
+    PostedForumNewsItem,
+    PostVerifyMessage,
+    Quote,
+    ReactionEmote,
+    Contest,
+    ContestEntry,
+    ContestVote,
+    FileOnlyChannel,
+    RandomMessageReaction
 ];

+ 23 - 23
shared/src/db/entity/AggroNewsItem.ts

@@ -1,23 +1,23 @@
-import {Entity, PrimaryColumn, Column} from "typeorm";
-
-@Entity()
-export class AggroNewsItem {
-
-    @PrimaryColumn()
-    feedName: string;
-
-    @PrimaryColumn()
-    newsId: number;
-
-    @Column()
-    hash: string;
-
-    @Column({ unique: true, nullable: true })
-    editMessageId?: string;
-
-    @Column({ unique: true, nullable: true })
-    forumsNewsPostId?: number;
-
-    @Column({ unique: true, nullable: true })
-    forumsEditPostId?: number;
-}
+import {Entity, PrimaryColumn, Column} from "typeorm";
+
+@Entity()
+export class AggroNewsItem {
+
+    @PrimaryColumn()
+    feedName: string;
+
+    @PrimaryColumn()
+    newsId: number;
+
+    @Column()
+    hash: string;
+
+    @Column({ unique: true, nullable: true })
+    editMessageId?: string;
+
+    @Column({ unique: true, nullable: true })
+    forumsNewsPostId?: number;
+
+    @Column({ unique: true, nullable: true })
+    forumsEditPostId?: number;
+}

+ 41 - 41
shared/src/db/entity/Contest.ts

@@ -1,42 +1,42 @@
-import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm";
-import { ContestEntry } from "./ContestEntry";
-import { ContestVote } from "./ContestVote";
-
-@Entity()
-export class Contest {
-
-    @PrimaryGeneratedColumn()
-    id: number;
-
-    @Column()
-    startDate: Date;
-
-    @Column()
-    endDate: Date;
-
-    @Column()
-    channel: string;
-
-    @Column()
-    announceWinners: boolean;
-
-    @Column()
-    voteReaction: string;
-
-    @Column({ default: 1 })
-    maxWinners: number;
-
-    @Column({ default: true })
-    uniqueWinners: boolean;
-
-    @Column({ default: false })
-    active: boolean;
-
-    // @OneToMany(type => ContestEntry, entry => entry.contest)
-    @OneToMany("ContestEntry", "contest")
-    entries: ContestEntry[];
-
-    // @OneToMany(type => ContestVote, vote => vote.contest)
-    @OneToMany("ContestVote", "contest")
-    votes: ContestVote[];
+import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm";
+import { ContestEntry } from "./ContestEntry";
+import { ContestVote } from "./ContestVote";
+
+@Entity()
+export class Contest {
+
+    @PrimaryGeneratedColumn()
+    id: number;
+
+    @Column()
+    startDate: Date;
+
+    @Column()
+    endDate: Date;
+
+    @Column()
+    channel: string;
+
+    @Column()
+    announceWinners: boolean;
+
+    @Column()
+    voteReaction: string;
+
+    @Column({ default: 1 })
+    maxWinners: number;
+
+    @Column({ default: true })
+    uniqueWinners: boolean;
+
+    @Column({ default: false })
+    active: boolean;
+
+    // @OneToMany(type => ContestEntry, entry => entry.contest)
+    @OneToMany("ContestEntry", "contest")
+    entries: ContestEntry[];
+
+    // @OneToMany(type => ContestVote, vote => vote.contest)
+    @OneToMany("ContestVote", "contest")
+    votes: ContestVote[];
 }

+ 17 - 17
shared/src/db/entity/ContestEntry.ts

@@ -1,18 +1,18 @@
-import {Entity, PrimaryColumn, ManyToOne, OneToMany} from "typeorm";
-import {Contest} from "./Contest";
-import { ContestVote } from "./ContestVote";
-
-@Entity()
-export class ContestEntry {
-
-    @PrimaryColumn()
-    msgId: string;
-
-    // @ManyToOne(type => Contest, contest => contest.entries)
-    @ManyToOne("Contest", "entries")
-    contest: Contest;
-
-    // @OneToMany(type => ContestVote, vote => vote.contestEntry)
-    @OneToMany("ContestVote", "contestEntry")
-    votes: ContestVote[];
+import {Entity, PrimaryColumn, ManyToOne, OneToMany} from "typeorm";
+import {Contest} from "./Contest";
+import { ContestVote } from "./ContestVote";
+
+@Entity()
+export class ContestEntry {
+
+    @PrimaryColumn()
+    msgId: string;
+
+    // @ManyToOne(type => Contest, contest => contest.entries)
+    @ManyToOne("Contest", "entries")
+    contest: Contest;
+
+    // @OneToMany(type => ContestVote, vote => vote.contestEntry)
+    @OneToMany("ContestVote", "contestEntry")
+    votes: ContestVote[];
 }

+ 20 - 20
shared/src/db/entity/ContestVote.ts

@@ -1,21 +1,21 @@
-import { Contest } from "./Contest";
-import { ContestEntry } from "./ContestEntry";
-import { Entity, PrimaryColumn, ManyToOne, Column } from "typeorm";
-
-@Entity()
-export class ContestVote {
-
-    @PrimaryColumn()
-    userId: string;
-
-    @PrimaryColumn()
-    contestId: number;
-
-    // @ManyToOne(type => Contest, contest => contest.votes, { primary: true })
-    @ManyToOne("Contest", "votes", { primary: true })
-    contest: Contest;
-
-    // @ManyToOne(type => ContestEntry, entry => entry.votes)
-    @ManyToOne("ContestEntry", "votes")
-    contestEntry: ContestEntry;
+import { Contest } from "./Contest";
+import { ContestEntry } from "./ContestEntry";
+import { Entity, PrimaryColumn, ManyToOne, Column } from "typeorm";
+
+@Entity()
+export class ContestVote {
+
+    @PrimaryColumn()
+    userId: string;
+
+    @PrimaryColumn()
+    contestId: number;
+
+    // @ManyToOne(type => Contest, contest => contest.votes, { primary: true })
+    @ManyToOne("Contest", "votes", { primary: true })
+    contest: Contest;
+
+    // @ManyToOne(type => ContestEntry, entry => entry.votes)
+    @ManyToOne("ContestEntry", "votes")
+    contestEntry: ContestEntry;
 }

+ 11 - 11
shared/src/db/entity/DeadChatReply.ts

@@ -1,11 +1,11 @@
-import {Entity, PrimaryGeneratedColumn, Column} from "typeorm";
-
-@Entity()
-export class DeadChatReply {
-    
-    @PrimaryGeneratedColumn()
-    id: number;
-
-    @Column()
-    message: string;
-}
+import {Entity, PrimaryGeneratedColumn, Column} from "typeorm";
+
+@Entity()
+export class DeadChatReply {
+    
+    @PrimaryGeneratedColumn()
+    id: number;
+
+    @Column()
+    message: string;
+}

+ 16 - 16
shared/src/db/entity/FaceCaptionMessage.ts

@@ -1,16 +1,16 @@
-import {Entity, PrimaryColumn} from "typeorm";
-
-export enum FaceCaptionType {
-    PREFIX = "prefix",
-    POSTFIX = "postfix"
-}
-
-@Entity()
-export class FaceCaptionMessage {
-    
-    @PrimaryColumn()
-    message: string;
-
-    @PrimaryColumn({ type: "varchar" })
-    type: FaceCaptionType;
-}
+import {Entity, PrimaryColumn} from "typeorm";
+
+export enum FaceCaptionType {
+    PREFIX = "prefix",
+    POSTFIX = "postfix"
+}
+
+@Entity()
+export class FaceCaptionMessage {
+    
+    @PrimaryColumn()
+    message: string;
+
+    @PrimaryColumn({ type: "varchar" })
+    type: FaceCaptionType;
+}

+ 10 - 10
shared/src/db/entity/FileOnlyChannel.ts

@@ -1,11 +1,11 @@
-import { Entity, PrimaryColumn, Column} from "typeorm";
-
-@Entity()
-export class FileOnlyChannel {
-    
-    @PrimaryColumn()
-    channelId: string;
-
-    @Column()
-    warningMessageChannelId: string;
+import { Entity, PrimaryColumn, Column} from "typeorm";
+
+@Entity()
+export class FileOnlyChannel {
+    
+    @PrimaryColumn()
+    channelId: string;
+
+    @Column()
+    warningMessageChannelId: string;
 };

+ 40 - 40
shared/src/db/entity/Guide.ts

@@ -1,40 +1,40 @@
-import {Entity, Column, PrimaryGeneratedColumn, ManyToMany, JoinTable} from "typeorm";
-
-export enum GuideType {
-    GUIDE = "guide",
-    MEME = "meme",
-    MISC = "misc"
-}
-
-@Entity()
-export class Guide {
-    
-    @PrimaryGeneratedColumn()
-    id: number;
-
-    @ManyToMany(type => GuideKeyword, keyword => keyword.relatedGuides)
-    @JoinTable()
-    keywords: GuideKeyword[];
-
-    @Column()
-    displayName: string;
-
-    @Column()
-    content: string;
-
-    @Column({ type: "varchar" })
-    type: GuideType;
-}
-
-@Entity()
-export class GuideKeyword {
-
-    @PrimaryGeneratedColumn()
-    id: number;
-
-    @Column({ unique: true })
-    keyword: string;
-
-    @ManyToMany(type => Guide, guide => guide.keywords)
-    relatedGuides: Guide[];
-}
+import {Entity, Column, PrimaryGeneratedColumn, ManyToMany, JoinTable} from "typeorm";
+
+export enum GuideType {
+    GUIDE = "guide",
+    MEME = "meme",
+    MISC = "misc"
+}
+
+@Entity()
+export class Guide {
+    
+    @PrimaryGeneratedColumn()
+    id: number;
+
+    @ManyToMany(type => GuideKeyword, keyword => keyword.relatedGuides)
+    @JoinTable()
+    keywords: GuideKeyword[];
+
+    @Column()
+    displayName: string;
+
+    @Column()
+    content: string;
+
+    @Column({ type: "varchar" })
+    type: GuideType;
+}
+
+@Entity()
+export class GuideKeyword {
+
+    @PrimaryGeneratedColumn()
+    id: number;
+
+    @Column({ unique: true })
+    keyword: string;
+
+    @ManyToMany(type => Guide, guide => guide.keywords)
+    relatedGuides: Guide[];
+}

+ 17 - 17
shared/src/db/entity/KnownChannel.ts

@@ -1,17 +1,17 @@
-import {Entity, Column, PrimaryGeneratedColumn} from "typeorm";
-
-@Entity()
-export class KnownChannel {
-
-    @PrimaryGeneratedColumn()
-    id: number;
-
-    @Column({ nullable: true })
-    channelType?: string;
-
-    @Column()
-    channelId: string;
-
-    @Column({ type: "decimal", default: 0.0 })
-    faceMorphProbability: number;
-}
+import {Entity, Column, PrimaryGeneratedColumn} from "typeorm";
+
+@Entity()
+export class KnownChannel {
+
+    @PrimaryGeneratedColumn()
+    id: number;
+
+    @Column({ nullable: true })
+    channelType?: string;
+
+    @Column()
+    channelId: string;
+
+    @Column({ type: "decimal", default: 0.0 })
+    faceMorphProbability: number;
+}

+ 24 - 24
shared/src/db/entity/KnownUser.ts

@@ -1,25 +1,25 @@
-import {Entity, TableInheritance, PrimaryColumn, Column, ChildEntity} from "typeorm";
-import { ReactionType } from "./ReactionEmote";
-
-@Entity()
-@TableInheritance({ column: { type: "varchar", name: "type" } })
-export class KnownUser {
-    
-    @PrimaryColumn()
-    userID: string;
-
-    @Column({ default: false })
-    canModerate: boolean;
-
-    @Column({ type: "varchar", default: ReactionType.NONE })
-    replyReactionType: ReactionType;
-
-    @Column({ type: "varchar", default: ReactionType.NONE })
-    mentionReactionType: ReactionType;
-}
-
-@ChildEntity()
-export class User extends KnownUser { }
-
-@ChildEntity()
+import {Entity, TableInheritance, PrimaryColumn, Column, ChildEntity} from "typeorm";
+import { ReactionType } from "./ReactionEmote";
+
+@Entity()
+@TableInheritance({ column: { type: "varchar", name: "type" } })
+export class KnownUser {
+    
+    @PrimaryColumn()
+    userID: string;
+
+    @Column({ default: false })
+    canModerate: boolean;
+
+    @Column({ type: "varchar", default: ReactionType.NONE })
+    replyReactionType: ReactionType;
+
+    @Column({ type: "varchar", default: ReactionType.NONE })
+    mentionReactionType: ReactionType;
+}
+
+@ChildEntity()
+export class User extends KnownUser { }
+
+@ChildEntity()
 export class UserRole extends KnownUser { }

+ 11 - 11
shared/src/db/entity/MessageReaction.ts

@@ -1,11 +1,11 @@
-import {Entity, PrimaryColumn, Column} from "typeorm";
-
-@Entity()
-export class MessageReaction {
-    
-    @PrimaryColumn()
-    message: string;
-
-    @Column()
-    reactionEmoteId: string;
-}
+import {Entity, PrimaryColumn, Column} from "typeorm";
+
+@Entity()
+export class MessageReaction {
+    
+    @PrimaryColumn()
+    message: string;
+
+    @Column()
+    reactionEmoteId: string;
+}

+ 26 - 26
shared/src/db/entity/PostVerifyMessage.ts

@@ -1,26 +1,26 @@
-import {Entity, PrimaryGeneratedColumn, Column} from "typeorm";
-
-@Entity()
-export class PostVerifyMessage {
-
-    @PrimaryGeneratedColumn()
-    id: number;
-
-    @Column()
-    messageId: string;
-
-    @Column()
-    author: string;
-
-    @Column()
-    title: string;
-
-    @Column()
-    link: string;
-
-    @Column()
-    text: string;
-
-    @Column()
-    isNew: boolean;
-}
+import {Entity, PrimaryGeneratedColumn, Column} from "typeorm";
+
+@Entity()
+export class PostVerifyMessage {
+
+    @PrimaryGeneratedColumn()
+    id: number;
+
+    @Column()
+    messageId: string;
+
+    @Column()
+    author: string;
+
+    @Column()
+    title: string;
+
+    @Column()
+    link: string;
+
+    @Column()
+    text: string;
+
+    @Column()
+    isNew: boolean;
+}

+ 14 - 14
shared/src/db/entity/Quote.ts

@@ -1,14 +1,14 @@
-import {Entity, PrimaryGeneratedColumn, Column} from "typeorm";
-
-@Entity()
-export class Quote {
-
-    @PrimaryGeneratedColumn()
-    id: number;
-
-    @Column()
-    author: string;
-
-    @Column()
-    message: string;
-}
+import {Entity, PrimaryGeneratedColumn, Column} from "typeorm";
+
+@Entity()
+export class Quote {
+
+    @PrimaryGeneratedColumn()
+    id: number;
+
+    @Column()
+    author: string;
+
+    @Column()
+    message: string;
+}

+ 17 - 17
shared/src/db/entity/RandomMesssageReaction.ts

@@ -1,17 +1,17 @@
-import {Entity, PrimaryColumn, Column} from "typeorm";
-
-@Entity()
-export class RandomMessageReaction {
-    
-    @PrimaryColumn()
-    userId: string;
-
-    @Column()
-    reactionEmoteId: string;
-
-    @Column({ type: "decimal", default: 0.0 })
-    reactProbability: number;
-
-    @Column({ type: "decimal", default: 0.0 })
-    maxWaitMs: number;
-}
+import {Entity, PrimaryColumn, Column} from "typeorm";
+
+@Entity()
+export class RandomMessageReaction {
+    
+    @PrimaryColumn()
+    userId: string;
+
+    @Column()
+    reactionEmoteId: string;
+
+    @Column({ type: "decimal", default: 0.0 })
+    reactProbability: number;
+
+    @Column({ type: "decimal", default: 0.0 })
+    maxWaitMs: number;
+}

+ 19 - 19
shared/src/db/entity/ReactionEmote.ts

@@ -1,19 +1,19 @@
-import {Entity, PrimaryColumn} from "typeorm";
-
-export enum ReactionType {
-    NONE = "none",
-    ANGERY = "angery",
-    HUG = "hug",
-    BIG = "big",
-    DED = "ded"
-};
-
-@Entity()
-export class ReactionEmote {
-    
-    @PrimaryColumn({ type: "varchar" })
-    type: ReactionType;
-
-    @PrimaryColumn()
-    reactionId: string;
-}
+import {Entity, PrimaryColumn} from "typeorm";
+
+export enum ReactionType {
+    NONE = "none",
+    ANGERY = "angery",
+    HUG = "hug",
+    BIG = "big",
+    DED = "ded"
+};
+
+@Entity()
+export class ReactionEmote {
+    
+    @PrimaryColumn({ type: "varchar" })
+    type: ReactionType;
+
+    @PrimaryColumn()
+    reactionId: string;
+}

+ 10 - 10
shared/src/rpc/backend.ts

@@ -1,11 +1,11 @@
-export interface Backend {
-    getModeratorUserInfo({ id }: { id: string }): Promise<UserInfo>;
-}
-
-export interface UserInfo {
-    username: string;
-    avatarURL: string;
-    userId: string;
-    guildId: string;
-    timestamp: Date;
+export interface Backend {
+    getModeratorUserInfo({ id }: { id: string }): Promise<UserInfo>;
+}
+
+export interface UserInfo {
+    username: string;
+    avatarURL: string;
+    userId: string;
+    guildId: string;
+    timestamp: Date;
 }

+ 6 - 6
web/.gitignore

@@ -1,6 +1,6 @@
-.DS_Store
-/node_modules/
-/src/node_modules/@sapper/
-yarn-error.log
-/cypress/screenshots/
-/__sapper__/
+.DS_Store
+/node_modules/
+/src/node_modules/@sapper/
+yarn-error.log
+/cypress/screenshots/
+/__sapper__/

+ 26 - 26
web/Dockerfile

@@ -1,27 +1,27 @@
-FROM node:10-alpine
-
-RUN apk --no-cache add make
-
-WORKDIR /app
-
-COPY ./shared/package.json ./shared/package.json
-WORKDIR /app/shared
-RUN npm install
-
-WORKDIR /app
-
-COPY ./web/package.json ./web/package.json
-WORKDIR /app/web
-RUN npm install
-
-WORKDIR /app
-
-COPY ./shared ./shared
-COPY ./web ./web
-COPY ./Makefile ./Makefile
-
-RUN make build_web
-
-WORKDIR /app/web
-EXPOSE 3000
+FROM node:14-alpine
+
+RUN apk --no-cache add make
+
+WORKDIR /app
+
+COPY ./shared/package.json ./shared/package.json
+WORKDIR /app/shared
+RUN npm install
+
+WORKDIR /app
+
+COPY ./web/package.json ./web/package.json
+WORKDIR /app/web
+RUN npm install
+
+WORKDIR /app
+
+COPY ./shared ./shared
+COPY ./web ./web
+COPY ./Makefile ./Makefile
+
+RUN make build_web
+
+WORKDIR /app/web
+EXPOSE 3000
 CMD npm start

+ 3 - 3
web/cypress.json

@@ -1,4 +1,4 @@
-{
-	"baseUrl": "http://localhost:3000",
-	"video": false
+{
+	"baseUrl": "http://localhost:3000",
+	"video": false
 }

+ 4 - 4
web/cypress/fixtures/example.json

@@ -1,5 +1,5 @@
-{
-  "name": "Using fixtures to represent data",
-  "email": "hello@cypress.io",
-  "body": "Fixtures are a great way to mock data for responses to routes"
+{
+  "name": "Using fixtures to represent data",
+  "email": "hello@cypress.io",
+  "body": "Fixtures are a great way to mock data for responses to routes"
 }

+ 18 - 18
web/cypress/integration/spec.js

@@ -1,19 +1,19 @@
-describe('Sapper template app', () => {
-	beforeEach(() => {
-		cy.visit('/')
-	});
-
-	it('has the correct <h1>', () => {
-		cy.contains('h1', 'Great success!')
-	});
-
-	it('navigates to /about', () => {
-		cy.get('nav a').contains('about').click();
-		cy.url().should('include', '/about');
-	});
-
-	it('navigates to /blog', () => {
-		cy.get('nav a').contains('blog').click();
-		cy.url().should('include', '/blog');
-	});
+describe('Sapper template app', () => {
+	beforeEach(() => {
+		cy.visit('/')
+	});
+
+	it('has the correct <h1>', () => {
+		cy.contains('h1', 'Great success!')
+	});
+
+	it('navigates to /about', () => {
+		cy.get('nav a').contains('about').click();
+		cy.url().should('include', '/about');
+	});
+
+	it('navigates to /blog', () => {
+		cy.get('nav a').contains('blog').click();
+		cy.url().should('include', '/blog');
+	});
 });

+ 17 - 17
web/cypress/plugins/index.js

@@ -1,17 +1,17 @@
-// ***********************************************************
-// This example plugins/index.js can be used to load plugins
-//
-// You can change the location of this file or turn off loading
-// the plugins file with the 'pluginsFile' configuration option.
-//
-// You can read more here:
-// https://on.cypress.io/plugins-guide
-// ***********************************************************
-
-// This function is called when a project is opened or re-opened (e.g. due to
-// the project's config changing)
-
-module.exports = (on, config) => {
-  // `on` is used to hook into various events Cypress emits
-  // `config` is the resolved Cypress config
-}
+// ***********************************************************
+// This example plugins/index.js can be used to load plugins
+//
+// You can change the location of this file or turn off loading
+// the plugins file with the 'pluginsFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/plugins-guide
+// ***********************************************************
+
+// This function is called when a project is opened or re-opened (e.g. due to
+// the project's config changing)
+
+module.exports = (on, config) => {
+  // `on` is used to hook into various events Cypress emits
+  // `config` is the resolved Cypress config
+}

+ 25 - 25
web/cypress/support/commands.js

@@ -1,25 +1,25 @@
-// ***********************************************
-// This example commands.js shows you how to
-// create various custom commands and overwrite
-// existing commands.
-//
-// For more comprehensive examples of custom
-// commands please read more here:
-// https://on.cypress.io/custom-commands
-// ***********************************************
-//
-//
-// -- This is a parent command --
-// Cypress.Commands.add("login", (email, password) => { ... })
-//
-//
-// -- This is a child command --
-// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
-//
-//
-// -- This is a dual command --
-// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
-//
-//
-// -- This is will overwrite an existing command --
-// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add("login", (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This is will overwrite an existing command --
+// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })

+ 20 - 20
web/cypress/support/index.js

@@ -1,20 +1,20 @@
-// ***********************************************************
-// This example support/index.js is processed and
-// loaded automatically before your test files.
-//
-// This is a great place to put global configuration and
-// behavior that modifies Cypress.
-//
-// You can change the location of this file or turn off
-// automatically serving support files with the
-// 'supportFile' configuration option.
-//
-// You can read more here:
-// https://on.cypress.io/configuration
-// ***********************************************************
-
-// Import commands.js using ES2015 syntax:
-import './commands'
-
-// Alternatively you can use CommonJS syntax:
-// require('./commands')
+// ***********************************************************
+// This example support/index.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')

+ 64 - 64
web/package.json

@@ -1,64 +1,64 @@
-{
-  "name": "noctbot-web",
-  "description": "NoctBot web frontend",
-  "version": "0.0.1",
-  "scripts": {
-    "dev": "sapper dev",
-    "build": "sapper build --legacy",
-    "export": "sapper export --legacy",
-    "start": "node __sapper__/build",
-    "cy:run": "cypress run",
-    "cy:open": "cypress open",
-    "test": "run-p --race dev cy:run"
-  },
-  "dependencies": {
-    "@types/btoa": "^1.2.3",
-    "@types/compression": "0.0.36",
-    "@types/cookie-session": "^2.0.37",
-    "@types/dotenv": "^6.1.1",
-    "@types/express": "^4.17.0",
-    "@types/node-fetch": "^2.5.0",
-    "@types/request-promise-native": "^1.0.16",
-    "autoprefixer": "^9.6.1",
-    "btoa": "^1.2.1",
-    "bulma": "^0.7.5",
-    "compression": "^1.7.1",
-    "cookie-session": "^1.3.3",
-    "dotenv": "^8.0.0",
-    "express": "^4.17.1",
-    "node-fetch": "^2.6.0",
-    "pg": "^7.11.0",
-    "polka": "^0.5.0",
-    "postcss-import": "^12.0.1",
-    "postcss-nested": "^4.1.2",
-    "request": "^2.88.0",
-    "request-promise-native": "^1.0.7",
-    "sirv": "^0.4.0",
-    "tailwindcss": "^1.1.2",
-    "typeorm": "^0.2.18",
-    "typescript-rest-rpc": "^1.0.10"
-  },
-  "devDependencies": {
-    "@babel/core": "^7.0.0",
-    "@babel/plugin-syntax-dynamic-import": "^7.0.0",
-    "@babel/plugin-transform-runtime": "^7.0.0",
-    "@babel/preset-env": "^7.0.0",
-    "@babel/runtime": "^7.0.0",
-    "node-sass": "^4.12.0",
-    "npm-run-all": "^4.1.5",
-    "postcss": "^7.0.17",
-    "rollup": "^1.12.0",
-    "rollup-plugin-babel": "^4.0.2",
-    "rollup-plugin-commonjs": "^10.0.0",
-    "rollup-plugin-node-resolve": "^5.2.0",
-    "rollup-plugin-replace": "^2.0.0",
-    "rollup-plugin-svelte": "^5.0.1",
-    "rollup-plugin-terser": "^4.0.4",
-    "rollup-plugin-typescript2": "^0.22.0",
-    "sapper": "^0.27.0",
-    "svelte": "^3.0.0",
-    "svelte-preprocess": "^2.15.1",
-    "svelte-preprocess-sass": "^0.2.0",
-    "typescript": "^3.5.3"
-  }
-}
+{
+  "name": "noctbot-web",
+  "description": "NoctBot web frontend",
+  "version": "0.0.1",
+  "scripts": {
+    "dev": "sapper dev",
+    "build": "sapper build --legacy",
+    "export": "sapper export --legacy",
+    "start": "node __sapper__/build",
+    "cy:run": "cypress run",
+    "cy:open": "cypress open",
+    "test": "run-p --race dev cy:run"
+  },
+  "dependencies": {
+    "@types/btoa": "^1.2.3",
+    "@types/compression": "0.0.36",
+    "@types/cookie-session": "^2.0.37",
+    "@types/dotenv": "^6.1.1",
+    "@types/express": "^4.17.0",
+    "@types/node-fetch": "^2.5.0",
+    "@types/request-promise-native": "^1.0.16",
+    "autoprefixer": "^9.6.1",
+    "btoa": "^1.2.1",
+    "bulma": "^0.7.5",
+    "compression": "^1.7.1",
+    "cookie-session": "^1.3.3",
+    "dotenv": "^8.0.0",
+    "express": "^4.17.1",
+    "node-fetch": "^2.6.0",
+    "pg": "^7.11.0",
+    "polka": "^0.5.0",
+    "postcss-import": "^12.0.1",
+    "postcss-nested": "^4.1.2",
+    "request": "^2.88.0",
+    "request-promise-native": "^1.0.7",
+    "sirv": "^0.4.0",
+    "tailwindcss": "^1.1.2",
+    "typeorm": "^0.2.18",
+    "typescript-rest-rpc": "^1.0.10"
+  },
+  "devDependencies": {
+    "@babel/core": "^7.0.0",
+    "@babel/plugin-syntax-dynamic-import": "^7.0.0",
+    "@babel/plugin-transform-runtime": "^7.0.0",
+    "@babel/preset-env": "^7.0.0",
+    "@babel/runtime": "^7.0.0",
+    "node-sass": "^4.12.0",
+    "npm-run-all": "^4.1.5",
+    "postcss": "^7.0.17",
+    "rollup": "^1.12.0",
+    "rollup-plugin-babel": "^4.0.2",
+    "rollup-plugin-commonjs": "^10.0.0",
+    "rollup-plugin-node-resolve": "^5.2.0",
+    "rollup-plugin-replace": "^2.0.0",
+    "rollup-plugin-svelte": "^5.0.1",
+    "rollup-plugin-terser": "^4.0.4",
+    "rollup-plugin-typescript2": "^0.22.0",
+    "sapper": "^0.27.0",
+    "svelte": "^3.0.0",
+    "svelte-preprocess": "^2.15.1",
+    "svelte-preprocess-sass": "^0.2.0",
+    "typescript": "^3.5.3"
+  }
+}

+ 131 - 131
web/rollup.config.js

@@ -1,131 +1,131 @@
-import resolve from 'rollup-plugin-node-resolve';
-import replace from 'rollup-plugin-replace';
-import commonjs from 'rollup-plugin-commonjs';
-import svelte from 'rollup-plugin-svelte';
-import babel from 'rollup-plugin-babel';
-import { terser } from 'rollup-plugin-terser';
-import config from 'sapper/config/rollup.js';
-import pkg from './package.json';
-import typescript from 'rollup-plugin-typescript2';
-import autoPreprocess from 'svelte-preprocess';
-
-const mode = process.env.NODE_ENV;
-const dev = mode === 'development';
-const legacy = !!process.env.SAPPER_LEGACY_BUILD;
-
-const onwarn = (warning, onwarn) => (warning.code === 'CIRCULAR_DEPENDENCY' && /[/\\]@sapper[/\\]/.test(warning.message)) || onwarn(warning);
-const dedupe = importee => importee === 'svelte' || importee.startsWith('svelte/');
-
-
-const preprocessOptions = {
-	scss: false,
-	transformers: {
-		// scss: {
-		// 	includePaths: [
-		// 		'node_modules',
-		// 		'src'
-		// 	]
-		// },
-		postcss: {
-			plugins: [
-				require('postcss-import'),
-				require('tailwindcss'),
-				require('postcss-nested'),
-				require('autoprefixer'),
-			]
-		}
-	},
-}
-
-export default {
-	client: {
-		input: config.client.input(),
-		output: config.client.output(),
-		plugins: [
-			replace({
-				'process.browser': true,
-				'process.env.NODE_ENV': JSON.stringify(mode)
-			}),
-			svelte({
-				dev,
-				hydratable: true,
-				emitCss: true,
-				preprocess: autoPreprocess(preprocessOptions)
-			}),
-			resolve({
-				browser: true,
-				dedupe
-			}),
-			commonjs(),
-
-			legacy && babel({
-				extensions: ['.js', '.mjs', '.html', '.svelte'],
-				runtimeHelpers: true,
-				exclude: ['node_modules/@babel/**'],
-				presets: [
-					['@babel/preset-env', {
-						targets: '> 0.25%, not dead'
-					}]
-				],
-				plugins: [
-					'@babel/plugin-syntax-dynamic-import',
-					['@babel/plugin-transform-runtime', {
-						useESModules: true
-					}]
-				]
-			}),
-
-			!dev && terser({
-				module: true
-			})
-		],
-
-		onwarn,
-	},
-
-	server: {
-		input: { server: config.server.input().server.replace(/\.js$/, '.ts') },
-		output: {
-			...config.server.output(),
-			sourcemap: process.env.NODE_ENV == "development"
-		},
-		plugins: [
-			replace({
-				'process.browser': false,
-				'process.env.NODE_ENV': JSON.stringify(mode)
-			}),
-			svelte({
-				generate: 'ssr',
-				dev,
-				preprocess: autoPreprocess(preprocessOptions)
-			}),
-			resolve({
-				dedupe,
-				extensions: ['.mjs', '.js', '.ts', '.json']
-			}),
-			commonjs(),
-			typescript()
-		],
-		external: Object.keys(pkg.dependencies).concat(
-			require('module').builtinModules || Object.keys(process.binding('natives'))
-		),
-
-		onwarn,
-	},
-
-	serviceworker: {
-		input: config.serviceworker.input(),
-		output: config.serviceworker.output(),
-		plugins: [
-			resolve(),
-			replace({
-				'process.browser': true,
-				'process.env.NODE_ENV': JSON.stringify(mode)
-			}),
-			commonjs(),
-			!dev && terser()
-		],
-
-		onwarn,
-	}
-};
+import resolve from 'rollup-plugin-node-resolve';
+import replace from 'rollup-plugin-replace';
+import commonjs from 'rollup-plugin-commonjs';
+import svelte from 'rollup-plugin-svelte';
+import babel from 'rollup-plugin-babel';
+import { terser } from 'rollup-plugin-terser';
+import config from 'sapper/config/rollup.js';
+import pkg from './package.json';
+import typescript from 'rollup-plugin-typescript2';
+import autoPreprocess from 'svelte-preprocess';
+
+const mode = process.env.NODE_ENV;
+const dev = mode === 'development';
+const legacy = !!process.env.SAPPER_LEGACY_BUILD;
+
+const onwarn = (warning, onwarn) => (warning.code === 'CIRCULAR_DEPENDENCY' && /[/\\]@sapper[/\\]/.test(warning.message)) || onwarn(warning);
+const dedupe = importee => importee === 'svelte' || importee.startsWith('svelte/');
+
+
+const preprocessOptions = {
+	scss: false,
+	transformers: {
+		// scss: {
+		// 	includePaths: [
+		// 		'node_modules',
+		// 		'src'
+		// 	]
+		// },
+		postcss: {
+			plugins: [
+				require('postcss-import'),
+				require('tailwindcss'),
+				require('postcss-nested'),
+				require('autoprefixer'),
+			]
+		}
+	},
+}
+
+export default {
+	client: {
+		input: config.client.input(),
+		output: config.client.output(),
+		plugins: [
+			replace({
+				'process.browser': true,
+				'process.env.NODE_ENV': JSON.stringify(mode)
+			}),
+			svelte({
+				dev,
+				hydratable: true,
+				emitCss: true,
+				preprocess: autoPreprocess(preprocessOptions)
+			}),
+			resolve({
+				browser: true,
+				dedupe
+			}),
+			commonjs(),
+
+			legacy && babel({
+				extensions: ['.js', '.mjs', '.html', '.svelte'],
+				runtimeHelpers: true,
+				exclude: ['node_modules/@babel/**'],
+				presets: [
+					['@babel/preset-env', {
+						targets: '> 0.25%, not dead'
+					}]
+				],
+				plugins: [
+					'@babel/plugin-syntax-dynamic-import',
+					['@babel/plugin-transform-runtime', {
+						useESModules: true
+					}]
+				]
+			}),
+
+			!dev && terser({
+				module: true
+			})
+		],
+
+		onwarn,
+	},
+
+	server: {
+		input: { server: config.server.input().server.replace(/\.js$/, '.ts') },
+		output: {
+			...config.server.output(),
+			sourcemap: process.env.NODE_ENV == "development"
+		},
+		plugins: [
+			replace({
+				'process.browser': false,
+				'process.env.NODE_ENV': JSON.stringify(mode)
+			}),
+			svelte({
+				generate: 'ssr',
+				dev,
+				preprocess: autoPreprocess(preprocessOptions)
+			}),
+			resolve({
+				dedupe,
+				extensions: ['.mjs', '.js', '.ts', '.json']
+			}),
+			commonjs(),
+			typescript()
+		],
+		external: Object.keys(pkg.dependencies).concat(
+			require('module').builtinModules || Object.keys(process.binding('natives'))
+		),
+
+		onwarn,
+	},
+
+	serviceworker: {
+		input: config.serviceworker.input(),
+		output: config.serviceworker.output(),
+		plugins: [
+			resolve(),
+			replace({
+				'process.browser': true,
+				'process.env.NODE_ENV': JSON.stringify(mode)
+			}),
+			commonjs(),
+			!dev && terser()
+		],
+
+		onwarn,
+	}
+};

+ 4 - 4
web/src/client.js

@@ -1,5 +1,5 @@
-import * as sapper from '@sapper/app';
-
-sapper.start({
-	target: document.querySelector('#sapper')
+import * as sapper from '@sapper/app';
+
+sapper.start({
+	target: document.querySelector('#sapper')
 });

+ 54 - 54
web/src/components/Nav.svelte

@@ -1,54 +1,54 @@
-<script>
-  import { stores } from "@sapper/app";
-  let { session } = stores();
-  export let segment;
-</script>
-
-<style lang="css">
-  nav {
-    @apply .w-2/12 .bg-gray-800 .rounded-l-sm .overflow-y-auto .flex .flex-col;
-  }
-
-  .user-box {
-    @apply .flex .items-center p-2 bg-gray-dark;
-
-    & > img {
-      @apply .w-1/5 .rounded-full;
-    }
-
-    & > div {
-      @apply .m-2 .font-bold .text-gray-100;
-    }
-  }
-
-  .button-list {
-    @apply .flex .flex-col .m-2 p-2;
-
-    & > h2 {
-      @apply .text-lg .my-1 .text-white .font-bold .uppercase .mt-2;
-    }
-
-    & > a {
-      @apply .bg-gray-800 .px-2 .py-1 .rounded-sm .font-medium;
-
-      &:hover {
-        @apply .bg-gray-700;
-      }
-
-      &[data-active="true"] {
-        @apply .bg-blue-800;
-      }
-    }
-  }
-</style>
-
-<nav role="navigation">
-  <div class="user-box">
-    <img alt="User Avatar" src={$session.user.avatarURL} />
-    <div>{$session.user.username}</div>
-  </div>
-  <div class="button-list">
-    <h2>Features</h2>
-    <a data-active={segment === 'contest'} href="dashboard/contest">Contest</a>
-  </div>
-</nav>
+<script>
+  import { stores } from "@sapper/app";
+  let { session } = stores();
+  export let segment;
+</script>
+
+<style lang="css">
+  nav {
+    @apply .w-2/12 .bg-gray-800 .rounded-l-sm .overflow-y-auto .flex .flex-col;
+  }
+
+  .user-box {
+    @apply .flex .items-center p-2 bg-gray-dark;
+
+    & > img {
+      @apply .w-1/5 .rounded-full;
+    }
+
+    & > div {
+      @apply .m-2 .font-bold .text-gray-100;
+    }
+  }
+
+  .button-list {
+    @apply .flex .flex-col .m-2 p-2;
+
+    & > h2 {
+      @apply .text-lg .my-1 .text-white .font-bold .uppercase .mt-2;
+    }
+
+    & > a {
+      @apply .bg-gray-800 .px-2 .py-1 .rounded-sm .font-medium;
+
+      &:hover {
+        @apply .bg-gray-700;
+      }
+
+      &[data-active="true"] {
+        @apply .bg-blue-800;
+      }
+    }
+  }
+</style>
+
+<nav role="navigation">
+  <div class="user-box">
+    <img alt="User Avatar" src={$session.user.avatarURL} />
+    <div>{$session.user.username}</div>
+  </div>
+  <div class="button-list">
+    <h2>Features</h2>
+    <a data-active={segment === 'contest'} href="dashboard/contest">Contest</a>
+  </div>
+</nav>

+ 40 - 40
web/src/routes/_error.svelte

@@ -1,40 +1,40 @@
-<script>
-	export let status;
-	export let error;
-
-	const dev = process.env.NODE_ENV === 'development';
-</script>
-
-<style>
-	h1, p {
-		margin: 0 auto;
-	}
-
-	h1 {
-		font-size: 2.8em;
-		font-weight: 700;
-		margin: 0 0 0.5em 0;
-	}
-
-	p {
-		margin: 1em auto;
-	}
-
-	@media (min-width: 480px) {
-		h1 {
-			font-size: 4em;
-		}
-	}
-</style>
-
-<svelte:head>
-	<title>{status}</title>
-</svelte:head>
-
-<h1>{status}</h1>
-
-<p>{error.message}</p>
-
-{#if dev && error.stack}
-	<pre>{error.stack}</pre>
-{/if}
+<script>
+	export let status;
+	export let error;
+
+	const dev = process.env.NODE_ENV === 'development';
+</script>
+
+<style>
+	h1, p {
+		margin: 0 auto;
+	}
+
+	h1 {
+		font-size: 2.8em;
+		font-weight: 700;
+		margin: 0 0 0.5em 0;
+	}
+
+	p {
+		margin: 1em auto;
+	}
+
+	@media (min-width: 480px) {
+		h1 {
+			font-size: 4em;
+		}
+	}
+</style>
+
+<svelte:head>
+	<title>{status}</title>
+</svelte:head>
+
+<h1>{status}</h1>
+
+<p>{error.message}</p>
+
+{#if dev && error.stack}
+	<pre>{error.stack}</pre>
+{/if}

+ 16 - 16
web/src/routes/_layout.svelte

@@ -1,16 +1,16 @@
-<script context="module">
-  export async function preload(page, session) {
-    const { user } = session;
-    if (!session.user && !page.path.startsWith("/login"))
-      return this.redirect(302, "login");
-    return { user };
-  }
-</script>
-
-<style global lang="css">
-  @import "../style/main.css";
-</style>
-
-<main>
-  <slot />
-</main>
+<script context="module">
+  export async function preload(page, session) {
+    const { user } = session;
+    if (!session.user && !page.path.startsWith("/login"))
+      return this.redirect(302, "login");
+    return { user };
+  }
+</script>
+
+<style global lang="css">
+  @import "../style/main.css";
+</style>
+
+<main>
+  <slot />
+</main>

+ 6 - 6
web/src/routes/about.svelte

@@ -1,7 +1,7 @@
-<svelte:head>
-	<title>About</title>
-</svelte:head>
-
-<h1>About this site</h1>
-
+<svelte:head>
+	<title>About</title>
+</svelte:head>
+
+<h1>About this site</h1>
+
 <p>This is the 'about' page. There's not much here.</p>

+ 48 - 48
web/src/routes/dashboard/_layout.svelte

@@ -1,48 +1,48 @@
-<script>
-  import Nav from "../../components/Nav.svelte";
-  export let segment;
-</script>
-
-<style lang="css">
-  .frame {
-    @apply w-screen h-screen m-auto .flex .items-center .justify-center;
-
-    & > div {
-      @apply w-full h-full;
-    }
-  }
-
-  @screen xl {
-    .frame {
-      & > div {
-        @apply .w-10/12;
-        height: calc(10 / 12 * 100%);
-      }
-    }
-  }
-
-  .button {
-    @apply .bg-gray-800 .px-2 .py-1 .rounded-sm .font-medium;
-
-    &:hover {
-      @apply .bg-gray-700;
-    }
-
-    &[data-active="true"] {
-      @apply .bg-blue-800;
-    }
-  }
-
-  .main-view {
-    @apply .bg-gray-700 .w-10/12 .overflow-y-auto .rounded-r-sm .p-2;
-  }
-</style>
-
-<div class="frame">
-  <div class="flex shadow-big">
-    <Nav {segment} />
-    <section class="main-view ">
-      <slot />
-    </section>
-  </div>
-</div>
+<script>
+  import Nav from "../../components/Nav.svelte";
+  export let segment;
+</script>
+
+<style lang="css">
+  .frame {
+    @apply w-screen h-screen m-auto .flex .items-center .justify-center;
+
+    & > div {
+      @apply w-full h-full;
+    }
+  }
+
+  @screen xl {
+    .frame {
+      & > div {
+        @apply .w-10/12;
+        height: calc(10 / 12 * 100%);
+      }
+    }
+  }
+
+  .button {
+    @apply .bg-gray-800 .px-2 .py-1 .rounded-sm .font-medium;
+
+    &:hover {
+      @apply .bg-gray-700;
+    }
+
+    &[data-active="true"] {
+      @apply .bg-blue-800;
+    }
+  }
+
+  .main-view {
+    @apply .bg-gray-700 .w-10/12 .overflow-y-auto .rounded-r-sm .p-2;
+  }
+</style>
+
+<div class="frame">
+  <div class="flex shadow-big">
+    <Nav {segment} />
+    <section class="main-view ">
+      <slot />
+    </section>
+  </div>
+</div>

+ 66 - 66
web/src/routes/dashboard/contest/_components/AddContestForm.svelte

@@ -1,66 +1,66 @@
-<script>
-  import EmojiSelector from "./EmojiSelector.svelte";
-  import DurationInput from "./DurationInput.svelte";
-
-  import { createEventDispatcher } from "svelte";
-  const dispatch = createEventDispatcher();
-
-  function submit(e) {
-    e.preventDefault();
-  }
-</script>
-
-<style>
-  div.emoji-selector {
-    @apply .flex;
-
-    & input[type="radio"] {
-      @apply hidden;
-    }
-  }
-
-  input[type="submit"] {
-    flex-grow: 0 !important;
-  }
-
-  span.info {
-    @əpply italic pl-10;
-  }
-</style>
-
-<form class="form-ctrl" on:submit={submit}>
-  <div>
-    <label for="channel-id">Channel</label>
-    <select id="channel-id">
-      <option value="01312">#test</option>
-      <option value="01313">#test2</option>
-      <option value="01314">#test3</option>
-    </select>
-  </div>
-  <div>
-    <label for="entry-duration">Entry duration</label>
-    <DurationInput id="entry-duration" unitId="entry-duration-unit" />
-  </div>
-  <div>
-    <label for="voting-duration">Voting duration</label>
-    <DurationInput id="voting-duration" unitId="voting-duration-unit" />
-  </div>
-  <div>
-    <div>Voting emoji</div>
-    <EmojiSelector />
-  </div>
-  <div>
-    <label for="max-winners">Max winners</label>
-    <input
-      id="max-winners"
-      name="max-winners"
-      type="number"
-      step="1"
-      min="1"
-      value="1" />
-  </div>
-  <div>
-    <div />
-    <input class="btn" type="submit" value="Create competition" />
-  </div>
-</form>
+<script>
+  import EmojiSelector from "./EmojiSelector.svelte";
+  import DurationInput from "./DurationInput.svelte";
+
+  import { createEventDispatcher } from "svelte";
+  const dispatch = createEventDispatcher();
+
+  function submit(e) {
+    e.preventDefault();
+  }
+</script>
+
+<style>
+  div.emoji-selector {
+    @apply .flex;
+
+    & input[type="radio"] {
+      @apply hidden;
+    }
+  }
+
+  input[type="submit"] {
+    flex-grow: 0 !important;
+  }
+
+  span.info {
+    @əpply italic pl-10;
+  }
+</style>
+
+<form class="form-ctrl" on:submit={submit}>
+  <div>
+    <label for="channel-id">Channel</label>
+    <select id="channel-id">
+      <option value="01312">#test</option>
+      <option value="01313">#test2</option>
+      <option value="01314">#test3</option>
+    </select>
+  </div>
+  <div>
+    <label for="entry-duration">Entry duration</label>
+    <DurationInput id="entry-duration" unitId="entry-duration-unit" />
+  </div>
+  <div>
+    <label for="voting-duration">Voting duration</label>
+    <DurationInput id="voting-duration" unitId="voting-duration-unit" />
+  </div>
+  <div>
+    <div>Voting emoji</div>
+    <EmojiSelector />
+  </div>
+  <div>
+    <label for="max-winners">Max winners</label>
+    <input
+      id="max-winners"
+      name="max-winners"
+      type="number"
+      step="1"
+      min="1"
+      value="1" />
+  </div>
+  <div>
+    <div />
+    <input class="btn" type="submit" value="Create competition" />
+  </div>
+</form>

+ 53 - 53
web/src/routes/dashboard/contest/_components/ContestEntry.svelte

@@ -1,53 +1,53 @@
-<script>
-  export let contest = {};
-  export let index = 0;
-  let visible = false;
-
-  function toggleView() {
-    visible = !visible;
-  }
-</script>
-
-<style>
-  h3 {
-    @apply .font-bold;
-  }
-</style>
-
-<tr class="entry" data-nth={index % 2 == 0 ? 'even' : 'odd'}>
-  <td>{contest.id}</td>
-  <td>
-    <pre>{contest.channel}</pre>
-  </td>
-  <td>{contest.started.toISOString()}</td>
-  <td>{contest.entryDuration}</td>
-  <td>{contest.voteDuration}</td>
-  <td>
-    <button class="btn" on:click={toggleView}>View info and actions</button>
-  </td>
-</tr>
-
-{#if visible}
-  <tr class="info">
-    <td colspan="6">
-      <h3>Actions</h3>
-      <div class="py-1 pb-4">
-        <button class="btn" disabled="disabled">End entry phase</button>
-        <button class="btn">Start voting</button>
-        <button class="btn">End vote phase</button>
-        <button class="btn">Announce winners</button>
-      </div>
-
-      {#if contest.winners}
-        <h3>Winners</h3>
-        <div class="py-1">
-          <ol class="list-decimal list-inside">
-            {#each contest.winners as winner}
-              <li>{winner}</li>
-            {/each}
-          </ol>
-        </div>
-      {/if}
-    </td>
-  </tr>
-{/if}
+<script>
+  export let contest = {};
+  export let index = 0;
+  let visible = false;
+
+  function toggleView() {
+    visible = !visible;
+  }
+</script>
+
+<style>
+  h3 {
+    @apply .font-bold;
+  }
+</style>
+
+<tr class="entry" data-nth={index % 2 == 0 ? 'even' : 'odd'}>
+  <td>{contest.id}</td>
+  <td>
+    <pre>{contest.channel}</pre>
+  </td>
+  <td>{contest.started.toISOString()}</td>
+  <td>{contest.entryDuration}</td>
+  <td>{contest.voteDuration}</td>
+  <td>
+    <button class="btn" on:click={toggleView}>View info and actions</button>
+  </td>
+</tr>
+
+{#if visible}
+  <tr class="info">
+    <td colspan="6">
+      <h3>Actions</h3>
+      <div class="py-1 pb-4">
+        <button class="btn" disabled="disabled">End entry phase</button>
+        <button class="btn">Start voting</button>
+        <button class="btn">End vote phase</button>
+        <button class="btn">Announce winners</button>
+      </div>
+
+      {#if contest.winners}
+        <h3>Winners</h3>
+        <div class="py-1">
+          <ol class="list-decimal list-inside">
+            {#each contest.winners as winner}
+              <li>{winner}</li>
+            {/each}
+          </ol>
+        </div>
+      {/if}
+    </td>
+  </tr>
+{/if}

+ 23 - 23
web/src/routes/dashboard/contest/_components/ContestTable.svelte

@@ -1,23 +1,23 @@
-<script>
-  import ContestEntry from "./ContestEntry.svelte";
-
-  export let contests = [];
-</script>
-
-<table>
-  <thead>
-    <tr>
-      <th>ID</th>
-      <th>In channel</th>
-      <th>Date started</th>
-      <th>Entry phase duration</th>
-      <th>Vote phase duration</th>
-      <th>Actions</th>
-    </tr>
-  </thead>
-  <tbody>
-    {#each contests as contest, i (contest.id)}
-      <ContestEntry {contest} index={i} />
-    {/each}
-  </tbody>
-</table>
+<script>
+  import ContestEntry from "./ContestEntry.svelte";
+
+  export let contests = [];
+</script>
+
+<table>
+  <thead>
+    <tr>
+      <th>ID</th>
+      <th>In channel</th>
+      <th>Date started</th>
+      <th>Entry phase duration</th>
+      <th>Vote phase duration</th>
+      <th>Actions</th>
+    </tr>
+  </thead>
+  <tbody>
+    {#each contests as contest, i (contest.id)}
+      <ContestEntry {contest} index={i} />
+    {/each}
+  </tbody>
+</table>

+ 26 - 26
web/src/routes/dashboard/contest/_components/DurationInput.svelte

@@ -1,26 +1,26 @@
-<script>
-  export let id;
-  export let unitId;
-</script>
-
-<style>
-  div {
-    @apply flex;
-  }
-
-  input {
-    @apply flex-grow mr-1;
-  }
-</style>
-
-<div>
-  <input {id} type="number" required />
-  <select id={unitId}>
-    <option value="s">seconds</option>
-    <option value="m">minutes</option>
-    <option value="h">hours</option>
-    <option value="d">days</option>
-    <option value="m">months</option>
-    <option value="y">years</option>
-  </select>
-</div>
+<script>
+  export let id;
+  export let unitId;
+</script>
+
+<style>
+  div {
+    @apply flex;
+  }
+
+  input {
+    @apply flex-grow mr-1;
+  }
+</style>
+
+<div>
+  <input {id} type="number" required />
+  <select id={unitId}>
+    <option value="s">seconds</option>
+    <option value="m">minutes</option>
+    <option value="h">hours</option>
+    <option value="d">days</option>
+    <option value="m">months</option>
+    <option value="y">years</option>
+  </select>
+</div>

+ 55 - 55
web/src/routes/dashboard/contest/_components/EmojiSelector.svelte

@@ -1,55 +1,55 @@
-<script>
-  export let emojis = [
-    ...[...Array(50).keys()].map(i => ({
-      id: `${i}`,
-      url:
-        "https://pbs.twimg.com/profile_images/1094633303196487687/AJ5HL3Tz_400x400.png",
-      name: "asd"
-    }))
-  ];
-  export let name = "";
-
-  let selectedEmoji = "";
-  function emojiSelected(e) {
-    selectedEmoji = e.target.dataset.emojiId;
-  }
-</script>
-
-<style>
-  div.emoji-selector {
-    @apply .flex .flex-wrap .bg-gray-800 .py-1 .px-1 .rounded .border .border-gray-900 w-1/3;
-
-    &:focus {
-      @apply .border-blue-500 .shadow;
-    }
-
-    & > div {
-      @apply .rounded .mr-1 .mb-1;
-
-      & > img {
-        @apply .w-8 .h-auto;
-      }
-
-      &:hover {
-        @apply .bg-gray-500;
-      }
-
-      &[data-selected="true"] {
-        @apply .bg-gray-300;
-      }
-    }
-  }
-</style>
-
-<div class="emoji-selector">
-  <input required type="hidden" {name} value={selectedEmoji} />
-  {#each emojis as emoji (emoji.id)}
-    <div title={emoji.name} data-selected={selectedEmoji === emoji.id}>
-      <img
-        data-emoji-id={emoji.id}
-        on:click={emojiSelected}
-        alt="Emote {emoji.id}"
-        src={emoji.url} />
-    </div>
-  {/each}
-</div>
+<script>
+  export let emojis = [
+    ...[...Array(50).keys()].map(i => ({
+      id: `${i}`,
+      url:
+        "https://pbs.twimg.com/profile_images/1094633303196487687/AJ5HL3Tz_400x400.png",
+      name: "asd"
+    }))
+  ];
+  export let name = "";
+
+  let selectedEmoji = "";
+  function emojiSelected(e) {
+    selectedEmoji = e.target.dataset.emojiId;
+  }
+</script>
+
+<style>
+  div.emoji-selector {
+    @apply .flex .flex-wrap .bg-gray-800 .py-1 .px-1 .rounded .border .border-gray-900 w-1/3;
+
+    &:focus {
+      @apply .border-blue-500 .shadow;
+    }
+
+    & > div {
+      @apply .rounded .mr-1 .mb-1;
+
+      & > img {
+        @apply .w-8 .h-auto;
+      }
+
+      &:hover {
+        @apply .bg-gray-500;
+      }
+
+      &[data-selected="true"] {
+        @apply .bg-gray-300;
+      }
+    }
+  }
+</style>
+
+<div class="emoji-selector">
+  <input required type="hidden" {name} value={selectedEmoji} />
+  {#each emojis as emoji (emoji.id)}
+    <div title={emoji.name} data-selected={selectedEmoji === emoji.id}>
+      <img
+        data-emoji-id={emoji.id}
+        on:click={emojiSelected}
+        alt="Emote {emoji.id}"
+        src={emoji.url} />
+    </div>
+  {/each}
+</div>

+ 63 - 63
web/src/routes/dashboard/contest/index.svelte

@@ -1,63 +1,63 @@
-<script>
-  import ContestTable from "./_components/ContestTable.svelte";
-  import AddContestForm from "./_components/AddContestForm.svelte";
-
-  let contests = [
-    {
-      id: 1,
-      channel: "#channel1",
-      started: new Date(),
-      entryDuration: "1d",
-      voteDuration: "1d",
-      winners: ["link1", "link2", "link3"]
-    },
-    {
-      id: 2,
-      channel: "#channel1",
-      started: new Date(),
-      entryDuration: "1d",
-      voteDuration: "1d",
-      winners: ["link1", "link2", "link3"]
-    },
-    {
-      id: 3,
-      channel: "#channel1",
-      started: new Date(),
-      entryDuration: "1d",
-      voteDuration: "1d",
-      winners: ["link1", "link2", "link3"]
-    }
-  ];
-
-  let showAddContest = false;
-
-  function toggleAddContest() {
-    showAddContest = !showAddContest;
-  }
-
-  function contestAdded(newContest) {
-    contests = [newContest, ...contests];
-  }
-</script>
-
-<style lang="css">
-  h2 {
-    @apply .text-xl .font-bold;
-  }
-</style>
-
-<svelte:head>
-  <title>Contest control</title>
-</svelte:head>
-<div class="nav-content">
-  <h2>Actions</h2>
-  <div class="pb-5">
-    <button on:click={toggleAddContest} class="btn">Add contest</button>
-    {#if showAddContest}
-      <AddContestForm on:contestAdded={contestAdded} />
-    {/if}
-  </div>
-
-  <h2>Current contests</h2>
-  <ContestTable {contests}/>
-</div>
+<script>
+  import ContestTable from "./_components/ContestTable.svelte";
+  import AddContestForm from "./_components/AddContestForm.svelte";
+
+  let contests = [
+    {
+      id: 1,
+      channel: "#channel1",
+      started: new Date(),
+      entryDuration: "1d",
+      voteDuration: "1d",
+      winners: ["link1", "link2", "link3"]
+    },
+    {
+      id: 2,
+      channel: "#channel1",
+      started: new Date(),
+      entryDuration: "1d",
+      voteDuration: "1d",
+      winners: ["link1", "link2", "link3"]
+    },
+    {
+      id: 3,
+      channel: "#channel1",
+      started: new Date(),
+      entryDuration: "1d",
+      voteDuration: "1d",
+      winners: ["link1", "link2", "link3"]
+    }
+  ];
+
+  let showAddContest = false;
+
+  function toggleAddContest() {
+    showAddContest = !showAddContest;
+  }
+
+  function contestAdded(newContest) {
+    contests = [newContest, ...contests];
+  }
+</script>
+
+<style lang="css">
+  h2 {
+    @apply .text-xl .font-bold;
+  }
+</style>
+
+<svelte:head>
+  <title>Contest control</title>
+</svelte:head>
+<div class="nav-content">
+  <h2>Actions</h2>
+  <div class="pb-5">
+    <button on:click={toggleAddContest} class="btn">Add contest</button>
+    {#if showAddContest}
+      <AddContestForm on:contestAdded={contestAdded} />
+    {/if}
+  </div>
+
+  <h2>Current contests</h2>
+  <ContestTable {contests}/>
+</div>

+ 8 - 8
web/src/routes/dashboard/index.svelte

@@ -1,8 +1,8 @@
-
-<svelte:head>
-  <title>Dashboard</title>
-</svelte:head>
-
-<div class="content">
-  <h1>Neigh!</h1>
-</div>
+
+<svelte:head>
+  <title>Dashboard</title>
+</svelte:head>
+
+<div class="content">
+  <h1>Neigh!</h1>
+</div>

+ 14 - 14
web/src/routes/index.svelte

@@ -1,14 +1,14 @@
-<script context="module">
-    export async function preload(page, session) {
-        const { user } = session;
-        if(user) return this.redirect(300, "dashboard");
-    }
-</script>
-
-<svelte:head>
-  <title>Loading</title>
-</svelte:head>
-
-<div class="content">
-  <h1>Loading...</h1>
-</div>
+<script context="module">
+    export async function preload(page, session) {
+        const { user } = session;
+        if(user) return this.redirect(300, "dashboard");
+    }
+</script>
+
+<svelte:head>
+  <title>Loading</title>
+</svelte:head>
+
+<div class="content">
+  <h1>Loading...</h1>
+</div>

+ 71 - 71
web/src/routes/login/discord/callback.ts

@@ -1,72 +1,72 @@
-import { Request, Response, NextFunction } from "express";
-import request from "request-promise-native";
-import { Response as Res } from "request";
-import { botService } from "src/util/rpc_client";
-
-const API_ENDPOINT = "https://discordapp.com/api";
-
-export async function get(req : Request, res : Response, next: NextFunction) {
-    if(!req.query.code)
-        throw new Error("NoCodeProvided");
-
-    let code = req.query.code;
-    let response = await request("/oauth2/token", {
-        method: "POST",
-        baseUrl: API_ENDPOINT,
-        qs: {
-            grant_type: "authorization_code",
-            code: code,
-            redirect_uri: `${process.env.ADMIN_URL}/login/discord/callback`
-        },
-        auth: {
-            user: process.env.BOT_CLIENT_ID,
-            pass: process.env.BOT_CLIENT_SECRET
-        },
-        resolveWithFullResponse: true
-    }) as Res;
-
-    let authResponse: AuthResponse = JSON.parse(response.body);
-
-    let userInfoResponse = await request("/users/@me", {
-        method: "GET",
-        baseUrl: API_ENDPOINT,
-        auth: {
-            bearer: authResponse.access_token
-        },
-        resolveWithFullResponse: true
-    });
-
-    let discordUser : DiscordUser = JSON.parse(userInfoResponse.body);
-
-    try {
-        let userInfo = await botService.getModeratorUserInfo({id: discordUser.id});
-        req.session.user = userInfo;
-        res.redirect(`${process.env.ADMIN_URL}/`);
-    } catch(e) {
-        console.log(`Failed to authorise user because: ${e}`);
-        res.redirect(`${process.env.ADMIN_URL}/login/?error=invalid_user`);
-        return;
-    }
-};
-
-interface AuthResponse {
-    access_token: string;
-    token_type: string;
-    expires_in: number;
-    refresh_token?: string;
-    scope: string;
-}
-
-interface DiscordUser {
-    id: string;
-    username: string;
-    discriminator: string;
-    avatar?: string;
-    bot?: boolean;
-    mfa_enabled?: boolean;
-    locale?: string;
-    verified?: boolean;
-    email?: string;
-    flags?: number;
-    premium_type?: number;
+import { Request, Response, NextFunction } from "express";
+import request from "request-promise-native";
+import { Response as Res } from "request";
+import { botService } from "src/util/rpc_client";
+
+const API_ENDPOINT = "https://discordapp.com/api";
+
+export async function get(req : Request, res : Response, next: NextFunction) {
+    if(!req.query.code)
+        throw new Error("NoCodeProvided");
+
+    let code = req.query.code;
+    let response = await request("/oauth2/token", {
+        method: "POST",
+        baseUrl: API_ENDPOINT,
+        qs: {
+            grant_type: "authorization_code",
+            code: code,
+            redirect_uri: `${process.env.ADMIN_URL}/login/discord/callback`
+        },
+        auth: {
+            user: process.env.BOT_CLIENT_ID,
+            pass: process.env.BOT_CLIENT_SECRET
+        },
+        resolveWithFullResponse: true
+    }) as Res;
+
+    let authResponse: AuthResponse = JSON.parse(response.body);
+
+    let userInfoResponse = await request("/users/@me", {
+        method: "GET",
+        baseUrl: API_ENDPOINT,
+        auth: {
+            bearer: authResponse.access_token
+        },
+        resolveWithFullResponse: true
+    });
+
+    let discordUser : DiscordUser = JSON.parse(userInfoResponse.body);
+
+    try {
+        let userInfo = await botService.getModeratorUserInfo({id: discordUser.id});
+        req.session.user = userInfo;
+        res.redirect(`${process.env.ADMIN_URL}/`);
+    } catch(e) {
+        console.log(`Failed to authorise user because: ${e}`);
+        res.redirect(`${process.env.ADMIN_URL}/login/?error=invalid_user`);
+        return;
+    }
+};
+
+interface AuthResponse {
+    access_token: string;
+    token_type: string;
+    expires_in: number;
+    refresh_token?: string;
+    scope: string;
+}
+
+interface DiscordUser {
+    id: string;
+    username: string;
+    discriminator: string;
+    avatar?: string;
+    bot?: boolean;
+    mfa_enabled?: boolean;
+    locale?: string;
+    verified?: boolean;
+    email?: string;
+    flags?: number;
+    premium_type?: number;
 }

+ 9 - 9
web/src/routes/login/discord/do.ts

@@ -1,10 +1,10 @@
-import {Request, Response, NextFunction } from "express";
-
-const API_ENDPOINT = "https://discordapp.com/api";
-
-export async function get(req : Request, res : Response, next : NextFunction) {
-    const CALLBACK_URL = encodeURIComponent(`${process.env.ADMIN_URL}/login/discord/callback`);
-    const SCOPE = encodeURIComponent("identify");
-
-    res.redirect(`${API_ENDPOINT}/oauth2/authorize?client_id=${process.env.BOT_CLIENT_ID}&scope=${SCOPE}&response_type=code&redirect_uri=${CALLBACK_URL}`);
+import {Request, Response, NextFunction } from "express";
+
+const API_ENDPOINT = "https://discordapp.com/api";
+
+export async function get(req : Request, res : Response, next : NextFunction) {
+    const CALLBACK_URL = encodeURIComponent(`${process.env.ADMIN_URL}/login/discord/callback`);
+    const SCOPE = encodeURIComponent("identify");
+
+    res.redirect(`${API_ENDPOINT}/oauth2/authorize?client_id=${process.env.BOT_CLIENT_ID}&scope=${SCOPE}&response_type=code&redirect_uri=${CALLBACK_URL}`);
 };

+ 60 - 60
web/src/routes/login/index.svelte

@@ -1,61 +1,61 @@
-<script context="module">
-  export async function preload(page, session) {
-    const { host, path, params, query } = page;
-    const { user } = session;
-    if (user) return this.redirect(300, "dashboard");
-    if (query.error) return { errors: query.error.split(",") };
-  }
-</script>
-
-<script>
-  const errorTexts = {
-    invalid_user: "The username is invalid!"
-  };
-
-  export let errors;
-</script>
-
-<style lang="css">
-  .login-box {
-    @apply .flex .flex-col .w-screen .h-screen .justify-center .items-center;
-  }
-
-  .box-item {
-    @apply .w-1/4 .p-2 .rounded;
-  }
-
-  .login-button-list {
-    @apply .flex .justify-center .m-2;
-  }
-
-  .discord-button {
-    @apply .p-2 .bg-indigo-700 .rounded-sm;
-
-    &:hover {
-      @apply .bg-indigo-600;
-    }
-  }
-</style>
-
-<svelte:head>
-  <title>Login</title>
-</svelte:head>
-
-<div class="login-box">
-  {#if errors}
-    {#each errors as errorId}
-      <div class="bg-red-800 box-item mb-2">{errorTexts[errorId]}</div>
-    {/each}
-  {/if}
-  <div class="bg-gray-dark box-item shadow-big text-center">
-    <h1 class="text-4xl">NoctBot Login</h1>
-    <div class="login-button-list">
-      <a class="discord-button" href="/login/discord/do">
-        <span class="icon">
-          <i class="fab fa-discord" />
-        </span>
-        <span>Login with Discord</span>
-      </a>
-    </div>
-  </div>
+<script context="module">
+  export async function preload(page, session) {
+    const { host, path, params, query } = page;
+    const { user } = session;
+    if (user) return this.redirect(300, "dashboard");
+    if (query.error) return { errors: query.error.split(",") };
+  }
+</script>
+
+<script>
+  const errorTexts = {
+    invalid_user: "The username is invalid!"
+  };
+
+  export let errors;
+</script>
+
+<style lang="css">
+  .login-box {
+    @apply .flex .flex-col .w-screen .h-screen .justify-center .items-center;
+  }
+
+  .box-item {
+    @apply .w-1/4 .p-2 .rounded;
+  }
+
+  .login-button-list {
+    @apply .flex .justify-center .m-2;
+  }
+
+  .discord-button {
+    @apply .p-2 .bg-indigo-700 .rounded-sm;
+
+    &:hover {
+      @apply .bg-indigo-600;
+    }
+  }
+</style>
+
+<svelte:head>
+  <title>Login</title>
+</svelte:head>
+
+<div class="login-box">
+  {#if errors}
+    {#each errors as errorId}
+      <div class="bg-red-800 box-item mb-2">{errorTexts[errorId]}</div>
+    {/each}
+  {/if}
+  <div class="bg-gray-dark box-item shadow-big text-center">
+    <h1 class="text-4xl">NoctBot Login</h1>
+    <div class="login-button-list">
+      <a class="discord-button" href="/login/discord/do">
+        <span class="icon">
+          <i class="fab fa-discord" />
+        </span>
+        <span>Login with Discord</span>
+      </a>
+    </div>
+  </div>
 </div>

+ 52 - 52
web/src/server.ts

@@ -1,52 +1,52 @@
-import compression from 'compression';
-import * as sapper from '@sapper/server';
-import { createConnection, getConnectionOptions } from "typeorm";
-import express from "express";
-import session from "cookie-session";
-import dotenv from "dotenv";
-import { DB_ENTITIES } from "@shared/db/entities";
-
-if(process.env.NODE_ENV == "development") {
-	console.log(process.cwd());
-    dotenv.config({
-        path: "../.env"
-    });
-    dotenv.config({
-        path: "../db.env"
-    });
-    process.env.TYPEORM_HOST = "localhost";
-    process.env.TYPEORM_USERNAME = process.env.DB_USERNAME;
-    process.env.TYPEORM_PASSWORD = process.env.DB_PASSWORD;
-	process.env.TYPEORM_DATABASE = process.env.DB_NAME;
-}
-
-
-const PORT = +(process.env.PORT as string);
-async function main() {
-	await createConnection({
-		...await getConnectionOptions(),
-		entities: DB_ENTITIES
-	});
-
-	express()
-		.use(
-			session({
-				maxAge: 604800,
-				secret: process.env.ADMIN_COOKIE_KEY,
-				name: "session"
-			}),
-			compression({ threshold: 0 }),
-			express.static("static"),
-			sapper.middleware({
-				session: (req, res) => ({
-					user: req.session && req.session.user
-				})
-			})
-		)
-		.listen(PORT, err => {
-			if (err) console.log('error', err);
-		});
-}
-
-main();
-
+import compression from 'compression';
+import * as sapper from '@sapper/server';
+import { createConnection, getConnectionOptions } from "typeorm";
+import express from "express";
+import session from "cookie-session";
+import dotenv from "dotenv";
+import { DB_ENTITIES } from "@shared/db/entities";
+
+if(process.env.NODE_ENV == "development") {
+	console.log(process.cwd());
+    dotenv.config({
+        path: "../.env"
+    });
+    dotenv.config({
+        path: "../db.env"
+    });
+    process.env.TYPEORM_HOST = "localhost";
+    process.env.TYPEORM_USERNAME = process.env.DB_USERNAME;
+    process.env.TYPEORM_PASSWORD = process.env.DB_PASSWORD;
+	process.env.TYPEORM_DATABASE = process.env.DB_NAME;
+}
+
+
+const PORT = +(process.env.PORT as string);
+async function main() {
+	await createConnection({
+		...await getConnectionOptions(),
+		entities: DB_ENTITIES
+	});
+
+	express()
+		.use(
+			session({
+				maxAge: 604800,
+				secret: process.env.ADMIN_COOKIE_KEY,
+				name: "session"
+			}),
+			compression({ threshold: 0 }),
+			express.static("static"),
+			sapper.middleware({
+				session: (req, res) => ({
+					user: req.session && req.session.user
+				})
+			})
+		)
+		.listen(PORT, err => {
+			if (err) console.log('error', err);
+		});
+}
+
+main();
+

+ 82 - 82
web/src/service-worker.js

@@ -1,82 +1,82 @@
-import { timestamp, files, shell, routes } from '@sapper/service-worker';
-
-const ASSETS = `cache${timestamp}`;
-
-// `shell` is an array of all the files generated by the bundler,
-// `files` is an array of everything in the `static` directory
-const to_cache = shell.concat(files);
-const cached = new Set(to_cache);
-
-self.addEventListener('install', event => {
-	event.waitUntil(
-		caches
-			.open(ASSETS)
-			.then(cache => cache.addAll(to_cache))
-			.then(() => {
-				self.skipWaiting();
-			})
-	);
-});
-
-self.addEventListener('activate', event => {
-	event.waitUntil(
-		caches.keys().then(async keys => {
-			// delete old caches
-			for (const key of keys) {
-				if (key !== ASSETS) await caches.delete(key);
-			}
-
-			self.clients.claim();
-		})
-	);
-});
-
-self.addEventListener('fetch', event => {
-	if (event.request.method !== 'GET' || event.request.headers.has('range')) return;
-
-	const url = new URL(event.request.url);
-
-	// don't try to handle e.g. data: URIs
-	if (!url.protocol.startsWith('http')) return;
-
-	// ignore dev server requests
-	if (url.hostname === self.location.hostname && url.port !== self.location.port) return;
-
-	// always serve static files and bundler-generated assets from cache
-	if (url.host === self.location.host && cached.has(url.pathname)) {
-		event.respondWith(caches.match(event.request));
-		return;
-	}
-
-	// for pages, you might want to serve a shell `service-worker-index.html` file,
-	// which Sapper has generated for you. It's not right for every
-	// app, but if it's right for yours then uncomment this section
-	/*
-	if (url.origin === self.origin && routes.find(route => route.pattern.test(url.pathname))) {
-		event.respondWith(caches.match('/service-worker-index.html'));
-		return;
-	}
-	*/
-
-	if (event.request.cache === 'only-if-cached') return;
-
-	// for everything else, try the network first, falling back to
-	// cache if the user is offline. (If the pages never change, you
-	// might prefer a cache-first approach to a network-first one.)
-	event.respondWith(
-		caches
-			.open(`offline${timestamp}`)
-			.then(async cache => {
-				try {
-					const response = await fetch(event.request);
-					cache.put(event.request, response.clone());
-					return response;
-				} catch(err) {
-					const response = await cache.match(event.request);
-					if (response) return response;
-
-					throw err;
-				}
-			})
-	);
-});
+import { timestamp, files, shell, routes } from '@sapper/service-worker';
+
+const ASSETS = `cache${timestamp}`;
+
+// `shell` is an array of all the files generated by the bundler,
+// `files` is an array of everything in the `static` directory
+const to_cache = shell.concat(files);
+const cached = new Set(to_cache);
+
+self.addEventListener('install', event => {
+	event.waitUntil(
+		caches
+			.open(ASSETS)
+			.then(cache => cache.addAll(to_cache))
+			.then(() => {
+				self.skipWaiting();
+			})
+	);
+});
+
+self.addEventListener('activate', event => {
+	event.waitUntil(
+		caches.keys().then(async keys => {
+			// delete old caches
+			for (const key of keys) {
+				if (key !== ASSETS) await caches.delete(key);
+			}
+
+			self.clients.claim();
+		})
+	);
+});
+
+self.addEventListener('fetch', event => {
+	if (event.request.method !== 'GET' || event.request.headers.has('range')) return;
+
+	const url = new URL(event.request.url);
+
+	// don't try to handle e.g. data: URIs
+	if (!url.protocol.startsWith('http')) return;
+
+	// ignore dev server requests
+	if (url.hostname === self.location.hostname && url.port !== self.location.port) return;
+
+	// always serve static files and bundler-generated assets from cache
+	if (url.host === self.location.host && cached.has(url.pathname)) {
+		event.respondWith(caches.match(event.request));
+		return;
+	}
+
+	// for pages, you might want to serve a shell `service-worker-index.html` file,
+	// which Sapper has generated for you. It's not right for every
+	// app, but if it's right for yours then uncomment this section
+	/*
+	if (url.origin === self.origin && routes.find(route => route.pattern.test(url.pathname))) {
+		event.respondWith(caches.match('/service-worker-index.html'));
+		return;
+	}
+	*/
+
+	if (event.request.cache === 'only-if-cached') return;
+
+	// for everything else, try the network first, falling back to
+	// cache if the user is offline. (If the pages never change, you
+	// might prefer a cache-first approach to a network-first one.)
+	event.respondWith(
+		caches
+			.open(`offline${timestamp}`)
+			.then(async cache => {
+				try {
+					const response = await fetch(event.request);
+					cache.put(event.request, response.clone());
+					return response;
+				} catch(err) {
+					const response = await cache.match(event.request);
+					if (response) return response;
+
+					throw err;
+				}
+			})
+	);
+});

+ 76 - 76
web/src/style/main.css

@@ -1,77 +1,77 @@
-@import "tailwindcss/base";
-@import "tailwindcss/components";
-@import "tailwindcss/utilities";
-
-body {
-    @apply .bg-gray-900 .text-white .font-sans;
-}
-
-.shadow-big {
-    box-shadow: 3px 3px 10px -1px rgba(0, 0, 0, 0.75);
-}
-
-.nav-content {
-    & h1 {
-        @apply .text-4xl .font-bold;
-    }
-}
-
-table {
-    @apply .border-gray-900 .rounded-sm .border .w-full;
-
-    & thead tr {
-        @apply .bg-gray-900;
-    }
-
-    th, td {
-        @apply .p-1 .border-gray-900 .border-r .border-b;
-    }
-
-    tbody tr.entry[data-nth="even"] {
-        @apply .bg-gray-800;
-    }
-
-    tbody tr.entry[data-nth="odd"] {
-        @apply .bg-gray-700;
-    }
-}
-
-.btn {
-    @apply .px-2 .py-1 .bg-blue-700 .rounded-sm .font-normal .border .border-blue-700;
-
-    &:hover {
-      @apply .bg-blue-600;
-    }
-
-    &[disabled] {
-      @apply .bg-transparent .border .border-blue-700 .text-gray-400 .cursor-not-allowed;
-    }
-}
-
-.input-ctrl {
-    @apply .bg-gray-800 .py-1 .px-1 .rounded .border .border-gray-900;
-}
-
-.input-ctrl-hover {
-    @apply .border-blue-500 .shadow;
-}
-
-input, select {
-    @apply .input-ctrl;
-
-    &:focus {
-        @apply .input-ctrl-hover;
-    }
-}
-
-form.form-ctrl > div {
-    @apply .flex .py-1 .items-center;
-
-    & > *:nth-child(1) {
-      @apply .flex-grow-0 w-1/6;
-    }
-
-    & > *:nth-child(2) {
-      @apply .flex-grow-0 w-1/3;
-    }
+@import "tailwindcss/base";
+@import "tailwindcss/components";
+@import "tailwindcss/utilities";
+
+body {
+    @apply .bg-gray-900 .text-white .font-sans;
+}
+
+.shadow-big {
+    box-shadow: 3px 3px 10px -1px rgba(0, 0, 0, 0.75);
+}
+
+.nav-content {
+    & h1 {
+        @apply .text-4xl .font-bold;
+    }
+}
+
+table {
+    @apply .border-gray-900 .rounded-sm .border .w-full;
+
+    & thead tr {
+        @apply .bg-gray-900;
+    }
+
+    th, td {
+        @apply .p-1 .border-gray-900 .border-r .border-b;
+    }
+
+    tbody tr.entry[data-nth="even"] {
+        @apply .bg-gray-800;
+    }
+
+    tbody tr.entry[data-nth="odd"] {
+        @apply .bg-gray-700;
+    }
+}
+
+.btn {
+    @apply .px-2 .py-1 .bg-blue-700 .rounded-sm .font-normal .border .border-blue-700;
+
+    &:hover {
+      @apply .bg-blue-600;
+    }
+
+    &[disabled] {
+      @apply .bg-transparent .border .border-blue-700 .text-gray-400 .cursor-not-allowed;
+    }
+}
+
+.input-ctrl {
+    @apply .bg-gray-800 .py-1 .px-1 .rounded .border .border-gray-900;
+}
+
+.input-ctrl-hover {
+    @apply .border-blue-500 .shadow;
+}
+
+input, select {
+    @apply .input-ctrl;
+
+    &:focus {
+        @apply .input-ctrl-hover;
+    }
+}
+
+form.form-ctrl > div {
+    @apply .flex .py-1 .items-center;
+
+    & > *:nth-child(1) {
+      @apply .flex-grow-0 w-1/6;
+    }
+
+    & > *:nth-child(2) {
+      @apply .flex-grow-0 w-1/3;
+    }
   }

+ 35 - 35
web/src/template.html

@@ -1,35 +1,35 @@
-<!doctype html>
-<html>
-<head>
-	<meta charset='utf-8'>
-	<meta name='viewport' content='width=device-width,initial-scale=1.0'>
-	<meta name='theme-color' content='#333333'>
-
-	%sapper.base%
-
-	<link rel='stylesheet' href='global.css'>
-	<link rel='manifest' href='manifest.json'>
-	<link rel='icon' type='image/png' href='favicon.png'>
-
-	<!-- Sapper generates a <style> tag containing critical CSS
-	     for the current page. CSS for the rest of the app is
-	     lazily loaded when it precaches secondary pages -->
-	%sapper.styles%
-
-	<script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
-
-	<!-- This contains the contents of the <svelte:head> component, if
-	     the current page has one -->
-	%sapper.head%
-</head>
-<body>
-	<!-- The application will be rendered inside this element,
-	     because `app/client.js` references it -->
-	<div id='sapper'>%sapper.html%</div>
-
-	<!-- Sapper creates a <script> tag containing `app/client.js`
-	     and anything else it needs to hydrate the app and
-	     initialise the router -->
-	%sapper.scripts%
-</body>
-</html>
+<!doctype html>
+<html>
+<head>
+	<meta charset='utf-8'>
+	<meta name='viewport' content='width=device-width,initial-scale=1.0'>
+	<meta name='theme-color' content='#333333'>
+
+	%sapper.base%
+
+	<link rel='stylesheet' href='global.css'>
+	<link rel='manifest' href='manifest.json'>
+	<link rel='icon' type='image/png' href='favicon.png'>
+
+	<!-- Sapper generates a <style> tag containing critical CSS
+	     for the current page. CSS for the rest of the app is
+	     lazily loaded when it precaches secondary pages -->
+	%sapper.styles%
+
+	<script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
+
+	<!-- This contains the contents of the <svelte:head> component, if
+	     the current page has one -->
+	%sapper.head%
+</head>
+<body>
+	<!-- The application will be rendered inside this element,
+	     because `app/client.js` references it -->
+	<div id='sapper'>%sapper.html%</div>
+
+	<!-- Sapper creates a <script> tag containing `app/client.js`
+	     and anything else it needs to hydrate the app and
+	     initialise the router -->
+	%sapper.scripts%
+</body>
+</html>

+ 38 - 38
web/src/typedefs/sapper.d.ts

@@ -1,39 +1,39 @@
-declare module '@sapper/app' {
-    // from sapper/runtime/src/app/types.ts
-    // sapper doesn't export its types yet
-    interface Redirect {
-        statusCode: number
-        location: string
-    }
-    // end
-
-    function goto(href: string, opts?: { replaceState: boolean }): Promise<unknown>
-    function prefetch(href: string): Promise<{ redirect?: Redirect; data?: unknown }>
-    function prefetchRoutes(pathnames: string[]): Promise<unknown>
-    function start(opts: { target: Node }): Promise<unknown>
-    const stores: () => unknown
-
-    export { goto, prefetch, prefetchRoutes, start, stores }
-}
-
-declare module '@sapper/server' {
-    import { RequestHandler } from 'express'
-
-    interface MiddlewareOptions {
-        session?: (req: Express.Request, res: Express.Response) => unknown
-        ignore?: unknown
-    }
-
-    function middleware(opts?: MiddlewareOptions): RequestHandler
-
-    export { middleware }
-}
-
-declare module '@sapper/service-worker' {
-    const timestamp: number
-    const files: string[]
-    const shell: string[]
-    const routes: { pattern: RegExp }[]
-
-    export { timestamp, files, files as assets, shell, routes }
+declare module '@sapper/app' {
+    // from sapper/runtime/src/app/types.ts
+    // sapper doesn't export its types yet
+    interface Redirect {
+        statusCode: number
+        location: string
+    }
+    // end
+
+    function goto(href: string, opts?: { replaceState: boolean }): Promise<unknown>
+    function prefetch(href: string): Promise<{ redirect?: Redirect; data?: unknown }>
+    function prefetchRoutes(pathnames: string[]): Promise<unknown>
+    function start(opts: { target: Node }): Promise<unknown>
+    const stores: () => unknown
+
+    export { goto, prefetch, prefetchRoutes, start, stores }
+}
+
+declare module '@sapper/server' {
+    import { RequestHandler } from 'express'
+
+    interface MiddlewareOptions {
+        session?: (req: Express.Request, res: Express.Response) => unknown
+        ignore?: unknown
+    }
+
+    function middleware(opts?: MiddlewareOptions): RequestHandler
+
+    export { middleware }
+}
+
+declare module '@sapper/service-worker' {
+    const timestamp: number
+    const files: string[]
+    const shell: string[]
+    const routes: { pattern: RegExp }[]
+
+    export { timestamp, files, files as assets, shell, routes }
 }

+ 5 - 5
web/src/util/rpc_client.ts

@@ -1,6 +1,6 @@
-(<any>global).fetch = require("node-fetch");
-import { createClient } from "typescript-rest-rpc/lib/client";
-import { Backend } from "@shared/rpc/backend";
-
-const BOT_ADDRESS = process.env.NODE_ENV == "development" ? "localhost" : "noctbot";
+(<any>global).fetch = require("node-fetch");
+import { createClient } from "typescript-rest-rpc/lib/client";
+import { Backend } from "@shared/rpc/backend";
+
+const BOT_ADDRESS = process.env.NODE_ENV == "development" ? "localhost" : "noctbot";
 export const botService : Backend = createClient(`http://${BOT_ADDRESS}:3010/rpc`);

+ 20 - 20
web/static/manifest.json

@@ -1,20 +1,20 @@
-{
-	"background_color": "#ffffff",
-	"theme_color": "#333333",
-	"name": "TODO",
-	"short_name": "TODO",
-	"display": "minimal-ui",
-	"start_url": "/",
-	"icons": [
-		{
-			"src": "logo-192.png",
-			"sizes": "192x192",
-			"type": "image/png"
-		},
-		{
-			"src": "logo-512.png",
-			"sizes": "512x512",
-			"type": "image/png"
-		}
-	]
-}
+{
+	"background_color": "#ffffff",
+	"theme_color": "#333333",
+	"name": "TODO",
+	"short_name": "TODO",
+	"display": "minimal-ui",
+	"start_url": "/",
+	"icons": [
+		{
+			"src": "logo-192.png",
+			"sizes": "192x192",
+			"type": "image/png"
+		},
+		{
+			"src": "logo-512.png",
+			"sizes": "512x512",
+			"type": "image/png"
+		}
+	]
+}

+ 11 - 11
web/tailwind.config.js

@@ -1,11 +1,11 @@
-module.exports = {
-  theme: {
-    extend: {
-      colors: {
-        'gray-dark': '#0F131A'
-      }
-    },
-  },
-  variants: {},
-  plugins: []
-}
+module.exports = {
+  theme: {
+    extend: {
+      colors: {
+        'gray-dark': '#0F131A'
+      }
+    },
+  },
+  variants: {},
+  plugins: []
+}

+ 34 - 34
web/tsconfig.json

@@ -1,35 +1,35 @@
-{
-    "compileOnSave": true,
-    "compilerOptions": {
-        "module": "esnext",
-        "noImplicitAny": true,
-        "removeComments": true,
-        "preserveConstEnums": true,
-        "moduleResolution": "node",
-        "outDir": "build",
-        "lib": ["es2018", "dom"],
-        "esModuleInterop": true,
-        "sourceMap": true,
-        "target": "es2018",
-        "emitDecoratorMetadata": true,
-        "experimentalDecorators": true,
-        "baseUrl": ".",
-        "paths": {
-            "@shared/*": ["../shared/src/*"]
-        },
-        "types": [
-            "node-fetch"
-        ]
-    },
-    "references": [
-        {
-            "path": "../shared"
-        }
-    ],
-    "include": [
-        "src/**/*.ts", "src/client.js"
-    ],
-    "exclude": [
-        "node_modules"
-    ]
+{
+    "compileOnSave": true,
+    "compilerOptions": {
+        "module": "esnext",
+        "noImplicitAny": true,
+        "removeComments": true,
+        "preserveConstEnums": true,
+        "moduleResolution": "node",
+        "outDir": "build",
+        "lib": ["es2018", "dom"],
+        "esModuleInterop": true,
+        "sourceMap": true,
+        "target": "es2018",
+        "emitDecoratorMetadata": true,
+        "experimentalDecorators": true,
+        "baseUrl": ".",
+        "paths": {
+            "@shared/*": ["../shared/src/*"]
+        },
+        "types": [
+            "node-fetch"
+        ]
+    },
+    "references": [
+        {
+            "path": "../shared"
+        }
+    ],
+    "include": [
+        "src/**/*.ts", "src/client.js"
+    ],
+    "exclude": [
+        "node_modules"
+    ]
 }