summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/client/app/common/scripts/aiscript.ts470
-rw-r--r--src/client/app/common/scripts/collect-page-vars.ts24
-rw-r--r--src/client/app/common/views/components/dialog.vue11
-rw-r--r--src/client/app/common/views/components/media-image.vue2
-rw-r--r--src/client/app/common/views/components/page-editor/page-editor.block.vue25
-rw-r--r--src/client/app/common/views/components/page-editor/page-editor.button.vue54
-rw-r--r--src/client/app/common/views/components/page-editor/page-editor.container.vue135
-rw-r--r--src/client/app/common/views/components/page-editor/page-editor.image.vue78
-rw-r--r--src/client/app/common/views/components/page-editor/page-editor.input.vue54
-rw-r--r--src/client/app/common/views/components/page-editor/page-editor.script-block.vue263
-rw-r--r--src/client/app/common/views/components/page-editor/page-editor.section.vue133
-rw-r--r--src/client/app/common/views/components/page-editor/page-editor.switch.vue48
-rw-r--r--src/client/app/common/views/components/page-editor/page-editor.text.vue57
-rw-r--r--src/client/app/common/views/components/page-editor/page-editor.vue452
-rw-r--r--src/client/app/common/views/components/page-preview.vue141
-rw-r--r--src/client/app/common/views/pages/page/page.block.vue34
-rw-r--r--src/client/app/common/views/pages/page/page.button.vue42
-rw-r--r--src/client/app/common/views/pages/page/page.image.vue36
-rw-r--r--src/client/app/common/views/pages/page/page.input.vue43
-rw-r--r--src/client/app/common/views/pages/page/page.section.vue55
-rw-r--r--src/client/app/common/views/pages/page/page.switch.vue33
-rw-r--r--src/client/app/common/views/pages/page/page.text.vue35
-rw-r--r--src/client/app/common/views/pages/page/page.vue143
-rw-r--r--src/client/app/desktop/script.ts4
-rw-r--r--src/client/app/desktop/views/components/ui.header.account.vue27
-rw-r--r--src/client/app/desktop/views/home/pages.vue92
-rw-r--r--src/client/app/desktop/views/pages/page-editor.vue32
-rw-r--r--src/client/app/desktop/views/pages/page.vue36
-rw-r--r--src/client/app/mobile/script.ts4
-rw-r--r--src/client/app/mobile/views/components/ui.nav.vue5
-rw-r--r--src/client/app/mobile/views/pages/page-editor.vue32
-rw-r--r--src/client/app/mobile/views/pages/page.vue36
-rw-r--r--src/client/app/mobile/views/pages/pages.vue94
-rw-r--r--src/client/themes/dark.json53
-rw-r--r--src/client/themes/light.json53
-rw-r--r--src/db/postgre.ts2
-rw-r--r--src/models/entities/page.ts105
-rw-r--r--src/models/index.ts2
-rw-r--r--src/models/repositories/page.ts61
-rw-r--r--src/server/api/endpoints/i/pages.ts44
-rw-r--r--src/server/api/endpoints/pages/create.ts108
-rw-r--r--src/server/api/endpoints/pages/delete.ts53
-rw-r--r--src/server/api/endpoints/pages/show.ts74
-rw-r--r--src/server/api/endpoints/pages/update.ts123
-rw-r--r--src/server/web/index.ts37
-rw-r--r--src/server/web/views/note.pug1
-rw-r--r--src/server/web/views/page.pug30
47 files changed, 3360 insertions, 16 deletions
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}`)