Browse Source

Fix line endings, update bot code to build

ghorsington 4 năm trước cách đây
mục cha
commit
5d6d5fc554
73 tập tin đã thay đổi với 2753 bổ sung2757 xóa
  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(

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 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"
+    ]
 }