summaryrefslogtreecommitdiff
path: root/src/client/app/common/views/components
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/common/views/components
parentUpdate define.ts (diff)
downloadmisskey-05b8111c1906c1285c9ddde758eda45b83792244.tar.gz
misskey-05b8111c1906c1285c9ddde758eda45b83792244.tar.bz2
misskey-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/common/views/components')
-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
13 files changed, 1450 insertions, 3 deletions
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>