summaryrefslogtreecommitdiff
path: root/src/client/app
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2019-04-29 09:11:57 +0900
committerGitHub <noreply@github.com>2019-04-29 09:11:57 +0900
commit05b8111c1906c1285c9ddde758eda45b83792244 (patch)
treeda5d58c4ae18436f739eaee9e1801c6c48056be5 /src/client/app
parentUpdate define.ts (diff)
downloadsharkey-05b8111c1906c1285c9ddde758eda45b83792244.tar.gz
sharkey-05b8111c1906c1285c9ddde758eda45b83792244.tar.bz2
sharkey-05b8111c1906c1285c9ddde758eda45b83792244.zip
Pages (#4811)
* wip * wip * wip * Update page-editor.vue * wip * wip * wip * wip * wip * wip * wip * Update page-editor.variable.core.vue * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * Update aiscript.ts * wip * Update package.json * wip * wip * wip * wip * wip * Update page.vue * wip * wip * wip * wip * more info * wip fn * wip * wip * wip
Diffstat (limited to 'src/client/app')
-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
33 files changed, 2715 insertions, 15 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>