From 05b8111c1906c1285c9ddde758eda45b83792244 Mon Sep 17 00:00:00 2001 From: syuilo <Syuilotan@yahoo.co.jp> Date: Mon, 29 Apr 2019 09:11:57 +0900 Subject: [PATCH] Pages (#4811) * wip * wip * wip * Update page-editor.vue * wip * wip * wip * wip * wip * wip * wip * Update page-editor.variable.core.vue * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * Update aiscript.ts * wip * Update package.json * wip * wip * wip * wip * wip * Update page.vue * wip * wip * wip * wip * more info * wip fn * wip * wip * wip --- .gitignore | 1 + CHANGELOG.md | 26 + locales/ja-JP.yml | 183 ++++++- migration/1556348509290-Pages.ts | 31 ++ package.json | 3 +- src/client/app/common/scripts/aiscript.ts | 470 ++++++++++++++++++ .../app/common/scripts/collect-page-vars.ts | 24 + .../app/common/views/components/dialog.vue | 11 +- .../common/views/components/media-image.vue | 2 +- .../page-editor/page-editor.block.vue | 25 + .../page-editor/page-editor.button.vue | 54 ++ .../page-editor/page-editor.container.vue | 135 +++++ .../page-editor/page-editor.image.vue | 78 +++ .../page-editor/page-editor.input.vue | 54 ++ .../page-editor/page-editor.script-block.vue | 263 ++++++++++ .../page-editor/page-editor.section.vue | 133 +++++ .../page-editor/page-editor.switch.vue | 48 ++ .../page-editor/page-editor.text.vue | 57 +++ .../components/page-editor/page-editor.vue | 452 +++++++++++++++++ .../common/views/components/page-preview.vue | 141 ++++++ .../common/views/pages/page/page.block.vue | 34 ++ .../common/views/pages/page/page.button.vue | 42 ++ .../common/views/pages/page/page.image.vue | 36 ++ .../common/views/pages/page/page.input.vue | 43 ++ .../common/views/pages/page/page.section.vue | 55 ++ .../common/views/pages/page/page.switch.vue | 33 ++ .../app/common/views/pages/page/page.text.vue | 35 ++ .../app/common/views/pages/page/page.vue | 143 ++++++ src/client/app/desktop/script.ts | 4 + .../views/components/ui.header.account.vue | 27 +- src/client/app/desktop/views/home/pages.vue | 92 ++++ .../app/desktop/views/pages/page-editor.vue | 32 ++ src/client/app/desktop/views/pages/page.vue | 36 ++ src/client/app/mobile/script.ts | 4 + .../app/mobile/views/components/ui.nav.vue | 5 +- .../app/mobile/views/pages/page-editor.vue | 32 ++ src/client/app/mobile/views/pages/page.vue | 36 ++ src/client/app/mobile/views/pages/pages.vue | 94 ++++ src/client/themes/dark.json5 | 3 + src/client/themes/light.json5 | 3 + src/db/postgre.ts | 2 + src/models/entities/page.ts | 105 ++++ src/models/index.ts | 2 + src/models/repositories/page.ts | 61 +++ src/server/api/endpoints/i/pages.ts | 44 ++ src/server/api/endpoints/pages/create.ts | 108 ++++ src/server/api/endpoints/pages/delete.ts | 53 ++ src/server/api/endpoints/pages/show.ts | 74 +++ src/server/api/endpoints/pages/update.ts | 123 +++++ src/server/web/index.ts | 37 +- src/server/web/views/note.pug | 1 + src/server/web/views/page.pug | 30 ++ 52 files changed, 3583 insertions(+), 37 deletions(-) create mode 100644 migration/1556348509290-Pages.ts create mode 100644 src/client/app/common/scripts/aiscript.ts create mode 100644 src/client/app/common/scripts/collect-page-vars.ts create mode 100644 src/client/app/common/views/components/page-editor/page-editor.block.vue create mode 100644 src/client/app/common/views/components/page-editor/page-editor.button.vue create mode 100644 src/client/app/common/views/components/page-editor/page-editor.container.vue create mode 100644 src/client/app/common/views/components/page-editor/page-editor.image.vue create mode 100644 src/client/app/common/views/components/page-editor/page-editor.input.vue create mode 100644 src/client/app/common/views/components/page-editor/page-editor.script-block.vue create mode 100644 src/client/app/common/views/components/page-editor/page-editor.section.vue create mode 100644 src/client/app/common/views/components/page-editor/page-editor.switch.vue create mode 100644 src/client/app/common/views/components/page-editor/page-editor.text.vue create mode 100644 src/client/app/common/views/components/page-editor/page-editor.vue create mode 100644 src/client/app/common/views/components/page-preview.vue create mode 100644 src/client/app/common/views/pages/page/page.block.vue create mode 100644 src/client/app/common/views/pages/page/page.button.vue create mode 100644 src/client/app/common/views/pages/page/page.image.vue create mode 100644 src/client/app/common/views/pages/page/page.input.vue create mode 100644 src/client/app/common/views/pages/page/page.section.vue create mode 100644 src/client/app/common/views/pages/page/page.switch.vue create mode 100644 src/client/app/common/views/pages/page/page.text.vue create mode 100644 src/client/app/common/views/pages/page/page.vue create mode 100644 src/client/app/desktop/views/home/pages.vue create mode 100644 src/client/app/desktop/views/pages/page-editor.vue create mode 100644 src/client/app/desktop/views/pages/page.vue create mode 100644 src/client/app/mobile/views/pages/page-editor.vue create mode 100644 src/client/app/mobile/views/pages/page.vue create mode 100644 src/client/app/mobile/views/pages/pages.vue create mode 100644 src/models/entities/page.ts create mode 100644 src/models/repositories/page.ts create mode 100644 src/server/api/endpoints/i/pages.ts create mode 100644 src/server/api/endpoints/pages/create.ts create mode 100644 src/server/api/endpoints/pages/delete.ts create mode 100644 src/server/api/endpoints/pages/show.ts create mode 100644 src/server/api/endpoints/pages/update.ts create mode 100644 src/server/web/views/page.pug diff --git a/.gitignore b/.gitignore index 650d4f6128..255b1ad4d6 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ api-docs.json yarn.lock .DS_Store /files +ormconfig.json diff --git a/CHANGELOG.md b/CHANGELOG.md index df090780a4..c576a5714e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,32 @@ mongodb: 8. master ブランãƒã«æˆ»ã™ 9. enjoy +unreleased +------------------- +### New features +#### MisskeyPages +ページ(記事)を作æˆã§ãるよã†ã«ã€‚ + +* 後ã‹ã‚‰ä½•åº¦ã§ã‚‚編集ã§ãã‚‹ +* アイã‚ャッãƒã‚’è¨å®šã§ãã‚‹ +* フォントをè¨å®šã§ãã‚‹ +* ç”»åƒã‚’好ããªä½ç½®ã«æŒ¿å…¥ã§ãã‚‹ +* URLを決ã‚られる +* タイトルをè¨å®šã§ãã‚‹ +* 見出ã—ã‚’è¨å®šã§ãã‚‹ +* ページã®è¦ç´„ã‚’è¨å®šã§ãã‚‹(URLプレビュー時ãªã©ã«ä¾¿åˆ©) +* 変数やå¼(aka AiScript)を使用ã—ã¦å‹•çš„ãªãƒšãƒ¼ã‚¸ã‚‚作れる +* 目次自動生æˆ(coming soon) + +ページを気ã«å…¥ã£ãŸã‚‰ã€Œã„ã„ãã€ã—よㆠ(coming soon) + +### Improvements +* APIコンソールã§ãƒ‘ラメータテンプレートを表示ã™ã‚‹ã‚ˆã†ã« + +### Fixes +* ãŠã™ã™ã‚ユーザーã«è‡ªåˆ†è‡ªèº«ãŒå«ã¾ã‚Œã‚‹å•é¡Œã‚’ä¿®æ£ +* ユーザーサジェストã§è¡¨ç¤ºåãŒå¤‰ã‚らãªã„å•é¡Œã‚’ä¿®æ£ + 11.4.0 (2019/04/25) ------------------- ### Improvements diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a475bc2c16..b0cb78f96b 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -65,6 +65,7 @@ common: trash: "ゴミ箱" drive: "ドライブ" + pages: "ページ" messaging: "トーク" home: "ホーム" deck: "デッã‚" @@ -1813,26 +1814,6 @@ docs: edit-this-page-on-github: "é–“é•ã„や改善点を見ã¤ã‘ã¾ã—ãŸã‹ï¼Ÿ" edit-this-page-on-github-link: "ã“ã®ãƒšãƒ¼ã‚¸ã‚’GitHubã§ç·¨é›†" - api: - entities: - properties: "プãƒãƒ‘ティ" - endpoints: - params: "パラメータ" - no-params: "パラメータã¯ã‚ã‚Šã¾ã›ã‚“" - res: "レスãƒãƒ³ã‚¹" - require-credential: "ã“ã®ã‚¨ãƒ³ãƒ‰ãƒã‚¤ãƒ³ãƒˆã¯èªè¨¼æƒ…å ±ãŒå¿…é ˆã§ã™ã€‚" - require-permission: "ã“ã®ã‚¨ãƒ³ãƒ‰ãƒã‚¤ãƒ³ãƒˆã¯{permission}ã®æ¨©é™ã‚’å¿…è¦ã¨ã—ã¾ã™ã€‚" - has-limit: "レートリミットãŒã‚ã‚Šã¾ã™ã€‚" - duration-limit: "ç›´è¿‘{duration}ミリ秒ã®é–“ã®ã“ã®ã‚¨ãƒ³ãƒ‰ãƒã‚¤ãƒ³ãƒˆã¸ã®ãƒªã‚¯ã‚¨ã‚¹ãƒˆæ•°ã®åˆè¨ˆãŒ{max}を超ãˆã‚‹å ´åˆã¯ãƒªã‚¯ã‚¨ã‚¹ãƒˆã§ãã¾ã›ã‚“。" - min-interval-limit: "å‰å›žã®ãƒªã‚¯ã‚¨ã‚¹ãƒˆã‹ã‚‰{interval}ミリ秒経ã£ã¦ã„ãªã„å ´åˆã¯ãƒªã‚¯ã‚¨ã‚¹ãƒˆã§ãã¾ã›ã‚“。" - show-src: "ã“ã®ã‚¨ãƒ³ãƒ‰ãƒã‚¤ãƒ³ãƒˆã®ã‚½ãƒ¼ã‚¹ã‚³ãƒ¼ãƒ‰ã‚‚閲覧ã§ãã¾ã™ã€‚" - show-src-link: "コードをGitHubã§è¦‹ã‚‹" - generated: "ã“ã®ãƒ‰ã‚ュメントã¯API定義ã«åŸºã¥ã自動生æˆã•ã‚Œã¦ã„ã¾ã™ã€‚" - props: - name: "åå‰" - type: "åž‹" - description: "説明" - dev/views/index.vue: manage-apps: "アプリã®ç®¡ç†" @@ -1857,3 +1838,165 @@ dev/views/new-app.vue: authority: "権é™" authority-desc: "ã“ã“ã§è¦æ±‚ã—ãŸæ©Ÿèƒ½ã ã‘ãŒAPIã‹ã‚‰ã‚¢ã‚¯ã‚»ã‚¹ã§ãã¾ã™ã€‚" authority-warning: "アプリ作æˆå¾Œã‚‚変更ã§ãã¾ã™ãŒã€æ–°ãŸãªæ¨©é™ã‚’付与ã™ã‚‹å ´åˆã€ãã®æ™‚点ã§é–¢é€£ä»˜ã‘られã¦ã„るユーザーã‚ーã¯ã™ã¹ã¦ç„¡åŠ¹ã«ãªã‚Šã¾ã™ã€‚" + +pages: + new-page: "ページã®ä½œæˆ" + edit-page: "ページã®ç·¨é›†" + page-created: "ページを作æˆã—ã¾ã—ãŸ" + page-updated: "ページを更新ã—ã¾ã—ãŸ" + are-you-sure-delete: "ã“ã®ãƒšãƒ¼ã‚¸ã‚’削除ã—ã¾ã™ã‹ï¼Ÿ" + page-deleted: "ページを削除ã—ã¾ã—ãŸ" + edit-this-page: "ã“ã®ãƒšãƒ¼ã‚¸ã‚’編集" + variables: "変数" + variables-info: "変数を使ã†ã“ã¨ã§å‹•çš„ãªãƒšãƒ¼ã‚¸ã‚’作æˆã§ãã¾ã™ã€‚テã‚スト内㧠<b>{ 変数å }</b> ã¨æ›¸ãã¨ãã“ã«å¤‰æ•°ã®å€¤ã‚’埋ã‚è¾¼ã‚ã¾ã™ã€‚例ãˆã° <b>Hello { thing } world!</b> ã¨ã„ã†ãƒ†ã‚ストã§ã€å¤‰æ•°(thing)ã®å€¤ãŒ <b>ai</b> ã ã£ãŸå ´åˆã€ãƒ†ã‚スト㯠<b>Hello ai world!</b> ã«ãªã‚Šã¾ã™ã€‚" + variables-info2: "変数ã®è©•ä¾¡(値を算出ã™ã‚‹ã“ã¨)ã¯ä¸Šã‹ã‚‰ä¸‹ã«è¡Œã‚れるã®ã§ã€ã‚る変数ã®ä¸ã§è‡ªåˆ†ã‚ˆã‚Šä¸‹ã®å¤‰æ•°ã‚’å‚ç…§ã™ã‚‹ã“ã¨ã¯ã§ãã¾ã›ã‚“。例ãˆã°ä¸Šã‹ã‚‰ <b>Aã€Bã€C</b> ã¨3ã¤ã®å¤‰æ•°ã‚’定義ã—ãŸã¨ãã€<b>C</b>ã®ä¸ã§<b>A</b>ã‚„<b>B</b>ã‚’å‚ç…§ã™ã‚‹ã“ã¨ã¯ã§ãã¾ã™ãŒã€<b>A</b>ã®ä¸ã§<b>B</b>ã‚„<b>C</b>ã‚’å‚ç…§ã™ã‚‹ã“ã¨ã¯ã§ãã¾ã›ã‚“。" + variables-info3: "ユーザーã‹ã‚‰ã®å…¥åŠ›ã‚’å—ã‘å–ã‚‹ã«ã¯ã€ãƒšãƒ¼ã‚¸ã«ã€Œãƒ¦ãƒ¼ã‚¶ãƒ¼å…¥åŠ›ã€ãƒ–ãƒãƒƒã‚¯ã‚’è¨ç½®ã—ã€ã€Œå¤‰æ•°åã€ã«å…¥åŠ›ã‚’æ ¼ç´ã—ãŸã„変数åã‚’è¨å®šã—ã¾ã™(変数ã¯è‡ªå‹•ã§ä½œæˆã•ã‚Œã¾ã™)。ãã®å¤‰æ•°ã‚’使ã£ã¦ãƒ¦ãƒ¼ã‚¶ãƒ¼å…¥åŠ›ã«å¿œã˜ãŸå‹•ä½œã‚’è¡Œãˆã¾ã™ã€‚" + more-details: "詳ã—ã„説明" + title: "タイトル" + url: "ページURL" + summary: "ページã®è¦ç´„" + align-center: "ä¸å¤®å¯„ã›" + font: "フォント" + fontSerif: "セリフ" + fontSansSerif: "サンセリフ" + set-eye-catchig-image: "アイã‚ャッãƒç”»åƒã‚’è¨å®š" + remove-eye-catchig-image: "アイã‚ャッãƒç”»åƒã‚’削除" + choose-block: "ブãƒãƒƒã‚¯ã‚’è¿½åŠ " + select-type: "種類をé¸æŠž" + enter-variable-name: "変数åを決ã‚ã¦ãã ã•ã„" + the-variable-name-is-already-used: "ãã®å¤‰æ•°åã¯æ—¢ã«ä½¿ã‚ã‚Œã¦ã„ã¾ã™" + blocks: + text: "テã‚スト" + section: "セクション" + image: "ç”»åƒ" + button: "ボタン" + input: "ユーザー入力" + _input: + name: "変数å" + text: "タイトル" + default: "デフォルト値" + inputType: "入力ã®ç¨®é¡ž" + _inputType: + string: "テã‚スト" + number: "数値" + switch: "スイッãƒ" + _switch: + name: "変数å" + text: "タイトル" + default: "デフォルト値" + _button: + text: "タイトル" + action: "ボタンを押ã—ãŸã¨ãã®å‹•ä½œ" + _action: + dialog: "ダイアãƒã‚°ã‚’表示ã™ã‚‹" + _dialog: + content: "内容" + resetRandom: "乱数をリセット" + script: + categories: + flow: "制御" + logical: "è«–ç†æ¼”ç®—" + operation: "計算" + comparison: "比較" + random: "ランダム" + value: "値" + fn: "関数" + blocks: + text: "テã‚スト" + multiLineText: "テã‚スト(複数行)" + textList: "テã‚ストã®ãƒªã‚¹ãƒˆ" + add: "+ 足ã™" + _add: + arg1: "A" + arg2: "B" + subtract: "- 引ã" + _subtract: + arg1: "A" + arg2: "B" + multiply: "× 掛ã‘ã‚‹" + _multiply: + arg1: "A" + arg2: "B" + divide: "÷ 割る" + _divide: + arg1: "A" + arg2: "B" + eq: "Aã¨BãŒåŒã˜" + _eq: + arg1: "A" + arg2: "B" + notEq: "Aã¨BãŒç•°ãªã‚‹" + _notEq: + arg1: "A" + arg2: "B" + and: "Aã‹ã¤B" + _and: + arg1: "A" + arg2: "B" + or: "Aã¾ãŸã¯B" + _or: + arg1: "A" + arg2: "B" + lt: "< AãŒBよりå°ã•ã„" + _lt: + arg1: "A" + arg2: "B" + gt: "> AãŒBより大ãã„" + _gt: + arg1: "A" + arg2: "B" + ltEq: "<= AãŒBã¨åŒã˜ã‹å°ã•ã„" + _ltEq: + arg1: "A" + arg2: "B" + gtEq: ">= AãŒBã¨åŒã˜ã‹å¤§ãã„" + _gtEq: + arg1: "A" + arg2: "B" + if: "分å²" + _if: + arg1: "ã‚‚ã—" + arg2: "ãªã‚‰" + arg3: "ãã†ã§ãªã‘ã‚Œã°" + not: "å¦å®š" + _not: + arg1: "å¦å®š" + random: "ランダム" + _random: + arg1: "確率" + rannum: "乱数" + _rannum: + arg1: "最å°" + arg2: "最大" + randomPick: "リストã‹ã‚‰ãƒ©ãƒ³ãƒ€ãƒ ã«é¸æŠž" + _randomPick: + arg1: "リスト" + dailyRandom: "ランダム(ユーザーã”ã¨ã«æ—¥æ›¿ã‚ã‚Š)" + _dailyRandom: + arg1: "確率" + dailyRannum: "乱数 (ユーザーã”ã¨ã«æ—¥æ›¿ã‚ã‚Š)" + _dailyRannum: + arg1: "最å°" + arg2: "最大" + dailyRandomPick: "リストã‹ã‚‰ãƒ©ãƒ³ãƒ€ãƒ ã«é¸æŠž (ユーザーã”ã¨ã«æ—¥æ›¿ã‚ã‚Š)" + _dailyRandomPick: + arg1: "リスト" + number: "æ•°" + ref: "変数" + in: "入力" + _in: + arg1: "スãƒãƒƒãƒˆç•ªå·" + fn: "関数" + _fn: + arg1: "出力" + typeError: "スãƒãƒƒãƒˆ{slot}ã¯\"{expect}\"ã‚’å—ã‘付ã‘ã¾ã™ãŒã€\"{actual}\"ãŒå…¥ã‚Œã‚‰ã‚Œã¦ã„ã¾ã™ï¼" + thereIsEmptySlot: "スãƒãƒƒãƒˆ{slot}ãŒç©ºã§ã™ï¼" + types: + string: "テã‚スト" + number: "数値" + boolean: "フラグ" + array: "リスト" + stringArray: "テã‚ストã®ãƒªã‚¹ãƒˆ" + emptySlot: "空ã®ã‚¹ãƒãƒƒãƒˆ" + enviromentVariables: "環境変数" + pageVariables: "ページè¦ç´ " diff --git a/migration/1556348509290-Pages.ts b/migration/1556348509290-Pages.ts new file mode 100644 index 0000000000..c44b4b1f79 --- /dev/null +++ b/migration/1556348509290-Pages.ts @@ -0,0 +1,31 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class Pages1556348509290 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise<any> { + await queryRunner.query(`CREATE TYPE "page_visibility_enum" AS ENUM('public', 'followers', 'specified')`); + await queryRunner.query(`CREATE TABLE "page" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "title" character varying(256) NOT NULL, "name" character varying(256) NOT NULL, "summary" character varying(256), "alignCenter" boolean NOT NULL, "font" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "eyeCatchingImageId" character varying(32), "content" jsonb NOT NULL DEFAULT '[]', "variables" jsonb NOT NULL DEFAULT '[]', "visibility" "page_visibility_enum" NOT NULL, "visibleUserIds" character varying(32) array NOT NULL DEFAULT '{}'::varchar[], CONSTRAINT "PK_742f4117e065c5b6ad21b37ba1f" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_fbb4297c927a9b85e9cefa2eb1" ON "page" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_af639b066dfbca78b01a920f8a" ON "page" ("updatedAt") `); + await queryRunner.query(`CREATE INDEX "IDX_b82c19c08afb292de4600d99e4" ON "page" ("name") `); + await queryRunner.query(`CREATE INDEX "IDX_ae1d917992dd0c9d9bbdad06c4" ON "page" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_90148bbc2bf0854428786bfc15" ON "page" ("visibleUserIds") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_2133ef8317e4bdb839c0dcbf13" ON "page" ("userId", "name") `); + await queryRunner.query(`ALTER TABLE "page" ADD CONSTRAINT "FK_ae1d917992dd0c9d9bbdad06c4a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "page" ADD CONSTRAINT "FK_3126dd7c502c9e4d7597ef7ef10" FOREIGN KEY ("eyeCatchingImageId") REFERENCES "drive_file"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise<any> { + await queryRunner.query(`ALTER TABLE "page" DROP CONSTRAINT "FK_3126dd7c502c9e4d7597ef7ef10"`); + await queryRunner.query(`ALTER TABLE "page" DROP CONSTRAINT "FK_ae1d917992dd0c9d9bbdad06c4a"`); + await queryRunner.query(`DROP INDEX "IDX_2133ef8317e4bdb839c0dcbf13"`); + await queryRunner.query(`DROP INDEX "IDX_90148bbc2bf0854428786bfc15"`); + await queryRunner.query(`DROP INDEX "IDX_ae1d917992dd0c9d9bbdad06c4"`); + await queryRunner.query(`DROP INDEX "IDX_b82c19c08afb292de4600d99e4"`); + await queryRunner.query(`DROP INDEX "IDX_af639b066dfbca78b01a920f8a"`); + await queryRunner.query(`DROP INDEX "IDX_fbb4297c927a9b85e9cefa2eb1"`); + await queryRunner.query(`DROP TABLE "page"`); + await queryRunner.query(`DROP TYPE "page_visibility_enum"`); + } + +} diff --git a/package.json b/package.json index b1900c8fa7..780ce5563a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "misskey", "author": "syuilo <i@syuilo.com>", - "version": "11.4.0", + "version": "11.5.0", "codename": "daybreak", "repository": { "type": "git", @@ -199,6 +199,7 @@ "rimraf": "2.6.3", "rndstr": "1.0.0", "s-age": "1.1.2", + "seedrandom": "3.0.1", "sharp": "0.22.0", "showdown": "1.9.0", "showdown-highlightjs-extension": "0.1.2", diff --git a/src/client/app/common/scripts/aiscript.ts b/src/client/app/common/scripts/aiscript.ts new file mode 100644 index 0000000000..4ef21f9943 --- /dev/null +++ b/src/client/app/common/scripts/aiscript.ts @@ -0,0 +1,470 @@ +/** + * AiScript + * evaluator & type checker + */ + +import autobind from 'autobind-decorator'; +import * as seedrandom from 'seedrandom'; + +import { + faSuperscript, + faAlignLeft, + faShareAlt, + faSquareRootAlt, + faPlus, + faMinus, + faTimes, + faDivide, + faList, + faQuoteRight, + faEquals, + faGreaterThan, + faLessThan, + faGreaterThanEqual, + faLessThanEqual, + faExclamation, + faNotEqual, + faDice, + faSortNumericUp, +} from '@fortawesome/free-solid-svg-icons'; +import { faFlag } from '@fortawesome/free-regular-svg-icons'; + +import { version } from '../../config'; + +export type Block = { + id: string; + type: string; + args: Block[]; + value: any; +}; + +export type Variable = Block & { + name: string; +}; + +type Type = 'string' | 'number' | 'boolean' | 'stringArray'; + +type TypeError = { + arg: number; + expect: Type; + actual: Type; +}; + +const funcDefs = { + if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: faShareAlt, }, + not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: faFlag, }, + or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: faFlag, }, + and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: faFlag, }, + add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faPlus, }, + subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faMinus, }, + multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faTimes, }, + divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faDivide, }, + eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: faEquals, }, + notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: faNotEqual, }, + gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faGreaterThan, }, + lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faLessThan, }, + gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faGreaterThanEqual, }, + ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faLessThanEqual, }, + rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: faDice, }, + random: { in: ['number'], out: 'boolean', category: 'random', icon: faDice, }, + randomPick: { in: [0], out: 0, category: 'random', icon: faDice, }, + dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: faDice, }, + dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: faDice, }, + dailyRandomPick: { in: [0], out: 0, category: 'random', icon: faDice, }, +}; + +const blockDefs = [ + { type: 'text', out: 'string', category: 'value', icon: faQuoteRight, }, + { type: 'multiLineText', out: 'string', category: 'value', icon: faAlignLeft, }, + { type: 'textList', out: 'stringArray', category: 'value', icon: faList, }, + { type: 'number', out: 'number', category: 'value', icon: faSortNumericUp, }, + { type: 'ref', out: null, category: 'value', icon: faSuperscript, }, + { type: 'in', out: null, category: 'value', icon: faSuperscript, }, + { type: 'fn', out: 'function', category: 'value', icon: faSuperscript, }, + ...Object.entries(funcDefs).map(([k, v]) => ({ + type: k, out: v.out || null, category: v.category, icon: v.icon + })) +]; + +type PageVar = { name: string; value: any; type: Type; }; + +const envVarsDef = { + AI: 'string', + VERSION: 'string', + LOGIN: 'boolean', + NAME: 'string', + USERNAME: 'string', + USERID: 'string', + NOTES_COUNT: 'number', + FOLLOWERS_COUNT: 'number', + FOLLOWING_COUNT: 'number', + IS_CAT: 'boolean', + MY_NOTES_COUNT: 'number', + MY_FOLLOWERS_COUNT: 'number', + MY_FOLLOWING_COUNT: 'number', +}; + +export class AiScript { + private variables: Variable[]; + private pageVars: PageVar[]; + private envVars: Record<keyof typeof envVarsDef, any>; + + public static envVarsDef = envVarsDef; + public static blockDefs = blockDefs; + public static funcDefs = funcDefs; + private opts: { + randomSeed?: string; user?: any; visitor?: any; + }; + + constructor(variables: Variable[] = [], pageVars: PageVar[] = [], opts: AiScript['opts'] = {}) { + this.variables = variables; + this.pageVars = pageVars; + this.opts = opts; + + this.envVars = { + AI: 'kawaii', + VERSION: version, + LOGIN: opts.visitor != null, + NAME: opts.visitor ? opts.visitor.name : '', + USERNAME: opts.visitor ? opts.visitor.username : '', + USERID: opts.visitor ? opts.visitor.id : '', + NOTES_COUNT: opts.visitor ? opts.visitor.notesCount : 0, + FOLLOWERS_COUNT: opts.visitor ? opts.visitor.followersCount : 0, + FOLLOWING_COUNT: opts.visitor ? opts.visitor.followingCount : 0, + IS_CAT: opts.visitor ? opts.visitor.isCat : false, + MY_NOTES_COUNT: opts.user ? opts.user.notesCount : 0, + MY_FOLLOWERS_COUNT: opts.user ? opts.user.followersCount : 0, + MY_FOLLOWING_COUNT: opts.user ? opts.user.followingCount : 0, + }; + } + + @autobind + public injectVars(vars: Variable[]) { + this.variables = vars; + } + + @autobind + public injectPageVars(pageVars: PageVar[]) { + this.pageVars = pageVars; + } + + @autobind + public updatePageVar(name: string, value: any) { + this.pageVars.find(v => v.name === name).value = value; + } + + @autobind + public updateRandomSeed(seed: string) { + this.opts.randomSeed = seed; + } + + @autobind + public static isLiteralBlock(v: Block) { + if (v.type === null) return true; + if (v.type === 'text') return true; + if (v.type === 'multiLineText') return true; + if (v.type === 'textList') return true; + if (v.type === 'number') return true; + if (v.type === 'ref') return true; + if (v.type === 'fn') return true; + if (v.type === 'in') return true; + return false; + } + + @autobind + public typeCheck(v: Block): TypeError | null { + if (AiScript.isLiteralBlock(v)) return null; + + const def = AiScript.funcDefs[v.type]; + if (def == null) { + throw new Error('Unknown type: ' + v.type); + } + + const generic: Type[] = []; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + const type = this.typeInference(v.args[i]); + if (type === null) continue; + + if (typeof arg === 'number') { + if (generic[arg] === undefined) { + generic[arg] = type; + } else if (type !== generic[arg]) { + return { + arg: i, + expect: generic[arg], + actual: type + }; + } + } else if (type !== arg) { + return { + arg: i, + expect: arg, + actual: type + }; + } + } + + return null; + } + + @autobind + public getExpectedType(v: Block, slot: number): Type | null { + const def = AiScript.funcDefs[v.type]; + if (def == null) { + throw new Error('Unknown type: ' + v.type); + } + + const generic: Type[] = []; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + const type = this.typeInference(v.args[i]); + if (type === null) continue; + + if (typeof arg === 'number') { + if (generic[arg] === undefined) { + generic[arg] = type; + } + } + } + + if (typeof def.in[slot] === 'number') { + return generic[def.in[slot]] || null; + } else { + return def.in[slot]; + } + } + + @autobind + public typeInference(v: Block): Type | null { + if (v.type === null) return null; + if (v.type === 'text') return 'string'; + if (v.type === 'multiLineText') return 'string'; + if (v.type === 'textList') return 'stringArray'; + if (v.type === 'number') return 'number'; + if (v.type === 'ref') { + const variable = this.variables.find(va => va.name === v.value); + if (variable) { + return this.typeInference(variable); + } + + const pageVar = this.pageVars.find(va => va.name === v.value); + if (pageVar) { + return pageVar.type; + } + + const envVar = AiScript.envVarsDef[v.value]; + if (envVar) { + return envVar; + } + + return null; + } + if (v.type === 'fn') return null; // todo + if (v.type === 'in') return null; // todo + + const generic: Type[] = []; + + const def = AiScript.funcDefs[v.type]; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + if (typeof arg === 'number') { + const type = this.typeInference(v.args[i]); + + if (generic[arg] === undefined) { + generic[arg] = type; + } else { + if (type !== generic[arg]) { + generic[arg] = null; + } + } + } + } + + if (typeof def.out === 'number') { + return generic[def.out]; + } else { + return def.out; + } + } + + @autobind + public getVarsByType(type: Type | null): Variable[] { + if (type == null) return this.variables; + return this.variables.filter(x => (this.typeInference(x) === null) || (this.typeInference(x) === type)); + } + + @autobind + public getVarByName(name: string): Variable { + return this.variables.find(x => x.name === name); + } + + @autobind + public getEnvVarsByType(type: Type | null): string[] { + if (type == null) return Object.keys(AiScript.envVarsDef); + return Object.entries(AiScript.envVarsDef).filter(([k, v]) => type === v).map(([k, v]) => k); + } + + @autobind + public getPageVarsByType(type: Type | null): string[] { + if (type == null) return this.pageVars.map(v => v.name); + return this.pageVars.filter(v => type === v.type).map(v => v.name); + } + + @autobind + private interpolate(str: string, values: { name: string, value: any }[]) { + return str.replace(/\{(.+?)\}/g, match => + (this.getVariableValue(match.slice(1, -1).trim(), values) || '').toString()); + } + + @autobind + public evaluateVars() { + const values: { name: string, value: any }[] = []; + + for (const v of this.variables) { + values.push({ + name: v.name, + value: this.evaluate(v, values) + }); + } + + for (const v of this.pageVars) { + values.push({ + name: v.name, + value: v.value + }); + } + + for (const [k, v] of Object.entries(this.envVars)) { + values.push({ + name: k, + value: v + }); + } + + return values; + } + + @autobind + private evaluate(block: Block, values: { name: string, value: any }[], slotArg: Record<string, any> = {}): any { + if (block.type === null) { + return null; + } + + if (block.type === 'number') { + return parseInt(block.value, 10); + } + + if (block.type === 'text' || block.type === 'multiLineText') { + return this.interpolate(block.value, values); + } + + if (block.type === 'textList') { + return block.value.trim().split('\n'); + } + + if (block.type === 'ref') { + return this.getVariableValue(block.value, values); + } + + if (block.type === 'in') { + return slotArg[block.value]; + } + + if (block.type === 'fn') { // ユーザー関数定義 + return { + slots: block.value.slots, + exec: slotArg => this.evaluate(block.value.expression, values, slotArg) + }; + } + + if (block.type.startsWith('fn:')) { // ユーザー関数呼ã³å‡ºã— + const fnName = block.type.split(':')[1]; + const fn = this.getVariableValue(fnName, values); + for (let i = 0; i < fn.slots.length; i++) { + const name = fn.slots[i]; + slotArg[name] = this.evaluate(block.args[i], values); + } + return fn.exec(slotArg); + } + + if (block.args === undefined) return null; + + const date = new Date(); + const day = `${this.opts.visitor ? this.opts.visitor.id : ''} ${date.getFullYear()}/${date.getMonth()}/${date.getDate()}`; + + const funcs: { [p in keyof typeof funcDefs]: any } = { + not: (a) => !a, + eq: (a, b) => a === b, + notEq: (a, b) => a !== b, + gt: (a, b) => a > b, + lt: (a, b) => a < b, + gtEq: (a, b) => a >= b, + ltEq: (a, b) => a <= b, + or: (a, b) => a || b, + and: (a, b) => a && b, + if: (bool, a, b) => bool ? a : b, + add: (a, b) => a + b, + subtract: (a, b) => a - b, + multiply: (a, b) => a * b, + divide: (a, b) => a / b, + random: (probability) => Math.floor(seedrandom(`${this.opts.randomSeed}:${block.id}`)() * 100) < probability, + rannum: (min, max) => min + Math.floor(seedrandom(`${this.opts.randomSeed}:${block.id}`)() * (max - min + 1)), + randomPick: (list) => list[Math.floor(seedrandom(`${this.opts.randomSeed}:${block.id}`)() * list.length)], + dailyRandom: (probability) => Math.floor(seedrandom(`${day}:${block.id}`)() * 100) < probability, + dailyRannum: (min, max) => min + Math.floor(seedrandom(`${day}:${block.id}`)() * (max - min + 1)), + dailyRandomPick: (list) => list[Math.floor(seedrandom(`${day}:${block.id}`)() * list.length)], + }; + + const fnName = block.type; + + const fn = funcs[fnName]; + if (fn == null) { + console.error('Unknown function: ' + fnName); + throw new Error('Unknown function: ' + fnName); + } + + const args = block.args.map(x => this.evaluate(x, values, slotArg)); + + return fn(...args); + } + + @autobind + private getVariableValue(name: string, values: { name: string, value: any }[]): any { + const v = values.find(v => v.name === name); + if (v) { + return v.value; + } + + const pageVar = this.pageVars.find(v => v.name === name); + if (pageVar) { + return pageVar.value; + } + + if (AiScript.envVarsDef[name]) { + return this.envVars[name].value; + } + + throw new Error(`Script: No such variable '${name}'`); + } + + @autobind + public isUsedName(name: string) { + if (this.variables.some(v => v.name === name)) { + return true; + } + + if (this.pageVars.some(v => v.name === name)) { + return true; + } + + if (AiScript.envVarsDef[name]) { + return true; + } + + return false; + } +} diff --git a/src/client/app/common/scripts/collect-page-vars.ts b/src/client/app/common/scripts/collect-page-vars.ts new file mode 100644 index 0000000000..86687e21f4 --- /dev/null +++ b/src/client/app/common/scripts/collect-page-vars.ts @@ -0,0 +1,24 @@ +export function collectPageVars(content) { + const pageVars = []; + const collect = (xs: any[]) => { + for (const x of xs) { + if (x.type === 'input') { + pageVars.push({ + name: x.name, + type: x.inputType, + value: x.default + }); + } else if (x.type === 'switch') { + pageVars.push({ + name: x.name, + type: 'boolean', + value: x.default + }); + } else if (x.children) { + collect(x.children); + } + } + }; + collect(content); + return pageVars; +} diff --git a/src/client/app/common/views/components/dialog.vue b/src/client/app/common/views/components/dialog.vue index c1ee7958c0..020c88f699 100644 --- a/src/client/app/common/views/components/dialog.vue +++ b/src/client/app/common/views/components/dialog.vue @@ -22,7 +22,14 @@ <ui-input v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></ui-input> <ui-input v-if="user" v-model="userInputValue" autofocus @keydown="onInputKeydown"><template #prefix>@</template></ui-input> <ui-select v-if="select" v-model="selectedValue" autofocus> - <option v-for="item in select.items" :value="item.value">{{ item.text }}</option> + <template v-if="select.items"> + <option v-for="item in select.items" :value="item.value">{{ item.text }}</option> + </template> + <template v-else> + <optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label"> + <option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option> + </optgroup> + </template> </ui-select> <ui-horizon-group no-grow class="buttons fit-bottom" v-if="!splash && (showOkButton || showCancelButton)"> <ui-button @click="ok" v-if="showOkButton" primary :autofocus="!input && !select && !user">{{ (showCancelButton || input || select || user) ? $t('@.ok') : $t('@.got-it') }}</ui-button> @@ -230,7 +237,7 @@ export default Vue.extend({ font-size 32px &.success - color #37ec92 + color #85da5a &.error color #ec4137 diff --git a/src/client/app/common/views/components/media-image.vue b/src/client/app/common/views/components/media-image.vue index 2559907512..6db4b40dd8 100644 --- a/src/client/app/common/views/components/media-image.vue +++ b/src/client/app/common/views/components/media-image.vue @@ -36,7 +36,7 @@ export default Vue.extend({ return { hide: true }; - } + }, computed: { style(): any { let url = `url(${ diff --git a/src/client/app/common/views/components/page-editor/page-editor.block.vue b/src/client/app/common/views/components/page-editor/page-editor.block.vue new file mode 100644 index 0000000000..a3e1488d1b --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.block.vue @@ -0,0 +1,25 @@ +<template> +<component :is="'x-' + value.type" :value="value" @input="v => updateItem(v)" @remove="() => $emit('remove', value)" :key="value.id"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XSection from './page-editor.section.vue'; +import XText from './page-editor.text.vue'; +import XImage from './page-editor.image.vue'; +import XButton from './page-editor.button.vue'; +import XInput from './page-editor.input.vue'; +import XSwitch from './page-editor.switch.vue'; + +export default Vue.extend({ + components: { + XSection, XText, XImage, XButton, XInput, XSwitch + }, + + props: { + value: { + required: true + } + }, +}); +</script> diff --git a/src/client/app/common/views/components/page-editor/page-editor.button.vue b/src/client/app/common/views/components/page-editor/page-editor.button.vue new file mode 100644 index 0000000000..d5fc243818 --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.button.vue @@ -0,0 +1,54 @@ +<template> +<x-container @remove="() => $emit('remove')"> + <template #header><fa :icon="faBolt"/> {{ $t('blocks.button') }}</template> + + <section class="xfhsjczc"> + <ui-input v-model="value.text"><span>{{ $t('blocks._button.text') }}</span></ui-input> + <ui-select v-model="value.action"> + <template #label>{{ $t('blocks._button.action') }}</template> + <option value="dialog">{{ $t('blocks._button._action.dialog') }}</option> + <option value="resetRandom">{{ $t('blocks._button._action.resetRandom') }}</option> + </ui-select> + <ui-input v-if="value.action === 'dialog'" v-model="value.content"><span>{{ $t('blocks._button._action._dialog.content') }}</span></ui-input> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import { faBolt } from '@fortawesome/free-solid-svg-icons'; +import XContainer from './page-editor.container.vue'; + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XContainer + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + faBolt + }; + }, + + created() { + if (this.value.text == null) Vue.set(this.value, 'text', ''); + if (this.value.action == null) Vue.set(this.value, 'action', 'dialog'); + if (this.value.content == null) Vue.set(this.value, 'content', null); + }, +}); +</script> + +<style lang="stylus" scoped> +.xfhsjczc + padding 0 16px 0 16px + +</style> diff --git a/src/client/app/common/views/components/page-editor/page-editor.container.vue b/src/client/app/common/views/components/page-editor/page-editor.container.vue new file mode 100644 index 0000000000..698fdfee45 --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.container.vue @@ -0,0 +1,135 @@ +<template> +<div class="cpjygsrt" :class="{ error: error != null, warn: warn != null }"> + <header> + <div class="title"><slot name="header"></slot></div> + <div class="buttons"> + <slot name="func"></slot> + <button v-if="removable" @click="remove()"> + <fa :icon="faTrashAlt"/> + </button> + <button @click="toggleContent(!showBody)"> + <template v-if="showBody"><fa icon="angle-up"/></template> + <template v-else><fa icon="angle-down"/></template> + </button> + </div> + </header> + <p v-show="showBody" class="error" v-if="error != null">{{ $t('script.typeError', { slot: error.arg + 1, expect: $t(`script.types.${error.expect}`), actual: $t(`script.types.${error.actual}`) }) }}</p> + <p v-show="showBody" class="warn" v-if="warn != null">{{ $t('script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p> + <div v-show="showBody"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../../../../i18n'; + +export default Vue.extend({ + i18n: i18n('pages'), + + props: { + expanded: { + type: Boolean, + default: true + }, + removable: { + type: Boolean, + default: true + }, + error: { + required: false, + default: null + }, + warn: { + required: false, + default: null + } + }, + data() { + return { + showBody: this.expanded, + faTrashAlt + }; + }, + methods: { + toggleContent(show: boolean) { + this.showBody = show; + this.$emit('toggle', show); + }, + remove() { + this.$emit('remove'); + } + } +}); +</script> + +<style lang="stylus" scoped> +.cpjygsrt + overflow hidden + background var(--face) + border solid 2px var(--pageBlockBorder) + border-radius 6px + + &:hover + border solid 2px var(--pageBlockBorderHover) + + &.warn + border solid 2px #dec44c + + &.error + border solid 2px #f00 + + & + .cpjygsrt + margin-top 16px + + > header + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color var(--faceHeaderText) + box-shadow 0 1px rgba(#000, 0.07) + + > [data-icon] + margin-right 6px + + &:empty + display none + + > .buttons + position absolute + z-index 2 + top 0 + right 0 + + > button + padding 0 + width 42px + font-size 0.9em + line-height 42px + color var(--faceTextButton) + + &:hover + color var(--faceTextButtonHover) + + &:active + color var(--faceTextButtonActive) + + > .warn + color #b19e49 + margin 0 + padding 16px 16px 0 16px + font-size 14px + + > .error + color #f00 + margin 0 + padding 16px 16px 0 16px + font-size 14px + +</style> diff --git a/src/client/app/common/views/components/page-editor/page-editor.image.vue b/src/client/app/common/views/components/page-editor/page-editor.image.vue new file mode 100644 index 0000000000..0bc1816e8d --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.image.vue @@ -0,0 +1,78 @@ +<template> +<x-container @remove="() => $emit('remove')"> + <template #header><fa :icon="faImage"/> {{ $t('blocks.image') }}</template> + <template #func> + <button @click="choose()"> + <fa :icon="faFolderOpen"/> + </button> + </template> + + <section class="oyyftmcf"> + <x-file-thumbnail class="preview" v-if="file" :file="file" :detail="true" fit="contain" @click="choose()"/> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import { faPencilAlt } from '@fortawesome/free-solid-svg-icons'; +import { faImage, faFolderOpen } from '@fortawesome/free-regular-svg-icons'; +import XContainer from './page-editor.container.vue'; +import XFileThumbnail from '../drive-file-thumbnail.vue'; + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XContainer, XFileThumbnail + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + file: null, + faPencilAlt, faImage, faFolderOpen + }; + }, + + created() { + if (this.value.fileId === undefined) Vue.set(this.value, 'fileId', null); + }, + + mounted() { + if (this.value.fileId == null) { + this.choose(); + } else { + this.$root.api('drive/files/show', { + fileId: this.value.fileId + }).then(file => { + this.file = file; + }); + } + }, + + methods: { + async choose() { + this.$chooseDriveFile({ + multiple: false + }).then(file => { + this.file = file; + this.value.fileId = file.id; + }); + }, + } +}); +</script> + +<style lang="stylus" scoped> +.oyyftmcf + > .preview + height 150px + +</style> diff --git a/src/client/app/common/views/components/page-editor/page-editor.input.vue b/src/client/app/common/views/components/page-editor/page-editor.input.vue new file mode 100644 index 0000000000..1f3754252b --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.input.vue @@ -0,0 +1,54 @@ +<template> +<x-container @remove="() => $emit('remove')"> + <template #header><fa :icon="faBolt"/> {{ $t('blocks.input') }}</template> + + <section class="dnvasjon"> + <ui-input v-model="value.name"><template #prefix><fa :icon="faSquareRootAlt"/></template><span>{{ $t('blocks._input.name') }}</span></ui-input> + <ui-input v-model="value.text"><span>{{ $t('blocks._input.text') }}</span></ui-input> + <ui-select v-model="value.inputType"> + <template #label>{{ $t('blocks._input.inputType') }}</template> + <option value="string">{{ $t('blocks._input._inputType.string') }}</option> + <option value="number">{{ $t('blocks._input._inputType.number') }}</option> + </ui-select> + <ui-input v-model="value.default" :type="value.inputType"><span>{{ $t('blocks._input.default') }}</span></ui-input> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import { faBolt, faSquareRootAlt } from '@fortawesome/free-solid-svg-icons'; +import XContainer from './page-editor.container.vue'; + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XContainer + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + faBolt, faSquareRootAlt + }; + }, + + created() { + if (this.value.name == null) Vue.set(this.value, 'name', ''); + if (this.value.inputType == null) Vue.set(this.value, 'inputType', 'string'); + }, +}); +</script> + +<style lang="stylus" scoped> +.dnvasjon + padding 0 16px 0 16px + +</style> diff --git a/src/client/app/common/views/components/page-editor/page-editor.script-block.vue b/src/client/app/common/views/components/page-editor/page-editor.script-block.vue new file mode 100644 index 0000000000..3122832030 --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.script-block.vue @@ -0,0 +1,263 @@ +<template> +<x-container :removable="removable" @remove="() => $emit('remove')" :error="error" :warn="warn"> + <template #header><fa v-if="icon" :icon="icon"/> <template v-if="title">{{ title }} <span class="turmquns" v-if="typeText">({{ typeText }})</span></template><template v-else-if="typeText">{{ typeText }}</template></template> + <template #func> + <button @click="changeType()"> + <fa :icon="faPencilAlt"/> + </button> + </template> + + <section v-if="value.type === null" class="pbglfege" @click="changeType()"> + {{ $t('script.emptySlot') }} + </section> + <section v-else-if="value.type === 'text'" class="tbwccoaw"> + <input v-model="value.value"/> + </section> + <section v-else-if="value.type === 'multiLineText'" class="tbwccoaw"> + <textarea v-model="value.value"></textarea> + </section> + <section v-else-if="value.type === 'textList'" class="frvuzvoi"> + <ui-textarea v-model="value.value"></ui-textarea> + </section> + <section v-else-if="value.type === 'number'" class="tbwccoaw"> + <input v-model="value.value" type="number"/> + </section> + <section v-else-if="value.type === 'ref'" class="hpdwcrvs"> + <select v-model="value.value"> + <option v-for="v in aiScript.getVarsByType(getExpectedType ? getExpectedType() : null).filter(x => x.name !== name)" :value="v.name">{{ v.name }}</option> + <optgroup :label="$t('script.pageVariables')"> + <option v-for="v in aiScript.getPageVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option> + </optgroup> + <optgroup :label="$t('script.enviromentVariables')"> + <option v-for="v in aiScript.getEnvVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option> + </optgroup> + </select> + </section> + <section v-else-if="value.type === 'in'" class="hpdwcrvs"> + <select v-model="value.value"> + <option v-for="v in fnSlots" :value="v">{{ v }}</option> + </select> + </section> + <section v-else-if="value.type === 'fn'" class="" style="padding:16px;"> + <ui-textarea v-model="slots"></ui-textarea> + <x-v v-if="value.value.expression" v-model="value.value.expression" :title="$t(`script.blocks._fn.arg1`)" :get-expected-type="() => null" :ai-script="aiScript" :fn-slots="value.value.slots" :name="name"/> + </section> + <section v-else-if="value.type.startsWith('fn:')" class="" style="padding:16px;"> + <x-v v-for="(x, i) in value.args" v-model="value.args[i]" :title="aiScript.getVarByName(value.type.split(':')[1]).value.slots[i]" :get-expected-type="() => null" :ai-script="aiScript" :name="name" :key="i"/> + </section> + <section v-else class="" style="padding:16px;"> + <x-v v-for="(x, i) in value.args" v-model="value.args[i]" :title="$t(`script.blocks._${value.type}.arg${i + 1}`)" :get-expected-type="() => _getExpectedType(i)" :ai-script="aiScript" :name="name" :fn-slots="fnSlots" :key="i"/> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import XContainer from './page-editor.container.vue'; +import { faSuperscript, faPencilAlt, faSquareRootAlt } from '@fortawesome/free-solid-svg-icons'; +import { AiScript } from '../../../scripts/aiscript'; +import * as uuid from 'uuid'; + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XContainer + }, + + inject: ['getScriptBlockList'], + + props: { + getExpectedType: { + required: false, + default: null + }, + value: { + required: true + }, + title: { + required: false + }, + removable: { + required: false, + default: false + }, + aiScript: { + required: true, + }, + name: { + required: true, + }, + fnSlots: { + required: false, + }, + }, + + data() { + return { + AiScript, + error: null, + warn: null, + slots: '', + faSuperscript, faPencilAlt, faSquareRootAlt + }; + }, + + computed: { + icon(): any { + if (this.value.type === null) return null; + if (this.value.type.startsWith('fn:')) return null; + return AiScript.blockDefs.find(x => x.type === this.value.type).icon; + }, + typeText(): any { + if (this.value.type === null) return null; + return this.$t(`script.blocks.${this.value.type}`); + }, + }, + + watch: { + slots() { + this.value.value.slots = this.slots.split('\n'); + } + }, + + beforeCreate() { + this.$options.components.XV = require('./page-editor.script-block.vue').default; + }, + + created() { + if (this.value.value == null) Vue.set(this.value, 'value', null); + + if (this.value.value && this.value.value.slots) this.slots = this.value.value.slots.join('\n'); + + this.$watch('value.type', (t) => { + this.warn = null; + + if (this.value.type === 'fn') { + const id = uuid.v4(); + this.value.value = {}; + Vue.set(this.value.value, 'slots', []); + Vue.set(this.value.value, 'expression', { id, type: null }); + return; + } + + if (this.value.type && this.value.type.startsWith('fn:')) { + const fnName = this.value.type.split(':')[1]; + const fn = this.aiScript.getVarByName(fnName); + + const empties = []; + for (let i = 0; i < fn.value.slots.length; i++) { + const id = uuid.v4(); + empties.push({ id, type: null }); + } + Vue.set(this.value, 'args', empties); + return; + } + + if (AiScript.isLiteralBlock(this.value)) return; + + const empties = []; + for (let i = 0; i < AiScript.funcDefs[this.value.type].in.length; i++) { + const id = uuid.v4(); + empties.push({ id, type: null }); + } + Vue.set(this.value, 'args', empties); + + for (let i = 0; i < AiScript.funcDefs[this.value.type].in.length; i++) { + const inType = AiScript.funcDefs[this.value.type].in[i]; + if (typeof inType !== 'number') { + if (inType === 'number') this.value.args[i].type = 'number'; + if (inType === 'string') this.value.args[i].type = 'text'; + } + } + }); + + this.$watch('value.args', (args) => { + if (args == null) { + this.warn = null; + return; + } + const emptySlotIndex = args.findIndex(x => x.type === null); + if (emptySlotIndex !== -1 && emptySlotIndex < args.length) { + this.warn = { + slot: emptySlotIndex + }; + } else { + this.warn = null; + } + }, { + deep: true + }); + + this.$watch('aiScript.variables', () => { + if (this.type != null && this.value) { + this.error = this.aiScript.typeCheck(this.value); + } + }, { + deep: true + }); + }, + + methods: { + async changeType() { + const { canceled, result: type } = await this.$root.dialog({ + type: null, + title: this.$t('select-type'), + select: { + groupedItems: this.getScriptBlockList(this.getExpectedType ? this.getExpectedType() : null) + }, + showCancelButton: true + }); + if (canceled) return; + this.value.type = type; + }, + + _getExpectedType(slot: number) { + return this.aiScript.getExpectedType(this.value, slot); + } + } +}); +</script> + +<style lang="stylus" scoped> +.turmquns + opacity 0.7 + +.pbglfege + opacity 0.5 + padding 16px + text-align center + cursor pointer + color var(--text) + +.tbwccoaw + > input + > textarea + display block + -webkit-appearance none + -moz-appearance none + appearance none + width 100% + max-width 100% + min-width 100% + border none + box-shadow none + padding 16px + font-size 16px + background transparent + color var(--text) + + > textarea + min-height 100px + +.hpdwcrvs + padding 16px + + > select + display block + padding 4px + font-size 16px + width 100% + +</style> diff --git a/src/client/app/common/views/components/page-editor/page-editor.section.vue b/src/client/app/common/views/components/page-editor/page-editor.section.vue new file mode 100644 index 0000000000..d7a247b0b1 --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.section.vue @@ -0,0 +1,133 @@ +<template> +<x-container @remove="() => $emit('remove')"> + <template #header><fa :icon="faStickyNote"/> {{ value.title }}</template> + <template #func> + <button @click="rename()"> + <fa :icon="faPencilAlt"/> + </button> + <button @click="add()"> + <fa :icon="faPlus"/> + </button> + </template> + + <section class="ilrvjyvi"> + <div class="children"> + <x-block v-for="child in value.children" :value="child" @input="v => updateItem(v)" @remove="() => remove(child)" :key="child.id"/> + </div> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import { faPlus, faPencilAlt } from '@fortawesome/free-solid-svg-icons'; +import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; +import XContainer from './page-editor.container.vue'; +import * as uuid from 'uuid'; + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XContainer + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + faStickyNote, faPlus, faPencilAlt + }; + }, + + beforeCreate() { + this.$options.components.XBlock = require('./page-editor.block.vue').default + }, + + created() { + if (this.value.title == null) Vue.set(this.value, 'title', null); + if (this.value.children == null) Vue.set(this.value, 'children', []); + }, + + mounted() { + if (this.value.title == null) { + this.rename(); + } + }, + + methods: { + async rename() { + const { canceled, result: title } = await this.$root.dialog({ + title: 'Enter title', + input: { + type: 'text', + default: this.value.title + }, + showCancelButton: true + }); + if (canceled) return; + this.value.title = title; + }, + + async add() { + const { canceled, result: type } = await this.$root.dialog({ + type: null, + title: this.$t('choose-block'), + select: { + items: [{ + value: 'section', text: this.$t('blocks.section') + }, { + value: 'text', text: this.$t('blocks.text') + }, { + value: 'image', text: this.$t('blocks.image') + }, { + value: 'button', text: this.$t('blocks.button') + }, { + value: 'input', text: this.$t('blocks.input') + }, { + value: 'switch', text: this.$t('blocks.switch') + }] + }, + showCancelButton: true + }); + if (canceled) return; + + const id = uuid.v4(); + this.value.children.push({ id, type }); + }, + + updateItem(v) { + const i = this.value.children.findIndex(x => x.id === v.id); + const newValue = [ + ...this.value.children.slice(0, i), + v, + ...this.value.children.slice(i + 1) + ]; + this.value.children = newValue; + this.$emit('input', this.value); + }, + + remove(el) { + const i = this.value.children.findIndex(x => x.id === el.id); + const newValue = [ + ...this.value.children.slice(0, i), + ...this.value.children.slice(i + 1) + ]; + this.value.children = newValue; + this.$emit('input', this.value); + } + } +}); +</script> + +<style lang="stylus" scoped> +.ilrvjyvi + > .children + padding 16px + +</style> diff --git a/src/client/app/common/views/components/page-editor/page-editor.switch.vue b/src/client/app/common/views/components/page-editor/page-editor.switch.vue new file mode 100644 index 0000000000..a9cfa2844f --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.switch.vue @@ -0,0 +1,48 @@ +<template> +<x-container @remove="() => $emit('remove')"> + <template #header><fa :icon="faBolt"/> {{ $t('blocks.switch') }}</template> + + <section class="kjuadyyj"> + <ui-input v-model="value.name"><template #prefix><fa :icon="faSquareRootAlt"/></template><span>{{ $t('blocks._switch.name') }}</span></ui-input> + <ui-input v-model="value.text"><span>{{ $t('blocks._switch.text') }}</span></ui-input> + <ui-switch v-model="value.default"><span>{{ $t('blocks._switch.default') }}</span></ui-switch> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import { faBolt, faSquareRootAlt } from '@fortawesome/free-solid-svg-icons'; +import XContainer from './page-editor.container.vue'; + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XContainer + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + faBolt, faSquareRootAlt + }; + }, + + created() { + if (this.value.name == null) Vue.set(this.value, 'name', ''); + }, +}); +</script> + +<style lang="stylus" scoped> +.kjuadyyj + padding 0 16px 16px 16px + +</style> diff --git a/src/client/app/common/views/components/page-editor/page-editor.text.vue b/src/client/app/common/views/components/page-editor/page-editor.text.vue new file mode 100644 index 0000000000..7368931b2f --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.text.vue @@ -0,0 +1,57 @@ +<template> +<x-container @remove="() => $emit('remove')"> + <template #header><fa :icon="faAlignLeft"/> {{ $t('blocks.text') }}</template> + + <section class="ihymsbbe"> + <textarea v-model="value.text"></textarea> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import { faAlignLeft } from '@fortawesome/free-solid-svg-icons'; +import XContainer from './page-editor.container.vue'; + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XContainer + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + faAlignLeft, + }; + }, + + created() { + if (this.value.text == null) Vue.set(this.value, 'text', ''); + }, +}); +</script> + +<style lang="stylus" scoped> +.ihymsbbe + > textarea + display block + -webkit-appearance none + -moz-appearance none + appearance none + width 100% + min-width 100% + min-height 150px + border none + box-shadow none + padding 16px + background transparent + color var(--text) +</style> diff --git a/src/client/app/common/views/components/page-editor/page-editor.vue b/src/client/app/common/views/components/page-editor/page-editor.vue new file mode 100644 index 0000000000..1bcaaa0330 --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.vue @@ -0,0 +1,452 @@ +<template> +<div> + <div class="gwbmwxkm" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"> + <header> + <div class="title"><fa :icon="faStickyNote"/> {{ pageId ? $t('edit-page') : $t('new-page') }}</div> + <div class="buttons"> + <button @click="del()"><fa :icon="faTrashAlt"/></button> + <button @click="() => showOptions = !showOptions"><fa :icon="faCog"/></button> + <button @click="save()"><fa :icon="faSave"/></button> + </div> + </header> + + <section> + <ui-input v-model="title"> + <span>{{ $t('title') }}</span> + </ui-input> + + <template v-if="showOptions"> + <ui-input v-model="summary"> + <span>{{ $t('summary') }}</span> + </ui-input> + + <ui-input v-model="name"> + <template #prefix>{{ url }}/@{{ $store.state.i.username }}/pages/</template> + <span>{{ $t('url') }}</span> + </ui-input> + + <ui-switch v-model="alignCenter">{{ $t('align-center') }}</ui-switch> + + <ui-select v-model="font"> + <template #label>{{ $t('font') }}</template> + <option value="serif">{{ $t('fontSerif') }}</option> + <option value="sans-serif">{{ $t('fontSansSerif') }}</option> + </ui-select> + + <div class="eyeCatch"> + <ui-button v-if="eyeCatchingImageId == null" @click="setEyeCatchingImage()"><fa :icon="faPlus"/> {{ $t('set-eye-catchig-image') }}</ui-button> + <div v-else-if="eyeCatchingImage"> + <img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name"/> + <ui-button @click="removeEyeCatchingImage()"><fa :icon="faTrashAlt"/> {{ $t('remove-eye-catchig-image') }}</ui-button> + </div> + </div> + </template> + + <div class="content" v-for="child in content"> + <x-block :value="child" @input="v => updateItem(v)" @remove="() => remove(child)" :key="child.id"/> + </div> + + <ui-button @click="add()"><fa :icon="faPlus"/></ui-button> + </section> + </div> + + <ui-container :body-togglable="true"> + <template #header><fa :icon="faSquareRootAlt"/> {{ $t('variables') }}</template> + <div class="qmuvgica"> + <div class="variables" v-show="variables.length > 0"> + <template v-for="variable in variables"> + <x-variable + :value="variable" + :removable="true" + @input="v => updateVariable(v)" + @remove="() => removeVariable(variable)" + :key="variable.name" + :ai-script="aiScript" + :name="variable.name" + :title="variable.name" + /> + </template> + </div> + + <ui-button @click="addVariable()" class="add"><fa :icon="faPlus"/></ui-button> + + <ui-info><span v-html="$t('variables-info')"></span><a @click="() => moreDetails = true" style="display:block;">{{ $t('more-details') }}</a></ui-info> + + <template v-if="moreDetails"> + <ui-info><span v-html="$t('variables-info2')"></span></ui-info> + <ui-info><span v-html="$t('variables-info3')"></span></ui-info> + </template> + </div> + </ui-container> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import { faICursor, faPlus, faSquareRootAlt, faCog } from '@fortawesome/free-solid-svg-icons'; +import { faSave, faStickyNote, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import XVariable from './page-editor.script-block.vue'; +import XBlock from './page-editor.block.vue'; +import * as uuid from 'uuid'; +import { AiScript } from '../../../scripts/aiscript'; +import { url } from '../../../../config'; +import { collectPageVars } from '../../../scripts/collect-page-vars'; + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XVariable, XBlock + }, + + props: { + page: { + type: String, + required: false + } + }, + + data() { + return { + pageId: null, + title: '', + summary: null, + name: Date.now().toString(), + eyeCatchingImage: null, + eyeCatchingImageId: null, + font: 'sans-serif', + content: [], + alignCenter: false, + variables: [], + aiScript: null, + showOptions: false, + moreDetails: false, + url, + faPlus, faICursor, faSave, faStickyNote, faSquareRootAlt, faCog, faTrashAlt + }; + }, + + watch: { + async eyeCatchingImageId() { + if (this.eyeCatchingImageId == null) { + this.eyeCatchingImage = null; + } else { + this.eyeCatchingImage = await this.$root.api('drive/files/show', { + fileId: this.eyeCatchingImageId, + }); + } + }, + }, + + created() { + this.aiScript = new AiScript(); + + this.$watch('variables', () => { + this.aiScript.injectVars(this.variables); + }, { deep: true }); + + this.$watch('content', () => { + this.aiScript.injectPageVars(collectPageVars(this.content)); + }, { deep: true }); + + if (this.page) { + this.$root.api('pages/show', { + pageId: this.page, + }).then(page => { + this.pageId = page.id; + this.title = page.title; + this.name = page.name; + this.summary = page.summary; + this.font = page.font; + this.alignCenter = page.alignCenter; + this.content = page.content; + this.variables = page.variables; + this.eyeCatchingImageId = page.eyeCatchingImageId; + }); + } else { + const id = uuid.v4(); + this.content = [{ + id, + type: 'text', + text: 'Hello World!' + }]; + } + }, + + provide() { + return { + getScriptBlockList: this.getScriptBlockList + } + }, + + methods: { + save() { + if (this.pageId) { + this.$root.api('pages/update', { + pageId: this.pageId, + title: this.title.trim(), + name: this.name.trim(), + summary: this.summary, + font: this.font, + alignCenter: this.alignCenter, + content: this.content, + variables: this.variables, + eyeCatchingImageId: this.eyeCatchingImageId, + }).then(page => { + this.$root.dialog({ + type: 'success', + text: this.$t('page-updated') + }); + }); + } else { + this.$root.api('pages/create', { + title: this.title.trim(), + name: this.name.trim(), + summary: this.summary, + font: this.font, + alignCenter: this.alignCenter, + content: this.content, + variables: this.variables, + eyeCatchingImageId: this.eyeCatchingImageId, + }).then(page => { + this.pageId = page.id; + this.$root.dialog({ + type: 'success', + text: this.$t('page-created') + }); + this.$router.push(`/i/pages/edit/${this.pageId}`); + }); + } + }, + + del() { + this.$root.dialog({ + type: 'warning', + text: this.$t('are-you-sure-delete'), + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + this.$root.api('pages/delete', { + pageId: this.pageId, + }).then(() => { + this.$root.dialog({ + type: 'success', + text: this.$t('page-deleted') + }); + this.$router.push(`/i/pages`); + }); + }); + }, + + async add() { + const { canceled, result: type } = await this.$root.dialog({ + type: null, + title: this.$t('choose-block'), + select: { + items: [{ + value: 'section', text: this.$t('blocks.section') + }, { + value: 'text', text: this.$t('blocks.text') + }, { + value: 'image', text: this.$t('blocks.image') + }, { + value: 'button', text: this.$t('blocks.button') + }, { + value: 'input', text: this.$t('blocks.input') + }, { + value: 'switch', text: this.$t('blocks.switch') + }] + }, + showCancelButton: true + }); + if (canceled) return; + + const id = uuid.v4(); + this.content.push({ id, type }); + }, + + async addVariable() { + let { canceled, result: name } = await this.$root.dialog({ + title: this.$t('enter-variable-name'), + input: { + type: 'text', + }, + showCancelButton: true + }); + if (canceled) return; + + name = name.trim(); + + if (this.aiScript.isUsedName(name)) { + this.$root.dialog({ + type: 'error', + text: this.$t('the-variable-name-is-already-used') + }); + return; + } + + const id = uuid.v4(); + this.variables.push({ id, name, type: null }); + }, + + updateItem(v) { + const i = this.content.findIndex(x => x.id === v.id); + const newValue = [ + ...this.content.slice(0, i), + v, + ...this.content.slice(i + 1) + ]; + this.content = newValue; + }, + + remove(el) { + const i = this.content.findIndex(x => x.id === el.id); + const newValue = [ + ...this.content.slice(0, i), + ...this.content.slice(i + 1) + ]; + this.content = newValue; + }, + + removeVariable(v) { + const i = this.variables.findIndex(x => x.name === v.name); + const newValue = [ + ...this.variables.slice(0, i), + ...this.variables.slice(i + 1) + ]; + this.variables = newValue; + }, + + getScriptBlockList(type: string = null) { + const list = []; + + const blocks = AiScript.blockDefs.filter(block => type === null || block.out === null || block.out === type); + + for (const block of blocks) { + const category = list.find(x => x.category === block.category); + if (category) { + category.items.push({ + value: block.type, + text: this.$t(`script.blocks.${block.type}`) + }); + } else { + list.push({ + category: block.category, + label: this.$t(`script.categories.${block.category}`), + items: [{ + value: block.type, + text: this.$t(`script.blocks.${block.type}`) + }] + }); + } + } + + const userFns = this.variables.filter(x => x.type === 'fn'); + if (userFns.length > 0) { + list.unshift({ + label: this.$t(`script.categories.fn`), + items: userFns.map(v => ({ + value: 'fn:' + v.name, + text: v.name + })) + }); + } + + return list; + }, + + setEyeCatchingImage() { + this.$chooseDriveFile({ + multiple: false + }).then(file => { + this.eyeCatchingImageId = file.id; + }); + }, + + removeEyeCatchingImage() { + this.eyeCatchingImageId = null; + } + } +}); +</script> + +<style lang="stylus" scoped> +.gwbmwxkm + overflow hidden + background var(--face) + margin-bottom 16px + + &.round + border-radius 6px + + &.shadow + box-shadow 0 3px 8px rgba(0, 0, 0, 0.2) + + > header + background var(--faceHeader) + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color var(--faceHeaderText) + box-shadow 0 var(--lineWidth) rgba(#000, 0.07) + + > [data-icon] + margin-right 6px + + &:empty + display none + + > .buttons + position absolute + z-index 2 + top 0 + right 0 + + > button + padding 0 + width 42px + font-size 0.9em + line-height 42px + color var(--faceTextButton) + + &:hover + color var(--faceTextButtonHover) + + &:active + color var(--faceTextButtonActive) + + > section + padding 0 32px 32px 32px + + @media (max-width 500px) + padding 0 16px 16px 16px + + > .content + margin-bottom 16px + + > .eyeCatch + margin-bottom 16px + + > div + > img + max-width 100% + +.qmuvgica + padding 32px + + @media (max-width 500px) + padding 16px + + > .variables + margin-bottom 16px + + > .add + margin-bottom 16px + +</style> diff --git a/src/client/app/common/views/components/page-preview.vue b/src/client/app/common/views/components/page-preview.vue new file mode 100644 index 0000000000..d8fdbf4b04 --- /dev/null +++ b/src/client/app/common/views/components/page-preview.vue @@ -0,0 +1,141 @@ +<template> +<router-link :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj" tabindex="-1" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"> + <div class="thumbnail" v-if="page.eyeCatchingImage" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div> + <article> + <header> + <h1 :title="page.title">{{ page.title }}</h1> + </header> + <p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p> + <footer> + <img class="icon" :src="page.user.avatarUrl"/> + <p>{{ page.user | userName }}</p> + </footer> + </article> +</router-link> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + page: { + type: Object, + required: true + }, + }, +}); +</script> + +<style lang="stylus" scoped> +.vhpxefrj + display block + overflow hidden + width 100% + background var(--face) + + &.round + border-radius 8px + + &.shadow + box-shadow 0 4px 16px rgba(#000, 0.1) + + @media (min-width 500px) + box-shadow 0 8px 32px rgba(#000, 0.1) + + > .thumbnail + position absolute + width 100px + height 100% + background-position center + background-size cover + display flex + justify-content center + align-items center + + > button + font-size 3.5em + opacity: 0.7 + + &:hover + font-size 4em + opacity 0.9 + + & + article + left 100px + width calc(100% - 100px) + + > article + padding 16px + + > header + margin-bottom 8px + + > h1 + margin 0 + font-size 1em + color var(--urlPreviewTitle) + + > p + margin 0 + color var(--urlPreviewText) + font-size 0.8em + + > footer + margin-top 8px + height 16px + + > img + display inline-block + width 16px + height 16px + margin-right 4px + vertical-align top + + > p + display inline-block + margin 0 + color var(--urlPreviewInfo) + font-size 0.8em + line-height 16px + vertical-align top + + @media (max-width 700px) + > .thumbnail + position relative + width 100% + height 100px + + & + article + left 0 + width 100% + + @media (max-width 550px) + font-size 12px + + > .thumbnail + height 80px + + > article + padding 12px + + @media (max-width 500px) + font-size 10px + + > .thumbnail + height 70px + + > article + padding 8px + + > header + margin-bottom 4px + + > footer + margin-top 4px + + > img + width 12px + height 12px + +</style> diff --git a/src/client/app/common/views/pages/page/page.block.vue b/src/client/app/common/views/pages/page/page.block.vue new file mode 100644 index 0000000000..48a89f9de7 --- /dev/null +++ b/src/client/app/common/views/pages/page/page.block.vue @@ -0,0 +1,34 @@ +<template> +<component :is="'x-' + value.type" :value="value" :page="page" :script="script" :key="value.id" :h="h"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XText from './page.text.vue'; +import XSection from './page.section.vue'; +import XImage from './page.image.vue'; +import XButton from './page.button.vue'; +import XInput from './page.input.vue'; +import XSwitch from './page.switch.vue'; + +export default Vue.extend({ + components: { + XText, XSection, XImage, XButton, XInput, XSwitch + }, + + props: { + value: { + required: true + }, + script: { + required: true + }, + page: { + required: true + }, + h: { + required: true + } + }, +}); +</script> diff --git a/src/client/app/common/views/pages/page/page.button.vue b/src/client/app/common/views/pages/page/page.button.vue new file mode 100644 index 0000000000..5063d27122 --- /dev/null +++ b/src/client/app/common/views/pages/page/page.button.vue @@ -0,0 +1,42 @@ +<template> +<div> + <ui-button class="kudkigyw" @click="click()">{{ value.text }}</ui-button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: true + }, + script: { + required: true + } + }, + + methods: { + click() { + if (this.value.action === 'dialog') { + this.script.reEval(); + this.$root.dialog({ + text: this.script.interpolate(this.value.content) + }); + } else if (this.value.action === 'resetRandom') { + this.script.aiScript.updateRandomSeed(Math.random()); + this.script.reEval(); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.kudkigyw + display inline-block + min-width 300px + max-width 450px + margin 8px 0 +</style> diff --git a/src/client/app/common/views/pages/page/page.image.vue b/src/client/app/common/views/pages/page/page.image.vue new file mode 100644 index 0000000000..1285445eb0 --- /dev/null +++ b/src/client/app/common/views/pages/page/page.image.vue @@ -0,0 +1,36 @@ +<template> +<div class="lzyxtsnt"> + <img v-if="image" :src="image.url"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: true + }, + page: { + required: true + }, + }, + + data() { + return { + image: null, + }; + }, + + created() { + this.image = this.page.attachedFiles.find(x => x.id === this.value.fileId); + } +}); +</script> + +<style lang="stylus" scoped> +.lzyxtsnt + > img + max-width 100% +</style> diff --git a/src/client/app/common/views/pages/page/page.input.vue b/src/client/app/common/views/pages/page/page.input.vue new file mode 100644 index 0000000000..cda5550337 --- /dev/null +++ b/src/client/app/common/views/pages/page/page.input.vue @@ -0,0 +1,43 @@ +<template> +<div> + <ui-input class="kudkigyw" v-model="v" :type="value.inputType">{{ value.text }}</ui-input> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: true + }, + script: { + required: true + } + }, + + data() { + return { + v: this.value.default, + }; + }, + + watch: { + v() { + let v = this.v; + if (this.value.inputType === 'number') v = parseInt(v, 10); + this.script.aiScript.updatePageVar(this.value.name, v); + this.script.reEval(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.kudkigyw + display inline-block + min-width 300px + max-width 450px + margin 8px 0 +</style> diff --git a/src/client/app/common/views/pages/page/page.section.vue b/src/client/app/common/views/pages/page/page.section.vue new file mode 100644 index 0000000000..03c009d9c3 --- /dev/null +++ b/src/client/app/common/views/pages/page/page.section.vue @@ -0,0 +1,55 @@ +<template> +<section class="sdgxphyu"> + <component :is="'h' + h">{{ value.title }}</component> + + <div class="children"> + <x-block v-for="child in value.children" :value="child" :page="page" :script="script" :key="child.id" :h="h + 1"/> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: true + }, + script: { + required: true + }, + page: { + required: true + }, + h: { + required: true + } + }, + + beforeCreate() { + this.$options.components.XBlock = require('./page.block.vue').default + }, +}); +</script> + +<style lang="stylus" scoped> +.sdgxphyu + margin 1.5em 0 + + > h2 + font-size 1.35em + margin 0 0 0.5em 0 + + > h3 + font-size 1em + margin 0 0 0.5em 0 + + > h4 + font-size 1em + margin 0 0 0.5em 0 + + > .children + //padding 16px + +</style> diff --git a/src/client/app/common/views/pages/page/page.switch.vue b/src/client/app/common/views/pages/page/page.switch.vue new file mode 100644 index 0000000000..715a2fee6e --- /dev/null +++ b/src/client/app/common/views/pages/page/page.switch.vue @@ -0,0 +1,33 @@ +<template> +<div> + <ui-switch v-model="v">{{ value.text }}</ui-switch> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: true + }, + script: { + required: true + } + }, + + data() { + return { + v: this.value.default, + }; + }, + + watch: { + v() { + this.script.aiScript.updatePageVar(this.value.name, this.v); + this.script.reEval(); + } + } +}); +</script> diff --git a/src/client/app/common/views/pages/page/page.text.vue b/src/client/app/common/views/pages/page/page.text.vue new file mode 100644 index 0000000000..eadc6f0aed --- /dev/null +++ b/src/client/app/common/views/pages/page/page.text.vue @@ -0,0 +1,35 @@ +<template> +<div class=""> + <mfm :text="text" :is-note="false" :i="$store.state.i" :key="text"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: true + }, + script: { + required: true + } + }, + + data() { + return { + text: this.script.interpolate(this.value.text), + }; + }, + + created() { + this.$watch('script.vars', () => { + this.text = this.script.interpolate(this.value.text); + }, { deep: true }); + } +}); +</script> + +<style lang="stylus" scoped> +</style> diff --git a/src/client/app/common/views/pages/page/page.vue b/src/client/app/common/views/pages/page/page.vue new file mode 100644 index 0000000000..5ca58a6a4e --- /dev/null +++ b/src/client/app/common/views/pages/page/page.vue @@ -0,0 +1,143 @@ +<template> +<div v-if="page" class="iroscrza" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners, center: page.alignCenter }" :style="{ fontFamily: page.font }"> + <header> + <div class="title">{{ page.title }}</div> + </header> + + <div v-if="script"> + <x-block v-for="child in page.content" :value="child" @input="v => updateBlock(v)" :page="page" :script="script" :key="child.id" :h="2"/> + </div> + + <footer> + <small>@{{ page.user.username }}</small> + <router-link v-if="$store.getters.isSignedIn && $store.state.i.id === page.userId" :to="`/i/pages/edit/${page.id}`">{{ $t('edit-this-page') }}</router-link> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import { faICursor, faPlus, faSquareRootAlt } from '@fortawesome/free-solid-svg-icons'; +import { faSave, faStickyNote } from '@fortawesome/free-regular-svg-icons'; +import XBlock from './page.block.vue'; +import { AiScript } from '../../../scripts/aiscript'; +import { collectPageVars } from '../../../scripts/collect-page-vars'; + +class Script { + public aiScript: AiScript; + public vars: any; + + constructor(aiScript) { + this.aiScript = aiScript; + this.vars = this.aiScript.evaluateVars(); + } + + public reEval() { + this.vars = this.aiScript.evaluateVars(); + } + + public interpolate(str: string) { + return str.replace(/\{(.+?)\}/g, match => + (this.vars.find(x => x.name === match.slice(1, -1).trim()).value || '').toString()); + } +} + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XBlock + }, + + props: { + pageName: { + type: String, + required: true + }, + username: { + type: String, + required: true + }, + }, + + data() { + return { + page: null, + script: null, + faPlus, faICursor, faSave, faStickyNote, faSquareRootAlt + }; + }, + + created() { + this.$root.api('pages/show', { + name: this.pageName, + username: this.username, + }).then(page => { + this.page = page; + const pageVars = this.getPageVars(); + this.script = new Script(new AiScript(this.page.variables, pageVars, { + randomSeed: Math.random(), + user: page.user, + visitor: this.$store.state.i + })); + }); + }, + + methods: { + getPageVars() { + return collectPageVars(this.page.content); + }, + } +}); +</script> + +<style lang="stylus" scoped> +.iroscrza + overflow hidden + background var(--face) + + &.center + text-align center + + &.round + border-radius 6px + + &.shadow + box-shadow 0 3px 8px rgba(0, 0, 0, 0.2) + + > header + > .title + z-index 1 + margin 0 + padding 32px 64px + font-size 24px + font-weight bold + color var(--text) + box-shadow 0 var(--lineWidth) rgba(#000, 0.07) + + @media (max-width 600px) + padding 16px 32px + font-size 20px + + > div + color var(--text) + padding 48px 64px + font-size 18px + + @media (max-width 600px) + padding 24px 32px + font-size 16px + + > footer + color var(--text) + padding 0 64px 38px 64px + + @media (max-width 600px) + padding 0 32px 28px 32px + + > small + display block + opacity 0.5 + +</style> diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts index 8d292ce324..00ba5db23a 100644 --- a/src/client/app/desktop/script.ts +++ b/src/client/app/desktop/script.ts @@ -156,7 +156,11 @@ init(async (launch, os) => { { path: '/explore', name: 'explore', component: () => import('../common/views/pages/explore.vue').then(m => m.default) }, { path: '/explore/tags/:tag', name: 'explore-tag', props: true, component: () => import('../common/views/pages/explore.vue').then(m => m.default) }, { path: '/i/favorites', component: () => import('./views/home/favorites.vue').then(m => m.default) }, + { path: '/i/pages', component: () => import('./views/home/pages.vue').then(m => m.default) }, ]}, + { path: '/@:user/pages/:page', props: true, component: () => import('./views/pages/page.vue').then(m => m.default) }, + { path: '/i/pages/new', component: () => import('./views/pages/page-editor.vue').then(m => m.default) }, + { path: '/i/pages/edit/:page', props: true, component: () => import('./views/pages/page-editor.vue').then(m => m.default) }, { path: '/i/messaging/:user', component: MkMessagingRoom }, { path: '/i/drive', component: MkDrive }, { path: '/i/drive/folder/:folder', component: MkDrive }, diff --git a/src/client/app/desktop/views/components/ui.header.account.vue b/src/client/app/desktop/views/components/ui.header.account.vue index 7f9decfdcd..05692667b7 100644 --- a/src/client/app/desktop/views/components/ui.header.account.vue +++ b/src/client/app/desktop/views/components/ui.header.account.vue @@ -9,35 +9,42 @@ <ul> <li> <router-link :to="`/@${ $store.state.i.username }`"> - <i><fa icon="user"/></i> + <i><fa icon="user" fixed-width/></i> <span>{{ $t('profile') }}</span> <i><fa icon="angle-right"/></i> </router-link> </li> <li @click="drive"> <p> - <i><fa icon="cloud"/></i> + <i><fa icon="cloud" fixed-width/></i> <span>{{ $t('@.drive') }}</span> <i><fa icon="angle-right"/></i> </p> </li> <li> <router-link to="/i/favorites"> - <i><fa icon="star"/></i> + <i><fa icon="star" fixed-width/></i> <span>{{ $t('@.favorites') }}</span> <i><fa icon="angle-right"/></i> </router-link> </li> <li @click="list"> <p> - <i><fa icon="list"/></i> + <i><fa icon="list" fixed-width/></i> <span>{{ $t('lists') }}</span> <i><fa icon="angle-right"/></i> </p> </li> + <li @click="page"> + <router-link to="/i/pages"> + <i><fa :icon="faStickyNote" fixed-width/></i> + <span>{{ $t('@.pages') }}</span> + <i><fa icon="angle-right"/></i> + </router-link> + </li> <li @click="followRequests" v-if="($store.state.i.isLocked || $store.state.i.carefulBot)"> <p> - <i><fa :icon="['far', 'envelope']"/></i> + <i><fa :icon="['far', 'envelope']" fixed-width/></i> <span>{{ $t('follow-requests') }}<i v-if="$store.state.i.pendingReceivedFollowRequestsCount">{{ $store.state.i.pendingReceivedFollowRequestsCount }}</i></span> <i><fa icon="angle-right"/></i> </p> @@ -46,14 +53,14 @@ <ul> <li> <router-link to="/i/settings"> - <i><fa icon="cog"/></i> + <i><fa icon="cog" fixed-width/></i> <span>{{ $t('@.settings') }}</span> <i><fa icon="angle-right"/></i> </router-link> </li> <li v-if="$store.state.i.isAdmin || $store.state.i.isModerator"> <a href="/admin"> - <i><fa icon="terminal"/></i> + <i><fa icon="terminal" fixed-width/></i> <span>{{ $t('admin') }}</span> <i><fa icon="angle-right"/></i> </a> @@ -76,7 +83,7 @@ <ul> <li @click="signout"> <p class="signout"> - <i><fa icon="power-off"/></i> + <i><fa icon="power-off" fixed-width/></i> <span>{{ $t('@.signout') }}</span> </p> </li> @@ -95,14 +102,14 @@ import MkFollowRequestsWindow from './received-follow-requests-window.vue'; import MkDriveWindow from './drive-window.vue'; import contains from '../../../common/scripts/contains'; import { faHome, faColumns } from '@fortawesome/free-solid-svg-icons'; -import { faMoon, faSun } from '@fortawesome/free-regular-svg-icons'; +import { faMoon, faSun, faStickyNote } from '@fortawesome/free-regular-svg-icons'; export default Vue.extend({ i18n: i18n('desktop/views/components/ui.header.account.vue'), data() { return { isOpen: false, - faHome, faColumns, faMoon, faSun + faHome, faColumns, faMoon, faSun, faStickyNote }; }, computed: { diff --git a/src/client/app/desktop/views/home/pages.vue b/src/client/app/desktop/views/home/pages.vue new file mode 100644 index 0000000000..9f7fb65159 --- /dev/null +++ b/src/client/app/desktop/views/home/pages.vue @@ -0,0 +1,92 @@ +<template> +<div class="rknalgpo" v-if="!fetching"> + <ui-button @click="create()"><fa :icon="faPlus"/></ui-button> + <sequential-entrance animation="entranceFromTop" delay="25"> + <template v-for="page in pages"> + <x-page-preview class="page" :page="page" :key="page.id"/> + </template> + </sequential-entrance> + <ui-button v-if="existMore" @click="fetchMore()">{{ $t('@.load-more') }}</ui-button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import Progress from '../../../common/scripts/loading'; +import { faPlus } from '@fortawesome/free-solid-svg-icons'; +import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; +import XPagePreview from '../../../common/views/components/page-preview.vue'; + +export default Vue.extend({ + i18n: i18n(), + components: { + XPagePreview + }, + data() { + return { + fetching: true, + pages: [], + existMore: false, + moreFetching: false, + faStickyNote, faPlus + }; + }, + created() { + this.fetch(); + }, + methods: { + fetch() { + Progress.start(); + this.fetching = true; + + this.$root.api('i/pages', { + limit: 11 + }).then(pages => { + if (pages.length == 11) { + this.existMore = true; + pages.pop(); + } + + this.pages = pages; + this.fetching = false; + + Progress.done(); + }); + }, + fetchMore() { + this.moreFetching = true; + this.$root.api('i/pages', { + limit: 11, + untilId: this.pages[this.pages.length - 1].id + }).then(pages => { + if (pages.length == 11) { + this.existMore = true; + pages.pop(); + } else { + this.existMore = false; + } + + this.pages = this.pages.concat(pages); + this.moreFetching = false; + }); + }, + create() { + this.$router.push(`/i/pages/new`); + } + } +}); +</script> + +<style lang="stylus" scoped> +.rknalgpo + margin 0 auto + + > * > .page + margin-bottom 8px + + @media (min-width 500px) + > * > .page + margin-bottom 16px + +</style> diff --git a/src/client/app/desktop/views/pages/page-editor.vue b/src/client/app/desktop/views/pages/page-editor.vue new file mode 100644 index 0000000000..50d1e7db61 --- /dev/null +++ b/src/client/app/desktop/views/pages/page-editor.vue @@ -0,0 +1,32 @@ +<template> +<mk-ui> + <main> + <x-page-editor :page="page"/> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + components: { + XPageEditor: () => import('../../../common/views/components/page-editor/page-editor.vue').then(m => m.default) + }, + + props: { + page: { + type: String, + required: false + } + } +}); +</script> + +<style lang="stylus" scoped> +main + margin 0 auto + padding 16px + max-width 900px + +</style> diff --git a/src/client/app/desktop/views/pages/page.vue b/src/client/app/desktop/views/pages/page.vue new file mode 100644 index 0000000000..1ddff08c76 --- /dev/null +++ b/src/client/app/desktop/views/pages/page.vue @@ -0,0 +1,36 @@ +<template> +<mk-ui> + <main> + <x-page :page-name="page" :username="user"/> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + components: { + XPage: () => import('../../../common/views/pages/page/page.vue').then(m => m.default) + }, + + props: { + page: { + type: String, + required: true + }, + user: { + type: String, + required: true + }, + } +}); +</script> + +<style lang="stylus" scoped> +main + margin 0 auto + padding 16px + max-width 950px + +</style> diff --git a/src/client/app/mobile/script.ts b/src/client/app/mobile/script.ts index 510141f94b..136bbc31c4 100644 --- a/src/client/app/mobile/script.ts +++ b/src/client/app/mobile/script.ts @@ -135,6 +135,7 @@ init((launch, os) => { { path: '/signup', name: 'signup', component: MkSignup }, { path: '/i/settings', name: 'settings', component: () => import('./views/pages/settings.vue').then(m => m.default) }, { path: '/i/favorites', name: 'favorites', component: MkFavorites }, + { path: '/i/pages', name: 'pages', component: () => import('./views/pages/pages.vue').then(m => m.default) }, { path: '/i/lists', name: 'user-lists', component: MkUserLists }, { path: '/i/lists/:list', name: 'user-list', component: MkUserList }, { path: '/i/received-follow-requests', name: 'received-follow-requests', component: MkReceivedFollowRequests }, @@ -144,6 +145,8 @@ init((launch, os) => { { path: '/i/drive', name: 'drive', component: MkDrive }, { path: '/i/drive/folder/:folder', component: MkDrive }, { path: '/i/drive/file/:file', component: MkDrive }, + { path: '/i/pages/new', component: () => import('./views/pages/page-editor.vue').then(m => m.default) }, + { path: '/i/pages/edit/:page', props: true, component: () => import('./views/pages/page-editor.vue').then(m => m.default) }, { path: '/selectdrive', component: MkSelectDrive }, { path: '/search', component: MkSearch }, { path: '/tags/:tag', component: MkTag }, @@ -156,6 +159,7 @@ init((launch, os) => { { path: 'following', component: () => import('../common/views/pages/following.vue').then(m => m.default) }, { path: 'followers', component: () => import('../common/views/pages/followers.vue').then(m => m.default) }, ]}, + { path: '/@:user/pages/:page', props: true, component: () => import('./views/pages/page.vue').then(m => m.default) }, { path: '/notes/:note', component: MkNote }, { path: '/authorize-follow', component: MkFollow }, { path: '*', component: MkNotFound } diff --git a/src/client/app/mobile/views/components/ui.nav.vue b/src/client/app/mobile/views/components/ui.nav.vue index 9a3ade4c63..da9bb518ef 100644 --- a/src/client/app/mobile/views/components/ui.nav.vue +++ b/src/client/app/mobile/views/components/ui.nav.vue @@ -29,6 +29,7 @@ <li><router-link to="/i/favorites" :data-active="$route.name == 'favorites'"><i><fa icon="star" fixed-width/></i>{{ $t('@.favorites') }}<i><fa icon="angle-right"/></i></router-link></li> <li><router-link to="/i/lists" :data-active="$route.name == 'user-lists'"><i><fa icon="list" fixed-width/></i>{{ $t('user-lists') }}<i><fa icon="angle-right"/></i></router-link></li> <li><router-link to="/i/drive" :data-active="$route.name == 'drive'"><i><fa icon="cloud" fixed-width/></i>{{ $t('@.drive') }}<i><fa icon="angle-right"/></i></router-link></li> + <li><router-link to="/i/pages" :data-active="$route.name == 'pages'"><i><fa :icon="faStickyNote" fixed-width/></i>{{ $t('@.pages') }}<i><fa icon="angle-right"/></i></router-link></li> </ul> <ul> <li><a @click="search"><i><fa icon="search" fixed-width/></i>{{ $t('search') }}<i><fa icon="angle-right"/></i></a></li> @@ -66,7 +67,7 @@ import Vue from 'vue'; import i18n from '../../../i18n'; import { lang } from '../../../config'; import { faNewspaper, faHashtag, faHome, faColumns } from '@fortawesome/free-solid-svg-icons'; -import { faMoon, faSun } from '@fortawesome/free-regular-svg-icons'; +import { faMoon, faSun, faStickyNote } from '@fortawesome/free-regular-svg-icons'; import { search } from '../../../common/scripts/search'; export default Vue.extend({ @@ -86,7 +87,7 @@ export default Vue.extend({ announcements: [], searching: false, showNotifications: false, - faNewspaper, faHashtag, faMoon, faSun, faHome, faColumns + faNewspaper, faHashtag, faMoon, faSun, faHome, faColumns, faStickyNote }; }, diff --git a/src/client/app/mobile/views/pages/page-editor.vue b/src/client/app/mobile/views/pages/page-editor.vue new file mode 100644 index 0000000000..9d549c784f --- /dev/null +++ b/src/client/app/mobile/views/pages/page-editor.vue @@ -0,0 +1,32 @@ +<template> +<mk-ui> + <main> + <x-page-editor :page="page"/> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + components: { + XPageEditor: () => import('../../../common/views/components/page-editor/page-editor.vue').then(m => m.default) + }, + + props: { + page: { + type: String, + required: false + } + } +}); +</script> + +<style lang="stylus" scoped> +main + margin 0 auto + padding 16px + max-width 1000px + +</style> diff --git a/src/client/app/mobile/views/pages/page.vue b/src/client/app/mobile/views/pages/page.vue new file mode 100644 index 0000000000..27ade4a398 --- /dev/null +++ b/src/client/app/mobile/views/pages/page.vue @@ -0,0 +1,36 @@ +<template> +<mk-ui> + <main> + <x-page :page-name="page" :username="user"/> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + components: { + XPage: () => import('../../../common/views/pages/page/page.vue').then(m => m.default) + }, + + props: { + page: { + type: String, + required: true + }, + user: { + type: String, + required: true + }, + } +}); +</script> + +<style lang="stylus" scoped> +main + margin 0 auto + padding 16px + max-width 1000px + +</style> diff --git a/src/client/app/mobile/views/pages/pages.vue b/src/client/app/mobile/views/pages/pages.vue new file mode 100644 index 0000000000..100c814ad9 --- /dev/null +++ b/src/client/app/mobile/views/pages/pages.vue @@ -0,0 +1,94 @@ +<template> +<mk-ui> + <template #header><span style="margin-right:4px;"><fa :icon="faStickyNote"/></span>{{ $t('@.pages') }}</template> + + <main> + <ui-button @click="create()"><fa :icon="faPlus"/></ui-button> + <sequential-entrance animation="entranceFromTop" delay="25"> + <template v-for="page in pages"> + <x-page-preview class="page" :page="page" :key="page.id"/> + </template> + </sequential-entrance> + <ui-button v-if="existMore" @click="fetchMore()">{{ $t('@.load-more') }}</ui-button> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import Progress from '../../../common/scripts/loading'; +import { faPlus } from '@fortawesome/free-solid-svg-icons'; +import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; +import XPagePreview from '../../../common/views/components/page-preview.vue'; + +export default Vue.extend({ + i18n: i18n(), + components: { + XPagePreview + }, + data() { + return { + fetching: true, + pages: [], + existMore: false, + moreFetching: false, + faStickyNote, faPlus + }; + }, + created() { + this.fetch(); + }, + methods: { + fetch() { + Progress.start(); + this.fetching = true; + + this.$root.api('i/pages', { + limit: 11 + }).then(pages => { + if (pages.length == 11) { + this.existMore = true; + pages.pop(); + } + + this.pages = pages; + this.fetching = false; + + Progress.done(); + }); + }, + fetchMore() { + this.moreFetching = true; + this.$root.api('i/pages', { + limit: 11, + untilId: this.pages[this.pages.length - 1].id + }).then(pages => { + if (pages.length == 11) { + this.existMore = true; + pages.pop(); + } else { + this.existMore = false; + } + + this.pages = this.pages.concat(pages); + this.moreFetching = false; + }); + }, + create() { + this.$router.push(`/i/pages/new`); + } + } +}); +</script> + +<style lang="stylus" scoped> +main + > * > .page + margin-bottom 8px + + @media (min-width 500px) + > * > .page + margin-bottom 16px + +</style> diff --git a/src/client/themes/dark.json5 b/src/client/themes/dark.json5 index 5f44f8570e..8e0c726b4c 100644 --- a/src/client/themes/dark.json5 +++ b/src/client/themes/dark.json5 @@ -232,5 +232,8 @@ adminDashboardCardBg: '$secondary', adminDashboardCardFg: '$text', adminDashboardCardDivider: 'rgba(0, 0, 0, 0.3)', + + pageBlockBorder: 'rgba(255, 255, 255, 0.1)', + pageBlockBorderHover: 'rgba(255, 255, 255, 0.15)', }, } diff --git a/src/client/themes/light.json5 b/src/client/themes/light.json5 index d5680f8f82..1fff18176a 100644 --- a/src/client/themes/light.json5 +++ b/src/client/themes/light.json5 @@ -232,5 +232,8 @@ adminDashboardCardBg: '$secondary', adminDashboardCardFg: '$text', adminDashboardCardDivider: 'rgba(0, 0, 0, 0.082)', + + pageBlockBorder: 'rgba(0, 0, 0, 0.1)', + pageBlockBorderHover: 'rgba(0, 0, 0, 0.15)', }, } diff --git a/src/db/postgre.ts b/src/db/postgre.ts index 71836638f1..18283836aa 100644 --- a/src/db/postgre.ts +++ b/src/db/postgre.ts @@ -40,6 +40,7 @@ import { Poll } from '../models/entities/poll'; import { UserKeypair } from '../models/entities/user-keypair'; import { UserPublickey } from '../models/entities/user-publickey'; import { UserProfile } from '../models/entities/user-profile'; +import { Page } from '../models/entities/page'; const sqlLogger = dbLogger.createSubLogger('sql', 'white', false); @@ -114,6 +115,7 @@ export function initDb(justBorrow = false, sync = false, log = false) { NoteReaction, NoteWatching, NoteUnread, + Page, Log, DriveFile, DriveFolder, diff --git a/src/models/entities/page.ts b/src/models/entities/page.ts new file mode 100644 index 0000000000..f57ca8c7c3 --- /dev/null +++ b/src/models/entities/page.ts @@ -0,0 +1,105 @@ +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; +import { DriveFile } from './drive-file'; + +@Entity() +@Index(['userId', 'name'], { unique: true }) +export class Page { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Page.' + }) + public createdAt: Date; + + @Index() + @Column('timestamp with time zone', { + comment: 'The updated date of the Page.' + }) + public updatedAt: Date; + + @Column('varchar', { + length: 256, + }) + public title: string; + + @Index() + @Column('varchar', { + length: 256, + }) + public name: string; + + @Column('varchar', { + length: 256, nullable: true + }) + public summary: string | null; + + @Column('boolean') + public alignCenter: boolean; + + @Column('varchar', { + length: 32, + }) + public font: string; + + @Index() + @Column({ + ...id(), + comment: 'The ID of author.' + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column({ + ...id(), + nullable: true, + }) + public eyeCatchingImageId: DriveFile['id'] | null; + + @ManyToOne(type => DriveFile, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public eyeCatchingImage: DriveFile | null; + + @Column('jsonb', { + default: [] + }) + public content: Record<string, any>[]; + + @Column('jsonb', { + default: [] + }) + public variables: Record<string, any>[]; + + /** + * public ... 公開 + * followers ... フォãƒãƒ¯ãƒ¼ã®ã¿ + * specified ... visibleUserIds ã§æŒ‡å®šã—ãŸãƒ¦ãƒ¼ã‚¶ãƒ¼ã®ã¿ + */ + @Column('enum', { enum: ['public', 'followers', 'specified'] }) + public visibility: 'public' | 'followers' | 'specified'; + + @Index() + @Column({ + ...id(), + array: true, default: '{}' + }) + public visibleUserIds: User['id'][]; + + constructor(data: Partial<Page>) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/src/models/index.ts b/src/models/index.ts index 826044e7a5..e402d6723d 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -35,6 +35,7 @@ import { AbuseUserReportRepository } from './repositories/abuse-user-report'; import { AuthSessionRepository } from './repositories/auth-session'; import { UserProfile } from './entities/user-profile'; import { HashtagRepository } from './repositories/hashtag'; +import { PageRepository } from './repositories/page'; export const Apps = getCustomRepository(AppRepository); export const Notes = getCustomRepository(NoteRepository); @@ -72,3 +73,4 @@ export const MessagingMessages = getCustomRepository(MessagingMessageRepository) export const ReversiGames = getCustomRepository(ReversiGameRepository); export const ReversiMatchings = getCustomRepository(ReversiMatchingRepository); export const Logs = getRepository(Log); +export const Pages = getCustomRepository(PageRepository); diff --git a/src/models/repositories/page.ts b/src/models/repositories/page.ts new file mode 100644 index 0000000000..4c1b4cc793 --- /dev/null +++ b/src/models/repositories/page.ts @@ -0,0 +1,61 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Page } from '../entities/page'; +import { SchemaType, types, bool } from '../../misc/schema'; +import { Users, DriveFiles } from '..'; +import { awaitAll } from '../../prelude/await-all'; +import { DriveFile } from '../entities/drive-file'; + +export type PackedPage = SchemaType<typeof packedPageSchema>; + +@EntityRepository(Page) +export class PageRepository extends Repository<Page> { + public async pack( + src: Page, + ): Promise<PackedPage> { + const attachedFiles: Promise<DriveFile | undefined>[] = []; + const collectFile = (xs: any[]) => { + for (const x of xs) { + if (x.type === 'image') { + attachedFiles.push(DriveFiles.findOne({ + id: x.fileId, + userId: src.userId + })); + } + if (x.children) { + collectFile(x.children); + } + } + }; + collectFile(src.content); + return await awaitAll({ + id: src.id, + createdAt: src.createdAt.toISOString(), + updatedAt: src.updatedAt.toISOString(), + userId: src.userId, + user: Users.pack(src.user || src.userId), + content: src.content, + variables: src.variables, + title: src.title, + name: src.name, + summary: src.summary, + alignCenter: src.alignCenter, + font: src.font, + eyeCatchingImageId: src.eyeCatchingImageId, + eyeCatchingImage: src.eyeCatchingImageId ? await DriveFiles.pack(src.eyeCatchingImageId) : null, + attachedFiles: DriveFiles.packMany(await Promise.all(attachedFiles)) + }); + } + + public packMany( + pages: Page[], + ) { + return Promise.all(pages.map(x => this.pack(x))); + } +} + +export const packedPageSchema = { + type: types.object, + optional: bool.false, nullable: bool.false, + properties: { + } +}; diff --git a/src/server/api/endpoints/i/pages.ts b/src/server/api/endpoints/i/pages.ts new file mode 100644 index 0000000000..5eb4db81b7 --- /dev/null +++ b/src/server/api/endpoints/i/pages.ts @@ -0,0 +1,44 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { Pages } from '../../../../models'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + desc: { + 'ja-JP': '自分ã®ä½œæˆã—ãŸãƒšãƒ¼ã‚¸ä¸€è¦§ã‚’å–å¾—ã—ã¾ã™ã€‚', + 'en-US': 'Get my pages.' + }, + + tags: ['account', 'pages'], + + requireCredential: true, + + kind: 'read:pages', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(Pages.createQueryBuilder('page'), ps.sinceId, ps.untilId) + .andWhere(`page.userId = :meId`, { meId: user.id }); + + const pages = await query + .take(ps.limit!) + .getMany(); + + return await Pages.packMany(pages); +}); diff --git a/src/server/api/endpoints/pages/create.ts b/src/server/api/endpoints/pages/create.ts new file mode 100644 index 0000000000..e6b813648b --- /dev/null +++ b/src/server/api/endpoints/pages/create.ts @@ -0,0 +1,108 @@ +import $ from 'cafy'; +import * as ms from 'ms'; +import define from '../../define'; +import { ID } from '../../../../misc/cafy-id'; +import { types, bool } from '../../../../misc/schema'; +import { Pages, DriveFiles } from '../../../../models'; +import { genId } from '../../../../misc/gen-id'; +import { Page } from '../../../../models/entities/page'; +import { ApiError } from '../../error'; + +export const meta = { + desc: { + 'ja-JP': 'ページを作æˆã—ã¾ã™ã€‚', + }, + + tags: ['pages'], + + requireCredential: true, + + kind: 'write:pages', + + limit: { + duration: ms('1hour'), + max: 300 + }, + + params: { + title: { + validator: $.str, + }, + + name: { + validator: $.str, + }, + + summary: { + validator: $.optional.nullable.str, + }, + + content: { + validator: $.arr($.obj()) + }, + + variables: { + validator: $.arr($.obj()) + }, + + eyeCatchingImageId: { + validator: $.optional.nullable.type(ID), + }, + + font: { + validator: $.optional.str.or(['serif', 'sans-serif']), + default: 'sans-serif' + }, + + alignCenter: { + validator: $.optional.bool, + default: false + }, + }, + + res: { + type: types.object, + optional: bool.false, nullable: bool.false, + ref: 'Page', + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'b7b97489-0f66-4b12-a5ff-b21bd63f6e1c' + }, + } +}; + +export default define(meta, async (ps, user) => { + let eyeCatchingImage = null; + if (ps.eyeCatchingImageId != null) { + eyeCatchingImage = await DriveFiles.findOne({ + id: ps.eyeCatchingImageId, + userId: user.id + }); + + if (eyeCatchingImage == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + const page = await Pages.save(new Page({ + id: genId(), + createdAt: new Date(), + updatedAt: new Date(), + title: ps.title, + name: ps.name, + summary: ps.summary, + content: ps.content, + variables: ps.variables, + eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null, + userId: user.id, + visibility: 'public', + alignCenter: ps.alignCenter, + font: ps.font + })); + + return await Pages.pack(page); +}); diff --git a/src/server/api/endpoints/pages/delete.ts b/src/server/api/endpoints/pages/delete.ts new file mode 100644 index 0000000000..043805aa33 --- /dev/null +++ b/src/server/api/endpoints/pages/delete.ts @@ -0,0 +1,53 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages } from '../../../../models'; +import { ID } from '../../../../misc/cafy-id'; + +export const meta = { + desc: { + 'ja-JP': '指定ã—ãŸãƒšãƒ¼ã‚¸ã‚’削除ã—ã¾ã™ã€‚', + }, + + tags: ['pages'], + + requireCredential: true, + + kind: 'write:pages', + + params: { + pageId: { + validator: $.type(ID), + desc: { + 'ja-JP': '対象ã®ãƒšãƒ¼ã‚¸ã®ID', + 'en-US': 'Target page ID.' + } + }, + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: 'eb0c6e1d-d519-4764-9486-52a7e1c6392a' + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '8b741b3e-2c22-44b3-a15f-29949aa1601e' + }, + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + if (page.userId !== user.id) { + throw new ApiError(meta.errors.accessDenied); + } + + await Pages.delete(page.id); +}); diff --git a/src/server/api/endpoints/pages/show.ts b/src/server/api/endpoints/pages/show.ts new file mode 100644 index 0000000000..dd1dc9f255 --- /dev/null +++ b/src/server/api/endpoints/pages/show.ts @@ -0,0 +1,74 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages, Users } from '../../../../models'; +import { types, bool } from '../../../../misc/schema'; +import { ID } from '../../../../misc/cafy-id'; +import { Page } from '../../../../models/entities/page'; + +export const meta = { + desc: { + 'ja-JP': '指定ã—ãŸãƒšãƒ¼ã‚¸ã®æƒ…å ±ã‚’å–å¾—ã—ã¾ã™ã€‚', + }, + + tags: ['pages'], + + requireCredential: false, + + params: { + pageId: { + validator: $.optional.type(ID), + desc: { + 'ja-JP': '対象ã®ãƒšãƒ¼ã‚¸ã®ID', + 'en-US': 'Target page ID.' + } + }, + + name: { + validator: $.optional.str, + }, + + username: { + validator: $.optional.str, + }, + }, + + res: { + type: types.object, + optional: bool.false, nullable: bool.false, + ref: 'Page', + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: '222120c0-3ead-4528-811b-b96f233388d7' + } + } +}; + +export default define(meta, async (ps, user) => { + let page: Page | undefined; + + if (ps.pageId) { + page = await Pages.findOne(ps.pageId); + } else if (ps.name && ps.username) { + const author = await Users.findOne({ + host: null, + usernameLower: ps.username.toLowerCase() + }); + if (author) { + page = await Pages.findOne({ + name: ps.name, + userId: author.id + }); + } + } + + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + return await Pages.pack(page); +}); diff --git a/src/server/api/endpoints/pages/update.ts b/src/server/api/endpoints/pages/update.ts new file mode 100644 index 0000000000..8ee34fc3ba --- /dev/null +++ b/src/server/api/endpoints/pages/update.ts @@ -0,0 +1,123 @@ +import $ from 'cafy'; +import * as ms from 'ms'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages, DriveFiles } from '../../../../models'; +import { ID } from '../../../../misc/cafy-id'; + +export const meta = { + desc: { + 'ja-JP': '指定ã—ãŸãƒšãƒ¼ã‚¸ã®æƒ…å ±ã‚’æ›´æ–°ã—ã¾ã™ã€‚', + }, + + tags: ['pages'], + + requireCredential: true, + + kind: 'write:pages', + + limit: { + duration: ms('1hour'), + max: 300 + }, + + params: { + pageId: { + validator: $.type(ID), + desc: { + 'ja-JP': '対象ã®ãƒšãƒ¼ã‚¸ã®ID', + 'en-US': 'Target page ID.' + } + }, + + title: { + validator: $.str, + }, + + name: { + validator: $.optional.str, + }, + + summary: { + validator: $.optional.nullable.str, + }, + + content: { + validator: $.arr($.obj()) + }, + + variables: { + validator: $.arr($.obj()) + }, + + eyeCatchingImageId: { + validator: $.optional.nullable.type(ID), + }, + + font: { + validator: $.optional.str.or(['serif', 'sans-serif']), + }, + + alignCenter: { + validator: $.optional.bool, + }, + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: '21149b9e-3616-4778-9592-c4ce89f5a864' + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '3c15cd52-3b4b-4274-967d-6456fc4f792b' + }, + + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'cfc23c7c-3887-490e-af30-0ed576703c82' + }, + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + if (page.userId !== user.id) { + throw new ApiError(meta.errors.accessDenied); + } + + let eyeCatchingImage = null; + if (ps.eyeCatchingImageId != null) { + eyeCatchingImage = await DriveFiles.findOne({ + id: ps.eyeCatchingImageId, + userId: user.id + }); + + if (eyeCatchingImage == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + await Pages.update(page.id, { + updatedAt: new Date(), + title: ps.title, + name: ps.name === undefined ? page.name : ps.name, + summary: ps.name === undefined ? page.summary : ps.summary, + content: ps.content, + variables: ps.variables, + alignCenter: ps.alignCenter === undefined ? page.alignCenter : ps.alignCenter, + font: ps.font === undefined ? page.font : ps.font, + eyeCatchingImageId: ps.eyeCatchingImageId === null + ? null + : ps.eyeCatchingImageId === undefined + ? page.eyeCatchingImageId + : eyeCatchingImage!.id, + }); +}); diff --git a/src/server/web/index.ts b/src/server/web/index.ts index 1f87cd70f8..c5a3497f44 100644 --- a/src/server/web/index.ts +++ b/src/server/web/index.ts @@ -16,7 +16,7 @@ import { fetchMeta } from '../../misc/fetch-meta'; import * as pkg from '../../../package.json'; import { genOpenapiSpec } from '../api/openapi/gen-spec'; import config from '../../config'; -import { Users, Notes, Emojis, UserProfiles } from '../../models'; +import { Users, Notes, Emojis, UserProfiles, Pages } from '../../models'; import parseAcct from '../../misc/acct/parse'; import getNoteSummary from '../../misc/get-note-summary'; import { ensure } from '../../prelude/ensure'; @@ -203,6 +203,41 @@ router.get('/notes/:note', async ctx => { ctx.status = 404; }); + +// Page +router.get('/@:user/pages/:page', async ctx => { + const { username, host } = parseAcct(ctx.params.user); + const user = await Users.findOne({ + usernameLower: username.toLowerCase(), + host + }); + + if (user == null) return; + + const page = await Pages.findOne({ + name: ctx.params.page, + userId: user.id + }); + + if (page) { + const _page = await Pages.pack(page); + const meta = await fetchMeta(); + await ctx.render('page', { + page: _page, + instanceName: meta.name || 'Misskey' + }); + + if (['public'].includes(page.visibility)) { + ctx.set('Cache-Control', 'public, max-age=180'); + } else { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } + + return; + } + + ctx.status = 404; +}); //#endregion router.get('/info', async ctx => { diff --git a/src/server/web/views/note.pug b/src/server/web/views/note.pug index dd6dda2582..983c731a04 100644 --- a/src/server/web/views/note.pug +++ b/src/server/web/views/note.pug @@ -25,6 +25,7 @@ block meta meta(name='twitter:card' content='summary') + // todo if user.twitter meta(name='twitter:creator' content=`@${user.twitter.screenName}`) diff --git a/src/server/web/views/page.pug b/src/server/web/views/page.pug new file mode 100644 index 0000000000..55f64ff054 --- /dev/null +++ b/src/server/web/views/page.pug @@ -0,0 +1,30 @@ +extends ./base + +block vars + - const user = page.user; + - const title = page.title; + - const url = `${config.url}/@${user.username}/${page.name}`; + +block title + = `${title} | ${instanceName}` + +block desc + meta(name='description' content= page.summary) + +block og + meta(property='og:type' content='article') + meta(property='og:title' content= title) + meta(property='og:description' content= page.summary) + meta(property='og:url' content= url) + meta(property='og:image' content= page.eyeCatchingImage ? page.eyeCatchingImage.thumbnailUrl : user.avatarUrl) + +block meta + meta(name='misskey:user-username' content=user.username) + meta(name='misskey:user-id' content=user.id) + meta(name='misskey:page-id' content=page.id) + + meta(name='twitter:card' content='summary') + + // todo + if user.twitter + meta(name='twitter:creator' content=`@${user.twitter.screenName}`) -- GitLab