* 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
This commit is contained in:
syuilo 2019-04-29 09:11:57 +09:00 committed by GitHub
parent 82ec1c5bcd
commit 94d23dfa19
52 changed files with 3583 additions and 37 deletions

1
.gitignore vendored
View File

@ -20,3 +20,4 @@ api-docs.json
yarn.lock
.DS_Store
/files
ormconfig.json

View File

@ -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

View File

@ -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: "ページ要素"

View File

@ -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"`);
}
}

View File

@ -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",

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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

View File

@ -36,7 +36,7 @@ export default Vue.extend({
return {
hide: true
};
}
},
computed: {
style(): any {
let url = `url(${

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 },

View File

@ -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: {

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 }

View File

@ -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
};
},

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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)',
},
}

View File

@ -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)',
},
}

View File

@ -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,

105
src/models/entities/page.ts Normal file
View File

@ -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;
}
}
}

View File

@ -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);

View File

@ -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: {
}
};

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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,
});
});

View File

@ -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 => {

View File

@ -25,6 +25,7 @@ block meta
meta(name='twitter:card' content='summary')
// todo
if user.twitter
meta(name='twitter:creator' content=`@${user.twitter.screenName}`)

View File

@ -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}`)