diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2019-04-29 09:11:57 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2019-04-29 09:11:57 +0900 |
| commit | 05b8111c1906c1285c9ddde758eda45b83792244 (patch) | |
| tree | da5d58c4ae18436f739eaee9e1801c6c48056be5 /src | |
| parent | Update define.ts (diff) | |
| download | sharkey-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')
47 files changed, 3360 insertions, 16 deletions
diff --git a/src/client/app/common/scripts/aiscript.ts b/src/client/app/common/scripts/aiscript.ts new file mode 100644 index 0000000000..4ef21f9943 --- /dev/null +++ b/src/client/app/common/scripts/aiscript.ts @@ -0,0 +1,470 @@ +/** + * AiScript + * evaluator & type checker + */ + +import autobind from 'autobind-decorator'; +import * as seedrandom from 'seedrandom'; + +import { + faSuperscript, + faAlignLeft, + faShareAlt, + faSquareRootAlt, + faPlus, + faMinus, + faTimes, + faDivide, + faList, + faQuoteRight, + faEquals, + faGreaterThan, + faLessThan, + faGreaterThanEqual, + faLessThanEqual, + faExclamation, + faNotEqual, + faDice, + faSortNumericUp, +} from '@fortawesome/free-solid-svg-icons'; +import { faFlag } from '@fortawesome/free-regular-svg-icons'; + +import { version } from '../../config'; + +export type Block = { + id: string; + type: string; + args: Block[]; + value: any; +}; + +export type Variable = Block & { + name: string; +}; + +type Type = 'string' | 'number' | 'boolean' | 'stringArray'; + +type TypeError = { + arg: number; + expect: Type; + actual: Type; +}; + +const funcDefs = { + if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: faShareAlt, }, + not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: faFlag, }, + or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: faFlag, }, + and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: faFlag, }, + add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faPlus, }, + subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faMinus, }, + multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faTimes, }, + divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faDivide, }, + eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: faEquals, }, + notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: faNotEqual, }, + gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faGreaterThan, }, + lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faLessThan, }, + gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faGreaterThanEqual, }, + ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faLessThanEqual, }, + rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: faDice, }, + random: { in: ['number'], out: 'boolean', category: 'random', icon: faDice, }, + randomPick: { in: [0], out: 0, category: 'random', icon: faDice, }, + dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: faDice, }, + dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: faDice, }, + dailyRandomPick: { in: [0], out: 0, category: 'random', icon: faDice, }, +}; + +const blockDefs = [ + { type: 'text', out: 'string', category: 'value', icon: faQuoteRight, }, + { type: 'multiLineText', out: 'string', category: 'value', icon: faAlignLeft, }, + { type: 'textList', out: 'stringArray', category: 'value', icon: faList, }, + { type: 'number', out: 'number', category: 'value', icon: faSortNumericUp, }, + { type: 'ref', out: null, category: 'value', icon: faSuperscript, }, + { type: 'in', out: null, category: 'value', icon: faSuperscript, }, + { type: 'fn', out: 'function', category: 'value', icon: faSuperscript, }, + ...Object.entries(funcDefs).map(([k, v]) => ({ + type: k, out: v.out || null, category: v.category, icon: v.icon + })) +]; + +type PageVar = { name: string; value: any; type: Type; }; + +const envVarsDef = { + AI: 'string', + VERSION: 'string', + LOGIN: 'boolean', + NAME: 'string', + USERNAME: 'string', + USERID: 'string', + NOTES_COUNT: 'number', + FOLLOWERS_COUNT: 'number', + FOLLOWING_COUNT: 'number', + IS_CAT: 'boolean', + MY_NOTES_COUNT: 'number', + MY_FOLLOWERS_COUNT: 'number', + MY_FOLLOWING_COUNT: 'number', +}; + +export class AiScript { + private variables: Variable[]; + private pageVars: PageVar[]; + private envVars: Record<keyof typeof envVarsDef, any>; + + public static envVarsDef = envVarsDef; + public static blockDefs = blockDefs; + public static funcDefs = funcDefs; + private opts: { + randomSeed?: string; user?: any; visitor?: any; + }; + + constructor(variables: Variable[] = [], pageVars: PageVar[] = [], opts: AiScript['opts'] = {}) { + this.variables = variables; + this.pageVars = pageVars; + this.opts = opts; + + this.envVars = { + AI: 'kawaii', + VERSION: version, + LOGIN: opts.visitor != null, + NAME: opts.visitor ? opts.visitor.name : '', + USERNAME: opts.visitor ? opts.visitor.username : '', + USERID: opts.visitor ? opts.visitor.id : '', + NOTES_COUNT: opts.visitor ? opts.visitor.notesCount : 0, + FOLLOWERS_COUNT: opts.visitor ? opts.visitor.followersCount : 0, + FOLLOWING_COUNT: opts.visitor ? opts.visitor.followingCount : 0, + IS_CAT: opts.visitor ? opts.visitor.isCat : false, + MY_NOTES_COUNT: opts.user ? opts.user.notesCount : 0, + MY_FOLLOWERS_COUNT: opts.user ? opts.user.followersCount : 0, + MY_FOLLOWING_COUNT: opts.user ? opts.user.followingCount : 0, + }; + } + + @autobind + public injectVars(vars: Variable[]) { + this.variables = vars; + } + + @autobind + public injectPageVars(pageVars: PageVar[]) { + this.pageVars = pageVars; + } + + @autobind + public updatePageVar(name: string, value: any) { + this.pageVars.find(v => v.name === name).value = value; + } + + @autobind + public updateRandomSeed(seed: string) { + this.opts.randomSeed = seed; + } + + @autobind + public static isLiteralBlock(v: Block) { + if (v.type === null) return true; + if (v.type === 'text') return true; + if (v.type === 'multiLineText') return true; + if (v.type === 'textList') return true; + if (v.type === 'number') return true; + if (v.type === 'ref') return true; + if (v.type === 'fn') return true; + if (v.type === 'in') return true; + return false; + } + + @autobind + public typeCheck(v: Block): TypeError | null { + if (AiScript.isLiteralBlock(v)) return null; + + const def = AiScript.funcDefs[v.type]; + if (def == null) { + throw new Error('Unknown type: ' + v.type); + } + + const generic: Type[] = []; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + const type = this.typeInference(v.args[i]); + if (type === null) continue; + + if (typeof arg === 'number') { + if (generic[arg] === undefined) { + generic[arg] = type; + } else if (type !== generic[arg]) { + return { + arg: i, + expect: generic[arg], + actual: type + }; + } + } else if (type !== arg) { + return { + arg: i, + expect: arg, + actual: type + }; + } + } + + return null; + } + + @autobind + public getExpectedType(v: Block, slot: number): Type | null { + const def = AiScript.funcDefs[v.type]; + if (def == null) { + throw new Error('Unknown type: ' + v.type); + } + + const generic: Type[] = []; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + const type = this.typeInference(v.args[i]); + if (type === null) continue; + + if (typeof arg === 'number') { + if (generic[arg] === undefined) { + generic[arg] = type; + } + } + } + + if (typeof def.in[slot] === 'number') { + return generic[def.in[slot]] || null; + } else { + return def.in[slot]; + } + } + + @autobind + public typeInference(v: Block): Type | null { + if (v.type === null) return null; + if (v.type === 'text') return 'string'; + if (v.type === 'multiLineText') return 'string'; + if (v.type === 'textList') return 'stringArray'; + if (v.type === 'number') return 'number'; + if (v.type === 'ref') { + const variable = this.variables.find(va => va.name === v.value); + if (variable) { + return this.typeInference(variable); + } + + const pageVar = this.pageVars.find(va => va.name === v.value); + if (pageVar) { + return pageVar.type; + } + + const envVar = AiScript.envVarsDef[v.value]; + if (envVar) { + return envVar; + } + + return null; + } + if (v.type === 'fn') return null; // todo + if (v.type === 'in') return null; // todo + + const generic: Type[] = []; + + const def = AiScript.funcDefs[v.type]; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + if (typeof arg === 'number') { + const type = this.typeInference(v.args[i]); + + if (generic[arg] === undefined) { + generic[arg] = type; + } else { + if (type !== generic[arg]) { + generic[arg] = null; + } + } + } + } + + if (typeof def.out === 'number') { + return generic[def.out]; + } else { + return def.out; + } + } + + @autobind + public getVarsByType(type: Type | null): Variable[] { + if (type == null) return this.variables; + return this.variables.filter(x => (this.typeInference(x) === null) || (this.typeInference(x) === type)); + } + + @autobind + public getVarByName(name: string): Variable { + return this.variables.find(x => x.name === name); + } + + @autobind + public getEnvVarsByType(type: Type | null): string[] { + if (type == null) return Object.keys(AiScript.envVarsDef); + return Object.entries(AiScript.envVarsDef).filter(([k, v]) => type === v).map(([k, v]) => k); + } + + @autobind + public getPageVarsByType(type: Type | null): string[] { + if (type == null) return this.pageVars.map(v => v.name); + return this.pageVars.filter(v => type === v.type).map(v => v.name); + } + + @autobind + private interpolate(str: string, values: { name: string, value: any }[]) { + return str.replace(/\{(.+?)\}/g, match => + (this.getVariableValue(match.slice(1, -1).trim(), values) || '').toString()); + } + + @autobind + public evaluateVars() { + const values: { name: string, value: any }[] = []; + + for (const v of this.variables) { + values.push({ + name: v.name, + value: this.evaluate(v, values) + }); + } + + for (const v of this.pageVars) { + values.push({ + name: v.name, + value: v.value + }); + } + + for (const [k, v] of Object.entries(this.envVars)) { + values.push({ + name: k, + value: v + }); + } + + return values; + } + + @autobind + private evaluate(block: Block, values: { name: string, value: any }[], slotArg: Record<string, any> = {}): any { + if (block.type === null) { + return null; + } + + if (block.type === 'number') { + return parseInt(block.value, 10); + } + + if (block.type === 'text' || block.type === 'multiLineText') { + return this.interpolate(block.value, values); + } + + if (block.type === 'textList') { + return block.value.trim().split('\n'); + } + + if (block.type === 'ref') { + return this.getVariableValue(block.value, values); + } + + if (block.type === 'in') { + return slotArg[block.value]; + } + + if (block.type === 'fn') { // ユーザー関数定義 + return { + slots: block.value.slots, + exec: slotArg => this.evaluate(block.value.expression, values, slotArg) + }; + } + + if (block.type.startsWith('fn:')) { // ユーザー関数呼び出し + const fnName = block.type.split(':')[1]; + const fn = this.getVariableValue(fnName, values); + for (let i = 0; i < fn.slots.length; i++) { + const name = fn.slots[i]; + slotArg[name] = this.evaluate(block.args[i], values); + } + return fn.exec(slotArg); + } + + if (block.args === undefined) return null; + + const date = new Date(); + const day = `${this.opts.visitor ? this.opts.visitor.id : ''} ${date.getFullYear()}/${date.getMonth()}/${date.getDate()}`; + + const funcs: { [p in keyof typeof funcDefs]: any } = { + not: (a) => !a, + eq: (a, b) => a === b, + notEq: (a, b) => a !== b, + gt: (a, b) => a > b, + lt: (a, b) => a < b, + gtEq: (a, b) => a >= b, + ltEq: (a, b) => a <= b, + or: (a, b) => a || b, + and: (a, b) => a && b, + if: (bool, a, b) => bool ? a : b, + add: (a, b) => a + b, + subtract: (a, b) => a - b, + multiply: (a, b) => a * b, + divide: (a, b) => a / b, + random: (probability) => Math.floor(seedrandom(`${this.opts.randomSeed}:${block.id}`)() * 100) < probability, + rannum: (min, max) => min + Math.floor(seedrandom(`${this.opts.randomSeed}:${block.id}`)() * (max - min + 1)), + randomPick: (list) => list[Math.floor(seedrandom(`${this.opts.randomSeed}:${block.id}`)() * list.length)], + dailyRandom: (probability) => Math.floor(seedrandom(`${day}:${block.id}`)() * 100) < probability, + dailyRannum: (min, max) => min + Math.floor(seedrandom(`${day}:${block.id}`)() * (max - min + 1)), + dailyRandomPick: (list) => list[Math.floor(seedrandom(`${day}:${block.id}`)() * list.length)], + }; + + const fnName = block.type; + + const fn = funcs[fnName]; + if (fn == null) { + console.error('Unknown function: ' + fnName); + throw new Error('Unknown function: ' + fnName); + } + + const args = block.args.map(x => this.evaluate(x, values, slotArg)); + + return fn(...args); + } + + @autobind + private getVariableValue(name: string, values: { name: string, value: any }[]): any { + const v = values.find(v => v.name === name); + if (v) { + return v.value; + } + + const pageVar = this.pageVars.find(v => v.name === name); + if (pageVar) { + return pageVar.value; + } + + if (AiScript.envVarsDef[name]) { + return this.envVars[name].value; + } + + throw new Error(`Script: No such variable '${name}'`); + } + + @autobind + public isUsedName(name: string) { + if (this.variables.some(v => v.name === name)) { + return true; + } + + if (this.pageVars.some(v => v.name === name)) { + return true; + } + + if (AiScript.envVarsDef[name]) { + return true; + } + + return false; + } +} diff --git a/src/client/app/common/scripts/collect-page-vars.ts b/src/client/app/common/scripts/collect-page-vars.ts new file mode 100644 index 0000000000..86687e21f4 --- /dev/null +++ b/src/client/app/common/scripts/collect-page-vars.ts @@ -0,0 +1,24 @@ +export function collectPageVars(content) { + const pageVars = []; + const collect = (xs: any[]) => { + for (const x of xs) { + if (x.type === 'input') { + pageVars.push({ + name: x.name, + type: x.inputType, + value: x.default + }); + } else if (x.type === 'switch') { + pageVars.push({ + name: x.name, + type: 'boolean', + value: x.default + }); + } else if (x.children) { + collect(x.children); + } + } + }; + collect(content); + return pageVars; +} diff --git a/src/client/app/common/views/components/dialog.vue b/src/client/app/common/views/components/dialog.vue index c1ee7958c0..020c88f699 100644 --- a/src/client/app/common/views/components/dialog.vue +++ b/src/client/app/common/views/components/dialog.vue @@ -22,7 +22,14 @@ <ui-input v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></ui-input> <ui-input v-if="user" v-model="userInputValue" autofocus @keydown="onInputKeydown"><template #prefix>@</template></ui-input> <ui-select v-if="select" v-model="selectedValue" autofocus> - <option v-for="item in select.items" :value="item.value">{{ item.text }}</option> + <template v-if="select.items"> + <option v-for="item in select.items" :value="item.value">{{ item.text }}</option> + </template> + <template v-else> + <optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label"> + <option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option> + </optgroup> + </template> </ui-select> <ui-horizon-group no-grow class="buttons fit-bottom" v-if="!splash && (showOkButton || showCancelButton)"> <ui-button @click="ok" v-if="showOkButton" primary :autofocus="!input && !select && !user">{{ (showCancelButton || input || select || user) ? $t('@.ok') : $t('@.got-it') }}</ui-button> @@ -230,7 +237,7 @@ export default Vue.extend({ font-size 32px &.success - color #37ec92 + color #85da5a &.error color #ec4137 diff --git a/src/client/app/common/views/components/media-image.vue b/src/client/app/common/views/components/media-image.vue index 2559907512..6db4b40dd8 100644 --- a/src/client/app/common/views/components/media-image.vue +++ b/src/client/app/common/views/components/media-image.vue @@ -36,7 +36,7 @@ export default Vue.extend({ return { hide: true }; - } + }, computed: { style(): any { let url = `url(${ diff --git a/src/client/app/common/views/components/page-editor/page-editor.block.vue b/src/client/app/common/views/components/page-editor/page-editor.block.vue new file mode 100644 index 0000000000..a3e1488d1b --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.block.vue @@ -0,0 +1,25 @@ +<template> +<component :is="'x-' + value.type" :value="value" @input="v => updateItem(v)" @remove="() => $emit('remove', value)" :key="value.id"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XSection from './page-editor.section.vue'; +import XText from './page-editor.text.vue'; +import XImage from './page-editor.image.vue'; +import XButton from './page-editor.button.vue'; +import XInput from './page-editor.input.vue'; +import XSwitch from './page-editor.switch.vue'; + +export default Vue.extend({ + components: { + XSection, XText, XImage, XButton, XInput, XSwitch + }, + + props: { + value: { + required: true + } + }, +}); +</script> diff --git a/src/client/app/common/views/components/page-editor/page-editor.button.vue b/src/client/app/common/views/components/page-editor/page-editor.button.vue new file mode 100644 index 0000000000..d5fc243818 --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.button.vue @@ -0,0 +1,54 @@ +<template> +<x-container @remove="() => $emit('remove')"> + <template #header><fa :icon="faBolt"/> {{ $t('blocks.button') }}</template> + + <section class="xfhsjczc"> + <ui-input v-model="value.text"><span>{{ $t('blocks._button.text') }}</span></ui-input> + <ui-select v-model="value.action"> + <template #label>{{ $t('blocks._button.action') }}</template> + <option value="dialog">{{ $t('blocks._button._action.dialog') }}</option> + <option value="resetRandom">{{ $t('blocks._button._action.resetRandom') }}</option> + </ui-select> + <ui-input v-if="value.action === 'dialog'" v-model="value.content"><span>{{ $t('blocks._button._action._dialog.content') }}</span></ui-input> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import { faBolt } from '@fortawesome/free-solid-svg-icons'; +import XContainer from './page-editor.container.vue'; + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XContainer + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + faBolt + }; + }, + + created() { + if (this.value.text == null) Vue.set(this.value, 'text', ''); + if (this.value.action == null) Vue.set(this.value, 'action', 'dialog'); + if (this.value.content == null) Vue.set(this.value, 'content', null); + }, +}); +</script> + +<style lang="stylus" scoped> +.xfhsjczc + padding 0 16px 0 16px + +</style> diff --git a/src/client/app/common/views/components/page-editor/page-editor.container.vue b/src/client/app/common/views/components/page-editor/page-editor.container.vue new file mode 100644 index 0000000000..698fdfee45 --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.container.vue @@ -0,0 +1,135 @@ +<template> +<div class="cpjygsrt" :class="{ error: error != null, warn: warn != null }"> + <header> + <div class="title"><slot name="header"></slot></div> + <div class="buttons"> + <slot name="func"></slot> + <button v-if="removable" @click="remove()"> + <fa :icon="faTrashAlt"/> + </button> + <button @click="toggleContent(!showBody)"> + <template v-if="showBody"><fa icon="angle-up"/></template> + <template v-else><fa icon="angle-down"/></template> + </button> + </div> + </header> + <p v-show="showBody" class="error" v-if="error != null">{{ $t('script.typeError', { slot: error.arg + 1, expect: $t(`script.types.${error.expect}`), actual: $t(`script.types.${error.actual}`) }) }}</p> + <p v-show="showBody" class="warn" v-if="warn != null">{{ $t('script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p> + <div v-show="showBody"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../../../../i18n'; + +export default Vue.extend({ + i18n: i18n('pages'), + + props: { + expanded: { + type: Boolean, + default: true + }, + removable: { + type: Boolean, + default: true + }, + error: { + required: false, + default: null + }, + warn: { + required: false, + default: null + } + }, + data() { + return { + showBody: this.expanded, + faTrashAlt + }; + }, + methods: { + toggleContent(show: boolean) { + this.showBody = show; + this.$emit('toggle', show); + }, + remove() { + this.$emit('remove'); + } + } +}); +</script> + +<style lang="stylus" scoped> +.cpjygsrt + overflow hidden + background var(--face) + border solid 2px var(--pageBlockBorder) + border-radius 6px + + &:hover + border solid 2px var(--pageBlockBorderHover) + + &.warn + border solid 2px #dec44c + + &.error + border solid 2px #f00 + + & + .cpjygsrt + margin-top 16px + + > header + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color var(--faceHeaderText) + box-shadow 0 1px rgba(#000, 0.07) + + > [data-icon] + margin-right 6px + + &:empty + display none + + > .buttons + position absolute + z-index 2 + top 0 + right 0 + + > button + padding 0 + width 42px + font-size 0.9em + line-height 42px + color var(--faceTextButton) + + &:hover + color var(--faceTextButtonHover) + + &:active + color var(--faceTextButtonActive) + + > .warn + color #b19e49 + margin 0 + padding 16px 16px 0 16px + font-size 14px + + > .error + color #f00 + margin 0 + padding 16px 16px 0 16px + font-size 14px + +</style> diff --git a/src/client/app/common/views/components/page-editor/page-editor.image.vue b/src/client/app/common/views/components/page-editor/page-editor.image.vue new file mode 100644 index 0000000000..0bc1816e8d --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.image.vue @@ -0,0 +1,78 @@ +<template> +<x-container @remove="() => $emit('remove')"> + <template #header><fa :icon="faImage"/> {{ $t('blocks.image') }}</template> + <template #func> + <button @click="choose()"> + <fa :icon="faFolderOpen"/> + </button> + </template> + + <section class="oyyftmcf"> + <x-file-thumbnail class="preview" v-if="file" :file="file" :detail="true" fit="contain" @click="choose()"/> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import { faPencilAlt } from '@fortawesome/free-solid-svg-icons'; +import { faImage, faFolderOpen } from '@fortawesome/free-regular-svg-icons'; +import XContainer from './page-editor.container.vue'; +import XFileThumbnail from '../drive-file-thumbnail.vue'; + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XContainer, XFileThumbnail + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + file: null, + faPencilAlt, faImage, faFolderOpen + }; + }, + + created() { + if (this.value.fileId === undefined) Vue.set(this.value, 'fileId', null); + }, + + mounted() { + if (this.value.fileId == null) { + this.choose(); + } else { + this.$root.api('drive/files/show', { + fileId: this.value.fileId + }).then(file => { + this.file = file; + }); + } + }, + + methods: { + async choose() { + this.$chooseDriveFile({ + multiple: false + }).then(file => { + this.file = file; + this.value.fileId = file.id; + }); + }, + } +}); +</script> + +<style lang="stylus" scoped> +.oyyftmcf + > .preview + height 150px + +</style> diff --git a/src/client/app/common/views/components/page-editor/page-editor.input.vue b/src/client/app/common/views/components/page-editor/page-editor.input.vue new file mode 100644 index 0000000000..1f3754252b --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.input.vue @@ -0,0 +1,54 @@ +<template> +<x-container @remove="() => $emit('remove')"> + <template #header><fa :icon="faBolt"/> {{ $t('blocks.input') }}</template> + + <section class="dnvasjon"> + <ui-input v-model="value.name"><template #prefix><fa :icon="faSquareRootAlt"/></template><span>{{ $t('blocks._input.name') }}</span></ui-input> + <ui-input v-model="value.text"><span>{{ $t('blocks._input.text') }}</span></ui-input> + <ui-select v-model="value.inputType"> + <template #label>{{ $t('blocks._input.inputType') }}</template> + <option value="string">{{ $t('blocks._input._inputType.string') }}</option> + <option value="number">{{ $t('blocks._input._inputType.number') }}</option> + </ui-select> + <ui-input v-model="value.default" :type="value.inputType"><span>{{ $t('blocks._input.default') }}</span></ui-input> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import { faBolt, faSquareRootAlt } from '@fortawesome/free-solid-svg-icons'; +import XContainer from './page-editor.container.vue'; + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XContainer + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + faBolt, faSquareRootAlt + }; + }, + + created() { + if (this.value.name == null) Vue.set(this.value, 'name', ''); + if (this.value.inputType == null) Vue.set(this.value, 'inputType', 'string'); + }, +}); +</script> + +<style lang="stylus" scoped> +.dnvasjon + padding 0 16px 0 16px + +</style> diff --git a/src/client/app/common/views/components/page-editor/page-editor.script-block.vue b/src/client/app/common/views/components/page-editor/page-editor.script-block.vue new file mode 100644 index 0000000000..3122832030 --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.script-block.vue @@ -0,0 +1,263 @@ +<template> +<x-container :removable="removable" @remove="() => $emit('remove')" :error="error" :warn="warn"> + <template #header><fa v-if="icon" :icon="icon"/> <template v-if="title">{{ title }} <span class="turmquns" v-if="typeText">({{ typeText }})</span></template><template v-else-if="typeText">{{ typeText }}</template></template> + <template #func> + <button @click="changeType()"> + <fa :icon="faPencilAlt"/> + </button> + </template> + + <section v-if="value.type === null" class="pbglfege" @click="changeType()"> + {{ $t('script.emptySlot') }} + </section> + <section v-else-if="value.type === 'text'" class="tbwccoaw"> + <input v-model="value.value"/> + </section> + <section v-else-if="value.type === 'multiLineText'" class="tbwccoaw"> + <textarea v-model="value.value"></textarea> + </section> + <section v-else-if="value.type === 'textList'" class="frvuzvoi"> + <ui-textarea v-model="value.value"></ui-textarea> + </section> + <section v-else-if="value.type === 'number'" class="tbwccoaw"> + <input v-model="value.value" type="number"/> + </section> + <section v-else-if="value.type === 'ref'" class="hpdwcrvs"> + <select v-model="value.value"> + <option v-for="v in aiScript.getVarsByType(getExpectedType ? getExpectedType() : null).filter(x => x.name !== name)" :value="v.name">{{ v.name }}</option> + <optgroup :label="$t('script.pageVariables')"> + <option v-for="v in aiScript.getPageVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option> + </optgroup> + <optgroup :label="$t('script.enviromentVariables')"> + <option v-for="v in aiScript.getEnvVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option> + </optgroup> + </select> + </section> + <section v-else-if="value.type === 'in'" class="hpdwcrvs"> + <select v-model="value.value"> + <option v-for="v in fnSlots" :value="v">{{ v }}</option> + </select> + </section> + <section v-else-if="value.type === 'fn'" class="" style="padding:16px;"> + <ui-textarea v-model="slots"></ui-textarea> + <x-v v-if="value.value.expression" v-model="value.value.expression" :title="$t(`script.blocks._fn.arg1`)" :get-expected-type="() => null" :ai-script="aiScript" :fn-slots="value.value.slots" :name="name"/> + </section> + <section v-else-if="value.type.startsWith('fn:')" class="" style="padding:16px;"> + <x-v v-for="(x, i) in value.args" v-model="value.args[i]" :title="aiScript.getVarByName(value.type.split(':')[1]).value.slots[i]" :get-expected-type="() => null" :ai-script="aiScript" :name="name" :key="i"/> + </section> + <section v-else class="" style="padding:16px;"> + <x-v v-for="(x, i) in value.args" v-model="value.args[i]" :title="$t(`script.blocks._${value.type}.arg${i + 1}`)" :get-expected-type="() => _getExpectedType(i)" :ai-script="aiScript" :name="name" :fn-slots="fnSlots" :key="i"/> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import XContainer from './page-editor.container.vue'; +import { faSuperscript, faPencilAlt, faSquareRootAlt } from '@fortawesome/free-solid-svg-icons'; +import { AiScript } from '../../../scripts/aiscript'; +import * as uuid from 'uuid'; + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XContainer + }, + + inject: ['getScriptBlockList'], + + props: { + getExpectedType: { + required: false, + default: null + }, + value: { + required: true + }, + title: { + required: false + }, + removable: { + required: false, + default: false + }, + aiScript: { + required: true, + }, + name: { + required: true, + }, + fnSlots: { + required: false, + }, + }, + + data() { + return { + AiScript, + error: null, + warn: null, + slots: '', + faSuperscript, faPencilAlt, faSquareRootAlt + }; + }, + + computed: { + icon(): any { + if (this.value.type === null) return null; + if (this.value.type.startsWith('fn:')) return null; + return AiScript.blockDefs.find(x => x.type === this.value.type).icon; + }, + typeText(): any { + if (this.value.type === null) return null; + return this.$t(`script.blocks.${this.value.type}`); + }, + }, + + watch: { + slots() { + this.value.value.slots = this.slots.split('\n'); + } + }, + + beforeCreate() { + this.$options.components.XV = require('./page-editor.script-block.vue').default; + }, + + created() { + if (this.value.value == null) Vue.set(this.value, 'value', null); + + if (this.value.value && this.value.value.slots) this.slots = this.value.value.slots.join('\n'); + + this.$watch('value.type', (t) => { + this.warn = null; + + if (this.value.type === 'fn') { + const id = uuid.v4(); + this.value.value = {}; + Vue.set(this.value.value, 'slots', []); + Vue.set(this.value.value, 'expression', { id, type: null }); + return; + } + + if (this.value.type && this.value.type.startsWith('fn:')) { + const fnName = this.value.type.split(':')[1]; + const fn = this.aiScript.getVarByName(fnName); + + const empties = []; + for (let i = 0; i < fn.value.slots.length; i++) { + const id = uuid.v4(); + empties.push({ id, type: null }); + } + Vue.set(this.value, 'args', empties); + return; + } + + if (AiScript.isLiteralBlock(this.value)) return; + + const empties = []; + for (let i = 0; i < AiScript.funcDefs[this.value.type].in.length; i++) { + const id = uuid.v4(); + empties.push({ id, type: null }); + } + Vue.set(this.value, 'args', empties); + + for (let i = 0; i < AiScript.funcDefs[this.value.type].in.length; i++) { + const inType = AiScript.funcDefs[this.value.type].in[i]; + if (typeof inType !== 'number') { + if (inType === 'number') this.value.args[i].type = 'number'; + if (inType === 'string') this.value.args[i].type = 'text'; + } + } + }); + + this.$watch('value.args', (args) => { + if (args == null) { + this.warn = null; + return; + } + const emptySlotIndex = args.findIndex(x => x.type === null); + if (emptySlotIndex !== -1 && emptySlotIndex < args.length) { + this.warn = { + slot: emptySlotIndex + }; + } else { + this.warn = null; + } + }, { + deep: true + }); + + this.$watch('aiScript.variables', () => { + if (this.type != null && this.value) { + this.error = this.aiScript.typeCheck(this.value); + } + }, { + deep: true + }); + }, + + methods: { + async changeType() { + const { canceled, result: type } = await this.$root.dialog({ + type: null, + title: this.$t('select-type'), + select: { + groupedItems: this.getScriptBlockList(this.getExpectedType ? this.getExpectedType() : null) + }, + showCancelButton: true + }); + if (canceled) return; + this.value.type = type; + }, + + _getExpectedType(slot: number) { + return this.aiScript.getExpectedType(this.value, slot); + } + } +}); +</script> + +<style lang="stylus" scoped> +.turmquns + opacity 0.7 + +.pbglfege + opacity 0.5 + padding 16px + text-align center + cursor pointer + color var(--text) + +.tbwccoaw + > input + > textarea + display block + -webkit-appearance none + -moz-appearance none + appearance none + width 100% + max-width 100% + min-width 100% + border none + box-shadow none + padding 16px + font-size 16px + background transparent + color var(--text) + + > textarea + min-height 100px + +.hpdwcrvs + padding 16px + + > select + display block + padding 4px + font-size 16px + width 100% + +</style> diff --git a/src/client/app/common/views/components/page-editor/page-editor.section.vue b/src/client/app/common/views/components/page-editor/page-editor.section.vue new file mode 100644 index 0000000000..d7a247b0b1 --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.section.vue @@ -0,0 +1,133 @@ +<template> +<x-container @remove="() => $emit('remove')"> + <template #header><fa :icon="faStickyNote"/> {{ value.title }}</template> + <template #func> + <button @click="rename()"> + <fa :icon="faPencilAlt"/> + </button> + <button @click="add()"> + <fa :icon="faPlus"/> + </button> + </template> + + <section class="ilrvjyvi"> + <div class="children"> + <x-block v-for="child in value.children" :value="child" @input="v => updateItem(v)" @remove="() => remove(child)" :key="child.id"/> + </div> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import { faPlus, faPencilAlt } from '@fortawesome/free-solid-svg-icons'; +import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; +import XContainer from './page-editor.container.vue'; +import * as uuid from 'uuid'; + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XContainer + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + faStickyNote, faPlus, faPencilAlt + }; + }, + + beforeCreate() { + this.$options.components.XBlock = require('./page-editor.block.vue').default + }, + + created() { + if (this.value.title == null) Vue.set(this.value, 'title', null); + if (this.value.children == null) Vue.set(this.value, 'children', []); + }, + + mounted() { + if (this.value.title == null) { + this.rename(); + } + }, + + methods: { + async rename() { + const { canceled, result: title } = await this.$root.dialog({ + title: 'Enter title', + input: { + type: 'text', + default: this.value.title + }, + showCancelButton: true + }); + if (canceled) return; + this.value.title = title; + }, + + async add() { + const { canceled, result: type } = await this.$root.dialog({ + type: null, + title: this.$t('choose-block'), + select: { + items: [{ + value: 'section', text: this.$t('blocks.section') + }, { + value: 'text', text: this.$t('blocks.text') + }, { + value: 'image', text: this.$t('blocks.image') + }, { + value: 'button', text: this.$t('blocks.button') + }, { + value: 'input', text: this.$t('blocks.input') + }, { + value: 'switch', text: this.$t('blocks.switch') + }] + }, + showCancelButton: true + }); + if (canceled) return; + + const id = uuid.v4(); + this.value.children.push({ id, type }); + }, + + updateItem(v) { + const i = this.value.children.findIndex(x => x.id === v.id); + const newValue = [ + ...this.value.children.slice(0, i), + v, + ...this.value.children.slice(i + 1) + ]; + this.value.children = newValue; + this.$emit('input', this.value); + }, + + remove(el) { + const i = this.value.children.findIndex(x => x.id === el.id); + const newValue = [ + ...this.value.children.slice(0, i), + ...this.value.children.slice(i + 1) + ]; + this.value.children = newValue; + this.$emit('input', this.value); + } + } +}); +</script> + +<style lang="stylus" scoped> +.ilrvjyvi + > .children + padding 16px + +</style> diff --git a/src/client/app/common/views/components/page-editor/page-editor.switch.vue b/src/client/app/common/views/components/page-editor/page-editor.switch.vue new file mode 100644 index 0000000000..a9cfa2844f --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.switch.vue @@ -0,0 +1,48 @@ +<template> +<x-container @remove="() => $emit('remove')"> + <template #header><fa :icon="faBolt"/> {{ $t('blocks.switch') }}</template> + + <section class="kjuadyyj"> + <ui-input v-model="value.name"><template #prefix><fa :icon="faSquareRootAlt"/></template><span>{{ $t('blocks._switch.name') }}</span></ui-input> + <ui-input v-model="value.text"><span>{{ $t('blocks._switch.text') }}</span></ui-input> + <ui-switch v-model="value.default"><span>{{ $t('blocks._switch.default') }}</span></ui-switch> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import { faBolt, faSquareRootAlt } from '@fortawesome/free-solid-svg-icons'; +import XContainer from './page-editor.container.vue'; + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XContainer + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + faBolt, faSquareRootAlt + }; + }, + + created() { + if (this.value.name == null) Vue.set(this.value, 'name', ''); + }, +}); +</script> + +<style lang="stylus" scoped> +.kjuadyyj + padding 0 16px 16px 16px + +</style> diff --git a/src/client/app/common/views/components/page-editor/page-editor.text.vue b/src/client/app/common/views/components/page-editor/page-editor.text.vue new file mode 100644 index 0000000000..7368931b2f --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.text.vue @@ -0,0 +1,57 @@ +<template> +<x-container @remove="() => $emit('remove')"> + <template #header><fa :icon="faAlignLeft"/> {{ $t('blocks.text') }}</template> + + <section class="ihymsbbe"> + <textarea v-model="value.text"></textarea> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import { faAlignLeft } from '@fortawesome/free-solid-svg-icons'; +import XContainer from './page-editor.container.vue'; + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XContainer + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + faAlignLeft, + }; + }, + + created() { + if (this.value.text == null) Vue.set(this.value, 'text', ''); + }, +}); +</script> + +<style lang="stylus" scoped> +.ihymsbbe + > textarea + display block + -webkit-appearance none + -moz-appearance none + appearance none + width 100% + min-width 100% + min-height 150px + border none + box-shadow none + padding 16px + background transparent + color var(--text) +</style> diff --git a/src/client/app/common/views/components/page-editor/page-editor.vue b/src/client/app/common/views/components/page-editor/page-editor.vue new file mode 100644 index 0000000000..1bcaaa0330 --- /dev/null +++ b/src/client/app/common/views/components/page-editor/page-editor.vue @@ -0,0 +1,452 @@ +<template> +<div> + <div class="gwbmwxkm" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"> + <header> + <div class="title"><fa :icon="faStickyNote"/> {{ pageId ? $t('edit-page') : $t('new-page') }}</div> + <div class="buttons"> + <button @click="del()"><fa :icon="faTrashAlt"/></button> + <button @click="() => showOptions = !showOptions"><fa :icon="faCog"/></button> + <button @click="save()"><fa :icon="faSave"/></button> + </div> + </header> + + <section> + <ui-input v-model="title"> + <span>{{ $t('title') }}</span> + </ui-input> + + <template v-if="showOptions"> + <ui-input v-model="summary"> + <span>{{ $t('summary') }}</span> + </ui-input> + + <ui-input v-model="name"> + <template #prefix>{{ url }}/@{{ $store.state.i.username }}/pages/</template> + <span>{{ $t('url') }}</span> + </ui-input> + + <ui-switch v-model="alignCenter">{{ $t('align-center') }}</ui-switch> + + <ui-select v-model="font"> + <template #label>{{ $t('font') }}</template> + <option value="serif">{{ $t('fontSerif') }}</option> + <option value="sans-serif">{{ $t('fontSansSerif') }}</option> + </ui-select> + + <div class="eyeCatch"> + <ui-button v-if="eyeCatchingImageId == null" @click="setEyeCatchingImage()"><fa :icon="faPlus"/> {{ $t('set-eye-catchig-image') }}</ui-button> + <div v-else-if="eyeCatchingImage"> + <img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name"/> + <ui-button @click="removeEyeCatchingImage()"><fa :icon="faTrashAlt"/> {{ $t('remove-eye-catchig-image') }}</ui-button> + </div> + </div> + </template> + + <div class="content" v-for="child in content"> + <x-block :value="child" @input="v => updateItem(v)" @remove="() => remove(child)" :key="child.id"/> + </div> + + <ui-button @click="add()"><fa :icon="faPlus"/></ui-button> + </section> + </div> + + <ui-container :body-togglable="true"> + <template #header><fa :icon="faSquareRootAlt"/> {{ $t('variables') }}</template> + <div class="qmuvgica"> + <div class="variables" v-show="variables.length > 0"> + <template v-for="variable in variables"> + <x-variable + :value="variable" + :removable="true" + @input="v => updateVariable(v)" + @remove="() => removeVariable(variable)" + :key="variable.name" + :ai-script="aiScript" + :name="variable.name" + :title="variable.name" + /> + </template> + </div> + + <ui-button @click="addVariable()" class="add"><fa :icon="faPlus"/></ui-button> + + <ui-info><span v-html="$t('variables-info')"></span><a @click="() => moreDetails = true" style="display:block;">{{ $t('more-details') }}</a></ui-info> + + <template v-if="moreDetails"> + <ui-info><span v-html="$t('variables-info2')"></span></ui-info> + <ui-info><span v-html="$t('variables-info3')"></span></ui-info> + </template> + </div> + </ui-container> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import { faICursor, faPlus, faSquareRootAlt, faCog } from '@fortawesome/free-solid-svg-icons'; +import { faSave, faStickyNote, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import XVariable from './page-editor.script-block.vue'; +import XBlock from './page-editor.block.vue'; +import * as uuid from 'uuid'; +import { AiScript } from '../../../scripts/aiscript'; +import { url } from '../../../../config'; +import { collectPageVars } from '../../../scripts/collect-page-vars'; + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XVariable, XBlock + }, + + props: { + page: { + type: String, + required: false + } + }, + + data() { + return { + pageId: null, + title: '', + summary: null, + name: Date.now().toString(), + eyeCatchingImage: null, + eyeCatchingImageId: null, + font: 'sans-serif', + content: [], + alignCenter: false, + variables: [], + aiScript: null, + showOptions: false, + moreDetails: false, + url, + faPlus, faICursor, faSave, faStickyNote, faSquareRootAlt, faCog, faTrashAlt + }; + }, + + watch: { + async eyeCatchingImageId() { + if (this.eyeCatchingImageId == null) { + this.eyeCatchingImage = null; + } else { + this.eyeCatchingImage = await this.$root.api('drive/files/show', { + fileId: this.eyeCatchingImageId, + }); + } + }, + }, + + created() { + this.aiScript = new AiScript(); + + this.$watch('variables', () => { + this.aiScript.injectVars(this.variables); + }, { deep: true }); + + this.$watch('content', () => { + this.aiScript.injectPageVars(collectPageVars(this.content)); + }, { deep: true }); + + if (this.page) { + this.$root.api('pages/show', { + pageId: this.page, + }).then(page => { + this.pageId = page.id; + this.title = page.title; + this.name = page.name; + this.summary = page.summary; + this.font = page.font; + this.alignCenter = page.alignCenter; + this.content = page.content; + this.variables = page.variables; + this.eyeCatchingImageId = page.eyeCatchingImageId; + }); + } else { + const id = uuid.v4(); + this.content = [{ + id, + type: 'text', + text: 'Hello World!' + }]; + } + }, + + provide() { + return { + getScriptBlockList: this.getScriptBlockList + } + }, + + methods: { + save() { + if (this.pageId) { + this.$root.api('pages/update', { + pageId: this.pageId, + title: this.title.trim(), + name: this.name.trim(), + summary: this.summary, + font: this.font, + alignCenter: this.alignCenter, + content: this.content, + variables: this.variables, + eyeCatchingImageId: this.eyeCatchingImageId, + }).then(page => { + this.$root.dialog({ + type: 'success', + text: this.$t('page-updated') + }); + }); + } else { + this.$root.api('pages/create', { + title: this.title.trim(), + name: this.name.trim(), + summary: this.summary, + font: this.font, + alignCenter: this.alignCenter, + content: this.content, + variables: this.variables, + eyeCatchingImageId: this.eyeCatchingImageId, + }).then(page => { + this.pageId = page.id; + this.$root.dialog({ + type: 'success', + text: this.$t('page-created') + }); + this.$router.push(`/i/pages/edit/${this.pageId}`); + }); + } + }, + + del() { + this.$root.dialog({ + type: 'warning', + text: this.$t('are-you-sure-delete'), + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + this.$root.api('pages/delete', { + pageId: this.pageId, + }).then(() => { + this.$root.dialog({ + type: 'success', + text: this.$t('page-deleted') + }); + this.$router.push(`/i/pages`); + }); + }); + }, + + async add() { + const { canceled, result: type } = await this.$root.dialog({ + type: null, + title: this.$t('choose-block'), + select: { + items: [{ + value: 'section', text: this.$t('blocks.section') + }, { + value: 'text', text: this.$t('blocks.text') + }, { + value: 'image', text: this.$t('blocks.image') + }, { + value: 'button', text: this.$t('blocks.button') + }, { + value: 'input', text: this.$t('blocks.input') + }, { + value: 'switch', text: this.$t('blocks.switch') + }] + }, + showCancelButton: true + }); + if (canceled) return; + + const id = uuid.v4(); + this.content.push({ id, type }); + }, + + async addVariable() { + let { canceled, result: name } = await this.$root.dialog({ + title: this.$t('enter-variable-name'), + input: { + type: 'text', + }, + showCancelButton: true + }); + if (canceled) return; + + name = name.trim(); + + if (this.aiScript.isUsedName(name)) { + this.$root.dialog({ + type: 'error', + text: this.$t('the-variable-name-is-already-used') + }); + return; + } + + const id = uuid.v4(); + this.variables.push({ id, name, type: null }); + }, + + updateItem(v) { + const i = this.content.findIndex(x => x.id === v.id); + const newValue = [ + ...this.content.slice(0, i), + v, + ...this.content.slice(i + 1) + ]; + this.content = newValue; + }, + + remove(el) { + const i = this.content.findIndex(x => x.id === el.id); + const newValue = [ + ...this.content.slice(0, i), + ...this.content.slice(i + 1) + ]; + this.content = newValue; + }, + + removeVariable(v) { + const i = this.variables.findIndex(x => x.name === v.name); + const newValue = [ + ...this.variables.slice(0, i), + ...this.variables.slice(i + 1) + ]; + this.variables = newValue; + }, + + getScriptBlockList(type: string = null) { + const list = []; + + const blocks = AiScript.blockDefs.filter(block => type === null || block.out === null || block.out === type); + + for (const block of blocks) { + const category = list.find(x => x.category === block.category); + if (category) { + category.items.push({ + value: block.type, + text: this.$t(`script.blocks.${block.type}`) + }); + } else { + list.push({ + category: block.category, + label: this.$t(`script.categories.${block.category}`), + items: [{ + value: block.type, + text: this.$t(`script.blocks.${block.type}`) + }] + }); + } + } + + const userFns = this.variables.filter(x => x.type === 'fn'); + if (userFns.length > 0) { + list.unshift({ + label: this.$t(`script.categories.fn`), + items: userFns.map(v => ({ + value: 'fn:' + v.name, + text: v.name + })) + }); + } + + return list; + }, + + setEyeCatchingImage() { + this.$chooseDriveFile({ + multiple: false + }).then(file => { + this.eyeCatchingImageId = file.id; + }); + }, + + removeEyeCatchingImage() { + this.eyeCatchingImageId = null; + } + } +}); +</script> + +<style lang="stylus" scoped> +.gwbmwxkm + overflow hidden + background var(--face) + margin-bottom 16px + + &.round + border-radius 6px + + &.shadow + box-shadow 0 3px 8px rgba(0, 0, 0, 0.2) + + > header + background var(--faceHeader) + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color var(--faceHeaderText) + box-shadow 0 var(--lineWidth) rgba(#000, 0.07) + + > [data-icon] + margin-right 6px + + &:empty + display none + + > .buttons + position absolute + z-index 2 + top 0 + right 0 + + > button + padding 0 + width 42px + font-size 0.9em + line-height 42px + color var(--faceTextButton) + + &:hover + color var(--faceTextButtonHover) + + &:active + color var(--faceTextButtonActive) + + > section + padding 0 32px 32px 32px + + @media (max-width 500px) + padding 0 16px 16px 16px + + > .content + margin-bottom 16px + + > .eyeCatch + margin-bottom 16px + + > div + > img + max-width 100% + +.qmuvgica + padding 32px + + @media (max-width 500px) + padding 16px + + > .variables + margin-bottom 16px + + > .add + margin-bottom 16px + +</style> diff --git a/src/client/app/common/views/components/page-preview.vue b/src/client/app/common/views/components/page-preview.vue new file mode 100644 index 0000000000..d8fdbf4b04 --- /dev/null +++ b/src/client/app/common/views/components/page-preview.vue @@ -0,0 +1,141 @@ +<template> +<router-link :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj" tabindex="-1" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"> + <div class="thumbnail" v-if="page.eyeCatchingImage" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div> + <article> + <header> + <h1 :title="page.title">{{ page.title }}</h1> + </header> + <p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p> + <footer> + <img class="icon" :src="page.user.avatarUrl"/> + <p>{{ page.user | userName }}</p> + </footer> + </article> +</router-link> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + page: { + type: Object, + required: true + }, + }, +}); +</script> + +<style lang="stylus" scoped> +.vhpxefrj + display block + overflow hidden + width 100% + background var(--face) + + &.round + border-radius 8px + + &.shadow + box-shadow 0 4px 16px rgba(#000, 0.1) + + @media (min-width 500px) + box-shadow 0 8px 32px rgba(#000, 0.1) + + > .thumbnail + position absolute + width 100px + height 100% + background-position center + background-size cover + display flex + justify-content center + align-items center + + > button + font-size 3.5em + opacity: 0.7 + + &:hover + font-size 4em + opacity 0.9 + + & + article + left 100px + width calc(100% - 100px) + + > article + padding 16px + + > header + margin-bottom 8px + + > h1 + margin 0 + font-size 1em + color var(--urlPreviewTitle) + + > p + margin 0 + color var(--urlPreviewText) + font-size 0.8em + + > footer + margin-top 8px + height 16px + + > img + display inline-block + width 16px + height 16px + margin-right 4px + vertical-align top + + > p + display inline-block + margin 0 + color var(--urlPreviewInfo) + font-size 0.8em + line-height 16px + vertical-align top + + @media (max-width 700px) + > .thumbnail + position relative + width 100% + height 100px + + & + article + left 0 + width 100% + + @media (max-width 550px) + font-size 12px + + > .thumbnail + height 80px + + > article + padding 12px + + @media (max-width 500px) + font-size 10px + + > .thumbnail + height 70px + + > article + padding 8px + + > header + margin-bottom 4px + + > footer + margin-top 4px + + > img + width 12px + height 12px + +</style> diff --git a/src/client/app/common/views/pages/page/page.block.vue b/src/client/app/common/views/pages/page/page.block.vue new file mode 100644 index 0000000000..48a89f9de7 --- /dev/null +++ b/src/client/app/common/views/pages/page/page.block.vue @@ -0,0 +1,34 @@ +<template> +<component :is="'x-' + value.type" :value="value" :page="page" :script="script" :key="value.id" :h="h"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XText from './page.text.vue'; +import XSection from './page.section.vue'; +import XImage from './page.image.vue'; +import XButton from './page.button.vue'; +import XInput from './page.input.vue'; +import XSwitch from './page.switch.vue'; + +export default Vue.extend({ + components: { + XText, XSection, XImage, XButton, XInput, XSwitch + }, + + props: { + value: { + required: true + }, + script: { + required: true + }, + page: { + required: true + }, + h: { + required: true + } + }, +}); +</script> diff --git a/src/client/app/common/views/pages/page/page.button.vue b/src/client/app/common/views/pages/page/page.button.vue new file mode 100644 index 0000000000..5063d27122 --- /dev/null +++ b/src/client/app/common/views/pages/page/page.button.vue @@ -0,0 +1,42 @@ +<template> +<div> + <ui-button class="kudkigyw" @click="click()">{{ value.text }}</ui-button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: true + }, + script: { + required: true + } + }, + + methods: { + click() { + if (this.value.action === 'dialog') { + this.script.reEval(); + this.$root.dialog({ + text: this.script.interpolate(this.value.content) + }); + } else if (this.value.action === 'resetRandom') { + this.script.aiScript.updateRandomSeed(Math.random()); + this.script.reEval(); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.kudkigyw + display inline-block + min-width 300px + max-width 450px + margin 8px 0 +</style> diff --git a/src/client/app/common/views/pages/page/page.image.vue b/src/client/app/common/views/pages/page/page.image.vue new file mode 100644 index 0000000000..1285445eb0 --- /dev/null +++ b/src/client/app/common/views/pages/page/page.image.vue @@ -0,0 +1,36 @@ +<template> +<div class="lzyxtsnt"> + <img v-if="image" :src="image.url"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: true + }, + page: { + required: true + }, + }, + + data() { + return { + image: null, + }; + }, + + created() { + this.image = this.page.attachedFiles.find(x => x.id === this.value.fileId); + } +}); +</script> + +<style lang="stylus" scoped> +.lzyxtsnt + > img + max-width 100% +</style> diff --git a/src/client/app/common/views/pages/page/page.input.vue b/src/client/app/common/views/pages/page/page.input.vue new file mode 100644 index 0000000000..cda5550337 --- /dev/null +++ b/src/client/app/common/views/pages/page/page.input.vue @@ -0,0 +1,43 @@ +<template> +<div> + <ui-input class="kudkigyw" v-model="v" :type="value.inputType">{{ value.text }}</ui-input> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: true + }, + script: { + required: true + } + }, + + data() { + return { + v: this.value.default, + }; + }, + + watch: { + v() { + let v = this.v; + if (this.value.inputType === 'number') v = parseInt(v, 10); + this.script.aiScript.updatePageVar(this.value.name, v); + this.script.reEval(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.kudkigyw + display inline-block + min-width 300px + max-width 450px + margin 8px 0 +</style> diff --git a/src/client/app/common/views/pages/page/page.section.vue b/src/client/app/common/views/pages/page/page.section.vue new file mode 100644 index 0000000000..03c009d9c3 --- /dev/null +++ b/src/client/app/common/views/pages/page/page.section.vue @@ -0,0 +1,55 @@ +<template> +<section class="sdgxphyu"> + <component :is="'h' + h">{{ value.title }}</component> + + <div class="children"> + <x-block v-for="child in value.children" :value="child" :page="page" :script="script" :key="child.id" :h="h + 1"/> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: true + }, + script: { + required: true + }, + page: { + required: true + }, + h: { + required: true + } + }, + + beforeCreate() { + this.$options.components.XBlock = require('./page.block.vue').default + }, +}); +</script> + +<style lang="stylus" scoped> +.sdgxphyu + margin 1.5em 0 + + > h2 + font-size 1.35em + margin 0 0 0.5em 0 + + > h3 + font-size 1em + margin 0 0 0.5em 0 + + > h4 + font-size 1em + margin 0 0 0.5em 0 + + > .children + //padding 16px + +</style> diff --git a/src/client/app/common/views/pages/page/page.switch.vue b/src/client/app/common/views/pages/page/page.switch.vue new file mode 100644 index 0000000000..715a2fee6e --- /dev/null +++ b/src/client/app/common/views/pages/page/page.switch.vue @@ -0,0 +1,33 @@ +<template> +<div> + <ui-switch v-model="v">{{ value.text }}</ui-switch> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: true + }, + script: { + required: true + } + }, + + data() { + return { + v: this.value.default, + }; + }, + + watch: { + v() { + this.script.aiScript.updatePageVar(this.value.name, this.v); + this.script.reEval(); + } + } +}); +</script> diff --git a/src/client/app/common/views/pages/page/page.text.vue b/src/client/app/common/views/pages/page/page.text.vue new file mode 100644 index 0000000000..eadc6f0aed --- /dev/null +++ b/src/client/app/common/views/pages/page/page.text.vue @@ -0,0 +1,35 @@ +<template> +<div class=""> + <mfm :text="text" :is-note="false" :i="$store.state.i" :key="text"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: true + }, + script: { + required: true + } + }, + + data() { + return { + text: this.script.interpolate(this.value.text), + }; + }, + + created() { + this.$watch('script.vars', () => { + this.text = this.script.interpolate(this.value.text); + }, { deep: true }); + } +}); +</script> + +<style lang="stylus" scoped> +</style> diff --git a/src/client/app/common/views/pages/page/page.vue b/src/client/app/common/views/pages/page/page.vue new file mode 100644 index 0000000000..5ca58a6a4e --- /dev/null +++ b/src/client/app/common/views/pages/page/page.vue @@ -0,0 +1,143 @@ +<template> +<div v-if="page" class="iroscrza" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners, center: page.alignCenter }" :style="{ fontFamily: page.font }"> + <header> + <div class="title">{{ page.title }}</div> + </header> + + <div v-if="script"> + <x-block v-for="child in page.content" :value="child" @input="v => updateBlock(v)" :page="page" :script="script" :key="child.id" :h="2"/> + </div> + + <footer> + <small>@{{ page.user.username }}</small> + <router-link v-if="$store.getters.isSignedIn && $store.state.i.id === page.userId" :to="`/i/pages/edit/${page.id}`">{{ $t('edit-this-page') }}</router-link> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import { faICursor, faPlus, faSquareRootAlt } from '@fortawesome/free-solid-svg-icons'; +import { faSave, faStickyNote } from '@fortawesome/free-regular-svg-icons'; +import XBlock from './page.block.vue'; +import { AiScript } from '../../../scripts/aiscript'; +import { collectPageVars } from '../../../scripts/collect-page-vars'; + +class Script { + public aiScript: AiScript; + public vars: any; + + constructor(aiScript) { + this.aiScript = aiScript; + this.vars = this.aiScript.evaluateVars(); + } + + public reEval() { + this.vars = this.aiScript.evaluateVars(); + } + + public interpolate(str: string) { + return str.replace(/\{(.+?)\}/g, match => + (this.vars.find(x => x.name === match.slice(1, -1).trim()).value || '').toString()); + } +} + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XBlock + }, + + props: { + pageName: { + type: String, + required: true + }, + username: { + type: String, + required: true + }, + }, + + data() { + return { + page: null, + script: null, + faPlus, faICursor, faSave, faStickyNote, faSquareRootAlt + }; + }, + + created() { + this.$root.api('pages/show', { + name: this.pageName, + username: this.username, + }).then(page => { + this.page = page; + const pageVars = this.getPageVars(); + this.script = new Script(new AiScript(this.page.variables, pageVars, { + randomSeed: Math.random(), + user: page.user, + visitor: this.$store.state.i + })); + }); + }, + + methods: { + getPageVars() { + return collectPageVars(this.page.content); + }, + } +}); +</script> + +<style lang="stylus" scoped> +.iroscrza + overflow hidden + background var(--face) + + &.center + text-align center + + &.round + border-radius 6px + + &.shadow + box-shadow 0 3px 8px rgba(0, 0, 0, 0.2) + + > header + > .title + z-index 1 + margin 0 + padding 32px 64px + font-size 24px + font-weight bold + color var(--text) + box-shadow 0 var(--lineWidth) rgba(#000, 0.07) + + @media (max-width 600px) + padding 16px 32px + font-size 20px + + > div + color var(--text) + padding 48px 64px + font-size 18px + + @media (max-width 600px) + padding 24px 32px + font-size 16px + + > footer + color var(--text) + padding 0 64px 38px 64px + + @media (max-width 600px) + padding 0 32px 28px 32px + + > small + display block + opacity 0.5 + +</style> diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts index 8d292ce324..00ba5db23a 100644 --- a/src/client/app/desktop/script.ts +++ b/src/client/app/desktop/script.ts @@ -156,7 +156,11 @@ init(async (launch, os) => { { path: '/explore', name: 'explore', component: () => import('../common/views/pages/explore.vue').then(m => m.default) }, { path: '/explore/tags/:tag', name: 'explore-tag', props: true, component: () => import('../common/views/pages/explore.vue').then(m => m.default) }, { path: '/i/favorites', component: () => import('./views/home/favorites.vue').then(m => m.default) }, + { path: '/i/pages', component: () => import('./views/home/pages.vue').then(m => m.default) }, ]}, + { path: '/@:user/pages/:page', props: true, component: () => import('./views/pages/page.vue').then(m => m.default) }, + { path: '/i/pages/new', component: () => import('./views/pages/page-editor.vue').then(m => m.default) }, + { path: '/i/pages/edit/:page', props: true, component: () => import('./views/pages/page-editor.vue').then(m => m.default) }, { path: '/i/messaging/:user', component: MkMessagingRoom }, { path: '/i/drive', component: MkDrive }, { path: '/i/drive/folder/:folder', component: MkDrive }, diff --git a/src/client/app/desktop/views/components/ui.header.account.vue b/src/client/app/desktop/views/components/ui.header.account.vue index 7f9decfdcd..05692667b7 100644 --- a/src/client/app/desktop/views/components/ui.header.account.vue +++ b/src/client/app/desktop/views/components/ui.header.account.vue @@ -9,35 +9,42 @@ <ul> <li> <router-link :to="`/@${ $store.state.i.username }`"> - <i><fa icon="user"/></i> + <i><fa icon="user" fixed-width/></i> <span>{{ $t('profile') }}</span> <i><fa icon="angle-right"/></i> </router-link> </li> <li @click="drive"> <p> - <i><fa icon="cloud"/></i> + <i><fa icon="cloud" fixed-width/></i> <span>{{ $t('@.drive') }}</span> <i><fa icon="angle-right"/></i> </p> </li> <li> <router-link to="/i/favorites"> - <i><fa icon="star"/></i> + <i><fa icon="star" fixed-width/></i> <span>{{ $t('@.favorites') }}</span> <i><fa icon="angle-right"/></i> </router-link> </li> <li @click="list"> <p> - <i><fa icon="list"/></i> + <i><fa icon="list" fixed-width/></i> <span>{{ $t('lists') }}</span> <i><fa icon="angle-right"/></i> </p> </li> + <li @click="page"> + <router-link to="/i/pages"> + <i><fa :icon="faStickyNote" fixed-width/></i> + <span>{{ $t('@.pages') }}</span> + <i><fa icon="angle-right"/></i> + </router-link> + </li> <li @click="followRequests" v-if="($store.state.i.isLocked || $store.state.i.carefulBot)"> <p> - <i><fa :icon="['far', 'envelope']"/></i> + <i><fa :icon="['far', 'envelope']" fixed-width/></i> <span>{{ $t('follow-requests') }}<i v-if="$store.state.i.pendingReceivedFollowRequestsCount">{{ $store.state.i.pendingReceivedFollowRequestsCount }}</i></span> <i><fa icon="angle-right"/></i> </p> @@ -46,14 +53,14 @@ <ul> <li> <router-link to="/i/settings"> - <i><fa icon="cog"/></i> + <i><fa icon="cog" fixed-width/></i> <span>{{ $t('@.settings') }}</span> <i><fa icon="angle-right"/></i> </router-link> </li> <li v-if="$store.state.i.isAdmin || $store.state.i.isModerator"> <a href="/admin"> - <i><fa icon="terminal"/></i> + <i><fa icon="terminal" fixed-width/></i> <span>{{ $t('admin') }}</span> <i><fa icon="angle-right"/></i> </a> @@ -76,7 +83,7 @@ <ul> <li @click="signout"> <p class="signout"> - <i><fa icon="power-off"/></i> + <i><fa icon="power-off" fixed-width/></i> <span>{{ $t('@.signout') }}</span> </p> </li> @@ -95,14 +102,14 @@ import MkFollowRequestsWindow from './received-follow-requests-window.vue'; import MkDriveWindow from './drive-window.vue'; import contains from '../../../common/scripts/contains'; import { faHome, faColumns } from '@fortawesome/free-solid-svg-icons'; -import { faMoon, faSun } from '@fortawesome/free-regular-svg-icons'; +import { faMoon, faSun, faStickyNote } from '@fortawesome/free-regular-svg-icons'; export default Vue.extend({ i18n: i18n('desktop/views/components/ui.header.account.vue'), data() { return { isOpen: false, - faHome, faColumns, faMoon, faSun + faHome, faColumns, faMoon, faSun, faStickyNote }; }, computed: { diff --git a/src/client/app/desktop/views/home/pages.vue b/src/client/app/desktop/views/home/pages.vue new file mode 100644 index 0000000000..9f7fb65159 --- /dev/null +++ b/src/client/app/desktop/views/home/pages.vue @@ -0,0 +1,92 @@ +<template> +<div class="rknalgpo" v-if="!fetching"> + <ui-button @click="create()"><fa :icon="faPlus"/></ui-button> + <sequential-entrance animation="entranceFromTop" delay="25"> + <template v-for="page in pages"> + <x-page-preview class="page" :page="page" :key="page.id"/> + </template> + </sequential-entrance> + <ui-button v-if="existMore" @click="fetchMore()">{{ $t('@.load-more') }}</ui-button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import Progress from '../../../common/scripts/loading'; +import { faPlus } from '@fortawesome/free-solid-svg-icons'; +import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; +import XPagePreview from '../../../common/views/components/page-preview.vue'; + +export default Vue.extend({ + i18n: i18n(), + components: { + XPagePreview + }, + data() { + return { + fetching: true, + pages: [], + existMore: false, + moreFetching: false, + faStickyNote, faPlus + }; + }, + created() { + this.fetch(); + }, + methods: { + fetch() { + Progress.start(); + this.fetching = true; + + this.$root.api('i/pages', { + limit: 11 + }).then(pages => { + if (pages.length == 11) { + this.existMore = true; + pages.pop(); + } + + this.pages = pages; + this.fetching = false; + + Progress.done(); + }); + }, + fetchMore() { + this.moreFetching = true; + this.$root.api('i/pages', { + limit: 11, + untilId: this.pages[this.pages.length - 1].id + }).then(pages => { + if (pages.length == 11) { + this.existMore = true; + pages.pop(); + } else { + this.existMore = false; + } + + this.pages = this.pages.concat(pages); + this.moreFetching = false; + }); + }, + create() { + this.$router.push(`/i/pages/new`); + } + } +}); +</script> + +<style lang="stylus" scoped> +.rknalgpo + margin 0 auto + + > * > .page + margin-bottom 8px + + @media (min-width 500px) + > * > .page + margin-bottom 16px + +</style> diff --git a/src/client/app/desktop/views/pages/page-editor.vue b/src/client/app/desktop/views/pages/page-editor.vue new file mode 100644 index 0000000000..50d1e7db61 --- /dev/null +++ b/src/client/app/desktop/views/pages/page-editor.vue @@ -0,0 +1,32 @@ +<template> +<mk-ui> + <main> + <x-page-editor :page="page"/> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + components: { + XPageEditor: () => import('../../../common/views/components/page-editor/page-editor.vue').then(m => m.default) + }, + + props: { + page: { + type: String, + required: false + } + } +}); +</script> + +<style lang="stylus" scoped> +main + margin 0 auto + padding 16px + max-width 900px + +</style> diff --git a/src/client/app/desktop/views/pages/page.vue b/src/client/app/desktop/views/pages/page.vue new file mode 100644 index 0000000000..1ddff08c76 --- /dev/null +++ b/src/client/app/desktop/views/pages/page.vue @@ -0,0 +1,36 @@ +<template> +<mk-ui> + <main> + <x-page :page-name="page" :username="user"/> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + components: { + XPage: () => import('../../../common/views/pages/page/page.vue').then(m => m.default) + }, + + props: { + page: { + type: String, + required: true + }, + user: { + type: String, + required: true + }, + } +}); +</script> + +<style lang="stylus" scoped> +main + margin 0 auto + padding 16px + max-width 950px + +</style> diff --git a/src/client/app/mobile/script.ts b/src/client/app/mobile/script.ts index 510141f94b..136bbc31c4 100644 --- a/src/client/app/mobile/script.ts +++ b/src/client/app/mobile/script.ts @@ -135,6 +135,7 @@ init((launch, os) => { { path: '/signup', name: 'signup', component: MkSignup }, { path: '/i/settings', name: 'settings', component: () => import('./views/pages/settings.vue').then(m => m.default) }, { path: '/i/favorites', name: 'favorites', component: MkFavorites }, + { path: '/i/pages', name: 'pages', component: () => import('./views/pages/pages.vue').then(m => m.default) }, { path: '/i/lists', name: 'user-lists', component: MkUserLists }, { path: '/i/lists/:list', name: 'user-list', component: MkUserList }, { path: '/i/received-follow-requests', name: 'received-follow-requests', component: MkReceivedFollowRequests }, @@ -144,6 +145,8 @@ init((launch, os) => { { path: '/i/drive', name: 'drive', component: MkDrive }, { path: '/i/drive/folder/:folder', component: MkDrive }, { path: '/i/drive/file/:file', component: MkDrive }, + { path: '/i/pages/new', component: () => import('./views/pages/page-editor.vue').then(m => m.default) }, + { path: '/i/pages/edit/:page', props: true, component: () => import('./views/pages/page-editor.vue').then(m => m.default) }, { path: '/selectdrive', component: MkSelectDrive }, { path: '/search', component: MkSearch }, { path: '/tags/:tag', component: MkTag }, @@ -156,6 +159,7 @@ init((launch, os) => { { path: 'following', component: () => import('../common/views/pages/following.vue').then(m => m.default) }, { path: 'followers', component: () => import('../common/views/pages/followers.vue').then(m => m.default) }, ]}, + { path: '/@:user/pages/:page', props: true, component: () => import('./views/pages/page.vue').then(m => m.default) }, { path: '/notes/:note', component: MkNote }, { path: '/authorize-follow', component: MkFollow }, { path: '*', component: MkNotFound } diff --git a/src/client/app/mobile/views/components/ui.nav.vue b/src/client/app/mobile/views/components/ui.nav.vue index 9a3ade4c63..da9bb518ef 100644 --- a/src/client/app/mobile/views/components/ui.nav.vue +++ b/src/client/app/mobile/views/components/ui.nav.vue @@ -29,6 +29,7 @@ <li><router-link to="/i/favorites" :data-active="$route.name == 'favorites'"><i><fa icon="star" fixed-width/></i>{{ $t('@.favorites') }}<i><fa icon="angle-right"/></i></router-link></li> <li><router-link to="/i/lists" :data-active="$route.name == 'user-lists'"><i><fa icon="list" fixed-width/></i>{{ $t('user-lists') }}<i><fa icon="angle-right"/></i></router-link></li> <li><router-link to="/i/drive" :data-active="$route.name == 'drive'"><i><fa icon="cloud" fixed-width/></i>{{ $t('@.drive') }}<i><fa icon="angle-right"/></i></router-link></li> + <li><router-link to="/i/pages" :data-active="$route.name == 'pages'"><i><fa :icon="faStickyNote" fixed-width/></i>{{ $t('@.pages') }}<i><fa icon="angle-right"/></i></router-link></li> </ul> <ul> <li><a @click="search"><i><fa icon="search" fixed-width/></i>{{ $t('search') }}<i><fa icon="angle-right"/></i></a></li> @@ -66,7 +67,7 @@ import Vue from 'vue'; import i18n from '../../../i18n'; import { lang } from '../../../config'; import { faNewspaper, faHashtag, faHome, faColumns } from '@fortawesome/free-solid-svg-icons'; -import { faMoon, faSun } from '@fortawesome/free-regular-svg-icons'; +import { faMoon, faSun, faStickyNote } from '@fortawesome/free-regular-svg-icons'; import { search } from '../../../common/scripts/search'; export default Vue.extend({ @@ -86,7 +87,7 @@ export default Vue.extend({ announcements: [], searching: false, showNotifications: false, - faNewspaper, faHashtag, faMoon, faSun, faHome, faColumns + faNewspaper, faHashtag, faMoon, faSun, faHome, faColumns, faStickyNote }; }, diff --git a/src/client/app/mobile/views/pages/page-editor.vue b/src/client/app/mobile/views/pages/page-editor.vue new file mode 100644 index 0000000000..9d549c784f --- /dev/null +++ b/src/client/app/mobile/views/pages/page-editor.vue @@ -0,0 +1,32 @@ +<template> +<mk-ui> + <main> + <x-page-editor :page="page"/> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + components: { + XPageEditor: () => import('../../../common/views/components/page-editor/page-editor.vue').then(m => m.default) + }, + + props: { + page: { + type: String, + required: false + } + } +}); +</script> + +<style lang="stylus" scoped> +main + margin 0 auto + padding 16px + max-width 1000px + +</style> diff --git a/src/client/app/mobile/views/pages/page.vue b/src/client/app/mobile/views/pages/page.vue new file mode 100644 index 0000000000..27ade4a398 --- /dev/null +++ b/src/client/app/mobile/views/pages/page.vue @@ -0,0 +1,36 @@ +<template> +<mk-ui> + <main> + <x-page :page-name="page" :username="user"/> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + components: { + XPage: () => import('../../../common/views/pages/page/page.vue').then(m => m.default) + }, + + props: { + page: { + type: String, + required: true + }, + user: { + type: String, + required: true + }, + } +}); +</script> + +<style lang="stylus" scoped> +main + margin 0 auto + padding 16px + max-width 1000px + +</style> diff --git a/src/client/app/mobile/views/pages/pages.vue b/src/client/app/mobile/views/pages/pages.vue new file mode 100644 index 0000000000..100c814ad9 --- /dev/null +++ b/src/client/app/mobile/views/pages/pages.vue @@ -0,0 +1,94 @@ +<template> +<mk-ui> + <template #header><span style="margin-right:4px;"><fa :icon="faStickyNote"/></span>{{ $t('@.pages') }}</template> + + <main> + <ui-button @click="create()"><fa :icon="faPlus"/></ui-button> + <sequential-entrance animation="entranceFromTop" delay="25"> + <template v-for="page in pages"> + <x-page-preview class="page" :page="page" :key="page.id"/> + </template> + </sequential-entrance> + <ui-button v-if="existMore" @click="fetchMore()">{{ $t('@.load-more') }}</ui-button> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import Progress from '../../../common/scripts/loading'; +import { faPlus } from '@fortawesome/free-solid-svg-icons'; +import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; +import XPagePreview from '../../../common/views/components/page-preview.vue'; + +export default Vue.extend({ + i18n: i18n(), + components: { + XPagePreview + }, + data() { + return { + fetching: true, + pages: [], + existMore: false, + moreFetching: false, + faStickyNote, faPlus + }; + }, + created() { + this.fetch(); + }, + methods: { + fetch() { + Progress.start(); + this.fetching = true; + + this.$root.api('i/pages', { + limit: 11 + }).then(pages => { + if (pages.length == 11) { + this.existMore = true; + pages.pop(); + } + + this.pages = pages; + this.fetching = false; + + Progress.done(); + }); + }, + fetchMore() { + this.moreFetching = true; + this.$root.api('i/pages', { + limit: 11, + untilId: this.pages[this.pages.length - 1].id + }).then(pages => { + if (pages.length == 11) { + this.existMore = true; + pages.pop(); + } else { + this.existMore = false; + } + + this.pages = this.pages.concat(pages); + this.moreFetching = false; + }); + }, + create() { + this.$router.push(`/i/pages/new`); + } + } +}); +</script> + +<style lang="stylus" scoped> +main + > * > .page + margin-bottom 8px + + @media (min-width 500px) + > * > .page + margin-bottom 16px + +</style> diff --git a/src/client/themes/dark.json5 b/src/client/themes/dark.json5 index 5f44f8570e..8e0c726b4c 100644 --- a/src/client/themes/dark.json5 +++ b/src/client/themes/dark.json5 @@ -232,5 +232,8 @@ adminDashboardCardBg: '$secondary', adminDashboardCardFg: '$text', adminDashboardCardDivider: 'rgba(0, 0, 0, 0.3)', + + pageBlockBorder: 'rgba(255, 255, 255, 0.1)', + pageBlockBorderHover: 'rgba(255, 255, 255, 0.15)', }, } diff --git a/src/client/themes/light.json5 b/src/client/themes/light.json5 index d5680f8f82..1fff18176a 100644 --- a/src/client/themes/light.json5 +++ b/src/client/themes/light.json5 @@ -232,5 +232,8 @@ adminDashboardCardBg: '$secondary', adminDashboardCardFg: '$text', adminDashboardCardDivider: 'rgba(0, 0, 0, 0.082)', + + pageBlockBorder: 'rgba(0, 0, 0, 0.1)', + pageBlockBorderHover: 'rgba(0, 0, 0, 0.15)', }, } diff --git a/src/db/postgre.ts b/src/db/postgre.ts index 71836638f1..18283836aa 100644 --- a/src/db/postgre.ts +++ b/src/db/postgre.ts @@ -40,6 +40,7 @@ import { Poll } from '../models/entities/poll'; import { UserKeypair } from '../models/entities/user-keypair'; import { UserPublickey } from '../models/entities/user-publickey'; import { UserProfile } from '../models/entities/user-profile'; +import { Page } from '../models/entities/page'; const sqlLogger = dbLogger.createSubLogger('sql', 'white', false); @@ -114,6 +115,7 @@ export function initDb(justBorrow = false, sync = false, log = false) { NoteReaction, NoteWatching, NoteUnread, + Page, Log, DriveFile, DriveFolder, diff --git a/src/models/entities/page.ts b/src/models/entities/page.ts new file mode 100644 index 0000000000..f57ca8c7c3 --- /dev/null +++ b/src/models/entities/page.ts @@ -0,0 +1,105 @@ +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; +import { DriveFile } from './drive-file'; + +@Entity() +@Index(['userId', 'name'], { unique: true }) +export class Page { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Page.' + }) + public createdAt: Date; + + @Index() + @Column('timestamp with time zone', { + comment: 'The updated date of the Page.' + }) + public updatedAt: Date; + + @Column('varchar', { + length: 256, + }) + public title: string; + + @Index() + @Column('varchar', { + length: 256, + }) + public name: string; + + @Column('varchar', { + length: 256, nullable: true + }) + public summary: string | null; + + @Column('boolean') + public alignCenter: boolean; + + @Column('varchar', { + length: 32, + }) + public font: string; + + @Index() + @Column({ + ...id(), + comment: 'The ID of author.' + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column({ + ...id(), + nullable: true, + }) + public eyeCatchingImageId: DriveFile['id'] | null; + + @ManyToOne(type => DriveFile, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public eyeCatchingImage: DriveFile | null; + + @Column('jsonb', { + default: [] + }) + public content: Record<string, any>[]; + + @Column('jsonb', { + default: [] + }) + public variables: Record<string, any>[]; + + /** + * public ... 公開 + * followers ... フォロワーのみ + * specified ... visibleUserIds で指定したユーザーのみ + */ + @Column('enum', { enum: ['public', 'followers', 'specified'] }) + public visibility: 'public' | 'followers' | 'specified'; + + @Index() + @Column({ + ...id(), + array: true, default: '{}' + }) + public visibleUserIds: User['id'][]; + + constructor(data: Partial<Page>) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/src/models/index.ts b/src/models/index.ts index 826044e7a5..e402d6723d 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -35,6 +35,7 @@ import { AbuseUserReportRepository } from './repositories/abuse-user-report'; import { AuthSessionRepository } from './repositories/auth-session'; import { UserProfile } from './entities/user-profile'; import { HashtagRepository } from './repositories/hashtag'; +import { PageRepository } from './repositories/page'; export const Apps = getCustomRepository(AppRepository); export const Notes = getCustomRepository(NoteRepository); @@ -72,3 +73,4 @@ export const MessagingMessages = getCustomRepository(MessagingMessageRepository) export const ReversiGames = getCustomRepository(ReversiGameRepository); export const ReversiMatchings = getCustomRepository(ReversiMatchingRepository); export const Logs = getRepository(Log); +export const Pages = getCustomRepository(PageRepository); diff --git a/src/models/repositories/page.ts b/src/models/repositories/page.ts new file mode 100644 index 0000000000..4c1b4cc793 --- /dev/null +++ b/src/models/repositories/page.ts @@ -0,0 +1,61 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Page } from '../entities/page'; +import { SchemaType, types, bool } from '../../misc/schema'; +import { Users, DriveFiles } from '..'; +import { awaitAll } from '../../prelude/await-all'; +import { DriveFile } from '../entities/drive-file'; + +export type PackedPage = SchemaType<typeof packedPageSchema>; + +@EntityRepository(Page) +export class PageRepository extends Repository<Page> { + public async pack( + src: Page, + ): Promise<PackedPage> { + const attachedFiles: Promise<DriveFile | undefined>[] = []; + const collectFile = (xs: any[]) => { + for (const x of xs) { + if (x.type === 'image') { + attachedFiles.push(DriveFiles.findOne({ + id: x.fileId, + userId: src.userId + })); + } + if (x.children) { + collectFile(x.children); + } + } + }; + collectFile(src.content); + return await awaitAll({ + id: src.id, + createdAt: src.createdAt.toISOString(), + updatedAt: src.updatedAt.toISOString(), + userId: src.userId, + user: Users.pack(src.user || src.userId), + content: src.content, + variables: src.variables, + title: src.title, + name: src.name, + summary: src.summary, + alignCenter: src.alignCenter, + font: src.font, + eyeCatchingImageId: src.eyeCatchingImageId, + eyeCatchingImage: src.eyeCatchingImageId ? await DriveFiles.pack(src.eyeCatchingImageId) : null, + attachedFiles: DriveFiles.packMany(await Promise.all(attachedFiles)) + }); + } + + public packMany( + pages: Page[], + ) { + return Promise.all(pages.map(x => this.pack(x))); + } +} + +export const packedPageSchema = { + type: types.object, + optional: bool.false, nullable: bool.false, + properties: { + } +}; diff --git a/src/server/api/endpoints/i/pages.ts b/src/server/api/endpoints/i/pages.ts new file mode 100644 index 0000000000..5eb4db81b7 --- /dev/null +++ b/src/server/api/endpoints/i/pages.ts @@ -0,0 +1,44 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { Pages } from '../../../../models'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + desc: { + 'ja-JP': '自分の作成したページ一覧を取得します。', + 'en-US': 'Get my pages.' + }, + + tags: ['account', 'pages'], + + requireCredential: true, + + kind: 'read:pages', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(Pages.createQueryBuilder('page'), ps.sinceId, ps.untilId) + .andWhere(`page.userId = :meId`, { meId: user.id }); + + const pages = await query + .take(ps.limit!) + .getMany(); + + return await Pages.packMany(pages); +}); diff --git a/src/server/api/endpoints/pages/create.ts b/src/server/api/endpoints/pages/create.ts new file mode 100644 index 0000000000..e6b813648b --- /dev/null +++ b/src/server/api/endpoints/pages/create.ts @@ -0,0 +1,108 @@ +import $ from 'cafy'; +import * as ms from 'ms'; +import define from '../../define'; +import { ID } from '../../../../misc/cafy-id'; +import { types, bool } from '../../../../misc/schema'; +import { Pages, DriveFiles } from '../../../../models'; +import { genId } from '../../../../misc/gen-id'; +import { Page } from '../../../../models/entities/page'; +import { ApiError } from '../../error'; + +export const meta = { + desc: { + 'ja-JP': 'ページを作成します。', + }, + + tags: ['pages'], + + requireCredential: true, + + kind: 'write:pages', + + limit: { + duration: ms('1hour'), + max: 300 + }, + + params: { + title: { + validator: $.str, + }, + + name: { + validator: $.str, + }, + + summary: { + validator: $.optional.nullable.str, + }, + + content: { + validator: $.arr($.obj()) + }, + + variables: { + validator: $.arr($.obj()) + }, + + eyeCatchingImageId: { + validator: $.optional.nullable.type(ID), + }, + + font: { + validator: $.optional.str.or(['serif', 'sans-serif']), + default: 'sans-serif' + }, + + alignCenter: { + validator: $.optional.bool, + default: false + }, + }, + + res: { + type: types.object, + optional: bool.false, nullable: bool.false, + ref: 'Page', + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'b7b97489-0f66-4b12-a5ff-b21bd63f6e1c' + }, + } +}; + +export default define(meta, async (ps, user) => { + let eyeCatchingImage = null; + if (ps.eyeCatchingImageId != null) { + eyeCatchingImage = await DriveFiles.findOne({ + id: ps.eyeCatchingImageId, + userId: user.id + }); + + if (eyeCatchingImage == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + const page = await Pages.save(new Page({ + id: genId(), + createdAt: new Date(), + updatedAt: new Date(), + title: ps.title, + name: ps.name, + summary: ps.summary, + content: ps.content, + variables: ps.variables, + eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null, + userId: user.id, + visibility: 'public', + alignCenter: ps.alignCenter, + font: ps.font + })); + + return await Pages.pack(page); +}); diff --git a/src/server/api/endpoints/pages/delete.ts b/src/server/api/endpoints/pages/delete.ts new file mode 100644 index 0000000000..043805aa33 --- /dev/null +++ b/src/server/api/endpoints/pages/delete.ts @@ -0,0 +1,53 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages } from '../../../../models'; +import { ID } from '../../../../misc/cafy-id'; + +export const meta = { + desc: { + 'ja-JP': '指定したページを削除します。', + }, + + tags: ['pages'], + + requireCredential: true, + + kind: 'write:pages', + + params: { + pageId: { + validator: $.type(ID), + desc: { + 'ja-JP': '対象のページのID', + 'en-US': 'Target page ID.' + } + }, + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: 'eb0c6e1d-d519-4764-9486-52a7e1c6392a' + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '8b741b3e-2c22-44b3-a15f-29949aa1601e' + }, + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + if (page.userId !== user.id) { + throw new ApiError(meta.errors.accessDenied); + } + + await Pages.delete(page.id); +}); diff --git a/src/server/api/endpoints/pages/show.ts b/src/server/api/endpoints/pages/show.ts new file mode 100644 index 0000000000..dd1dc9f255 --- /dev/null +++ b/src/server/api/endpoints/pages/show.ts @@ -0,0 +1,74 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages, Users } from '../../../../models'; +import { types, bool } from '../../../../misc/schema'; +import { ID } from '../../../../misc/cafy-id'; +import { Page } from '../../../../models/entities/page'; + +export const meta = { + desc: { + 'ja-JP': '指定したページの情報を取得します。', + }, + + tags: ['pages'], + + requireCredential: false, + + params: { + pageId: { + validator: $.optional.type(ID), + desc: { + 'ja-JP': '対象のページのID', + 'en-US': 'Target page ID.' + } + }, + + name: { + validator: $.optional.str, + }, + + username: { + validator: $.optional.str, + }, + }, + + res: { + type: types.object, + optional: bool.false, nullable: bool.false, + ref: 'Page', + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: '222120c0-3ead-4528-811b-b96f233388d7' + } + } +}; + +export default define(meta, async (ps, user) => { + let page: Page | undefined; + + if (ps.pageId) { + page = await Pages.findOne(ps.pageId); + } else if (ps.name && ps.username) { + const author = await Users.findOne({ + host: null, + usernameLower: ps.username.toLowerCase() + }); + if (author) { + page = await Pages.findOne({ + name: ps.name, + userId: author.id + }); + } + } + + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + return await Pages.pack(page); +}); diff --git a/src/server/api/endpoints/pages/update.ts b/src/server/api/endpoints/pages/update.ts new file mode 100644 index 0000000000..8ee34fc3ba --- /dev/null +++ b/src/server/api/endpoints/pages/update.ts @@ -0,0 +1,123 @@ +import $ from 'cafy'; +import * as ms from 'ms'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages, DriveFiles } from '../../../../models'; +import { ID } from '../../../../misc/cafy-id'; + +export const meta = { + desc: { + 'ja-JP': '指定したページの情報を更新します。', + }, + + tags: ['pages'], + + requireCredential: true, + + kind: 'write:pages', + + limit: { + duration: ms('1hour'), + max: 300 + }, + + params: { + pageId: { + validator: $.type(ID), + desc: { + 'ja-JP': '対象のページのID', + 'en-US': 'Target page ID.' + } + }, + + title: { + validator: $.str, + }, + + name: { + validator: $.optional.str, + }, + + summary: { + validator: $.optional.nullable.str, + }, + + content: { + validator: $.arr($.obj()) + }, + + variables: { + validator: $.arr($.obj()) + }, + + eyeCatchingImageId: { + validator: $.optional.nullable.type(ID), + }, + + font: { + validator: $.optional.str.or(['serif', 'sans-serif']), + }, + + alignCenter: { + validator: $.optional.bool, + }, + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: '21149b9e-3616-4778-9592-c4ce89f5a864' + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '3c15cd52-3b4b-4274-967d-6456fc4f792b' + }, + + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'cfc23c7c-3887-490e-af30-0ed576703c82' + }, + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + if (page.userId !== user.id) { + throw new ApiError(meta.errors.accessDenied); + } + + let eyeCatchingImage = null; + if (ps.eyeCatchingImageId != null) { + eyeCatchingImage = await DriveFiles.findOne({ + id: ps.eyeCatchingImageId, + userId: user.id + }); + + if (eyeCatchingImage == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + await Pages.update(page.id, { + updatedAt: new Date(), + title: ps.title, + name: ps.name === undefined ? page.name : ps.name, + summary: ps.name === undefined ? page.summary : ps.summary, + content: ps.content, + variables: ps.variables, + alignCenter: ps.alignCenter === undefined ? page.alignCenter : ps.alignCenter, + font: ps.font === undefined ? page.font : ps.font, + eyeCatchingImageId: ps.eyeCatchingImageId === null + ? null + : ps.eyeCatchingImageId === undefined + ? page.eyeCatchingImageId + : eyeCatchingImage!.id, + }); +}); diff --git a/src/server/web/index.ts b/src/server/web/index.ts index 1f87cd70f8..c5a3497f44 100644 --- a/src/server/web/index.ts +++ b/src/server/web/index.ts @@ -16,7 +16,7 @@ import { fetchMeta } from '../../misc/fetch-meta'; import * as pkg from '../../../package.json'; import { genOpenapiSpec } from '../api/openapi/gen-spec'; import config from '../../config'; -import { Users, Notes, Emojis, UserProfiles } from '../../models'; +import { Users, Notes, Emojis, UserProfiles, Pages } from '../../models'; import parseAcct from '../../misc/acct/parse'; import getNoteSummary from '../../misc/get-note-summary'; import { ensure } from '../../prelude/ensure'; @@ -203,6 +203,41 @@ router.get('/notes/:note', async ctx => { ctx.status = 404; }); + +// Page +router.get('/@:user/pages/:page', async ctx => { + const { username, host } = parseAcct(ctx.params.user); + const user = await Users.findOne({ + usernameLower: username.toLowerCase(), + host + }); + + if (user == null) return; + + const page = await Pages.findOne({ + name: ctx.params.page, + userId: user.id + }); + + if (page) { + const _page = await Pages.pack(page); + const meta = await fetchMeta(); + await ctx.render('page', { + page: _page, + instanceName: meta.name || 'Misskey' + }); + + if (['public'].includes(page.visibility)) { + ctx.set('Cache-Control', 'public, max-age=180'); + } else { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } + + return; + } + + ctx.status = 404; +}); //#endregion router.get('/info', async ctx => { diff --git a/src/server/web/views/note.pug b/src/server/web/views/note.pug index dd6dda2582..983c731a04 100644 --- a/src/server/web/views/note.pug +++ b/src/server/web/views/note.pug @@ -25,6 +25,7 @@ block meta meta(name='twitter:card' content='summary') + // todo if user.twitter meta(name='twitter:creator' content=`@${user.twitter.screenName}`) diff --git a/src/server/web/views/page.pug b/src/server/web/views/page.pug new file mode 100644 index 0000000000..55f64ff054 --- /dev/null +++ b/src/server/web/views/page.pug @@ -0,0 +1,30 @@ +extends ./base + +block vars + - const user = page.user; + - const title = page.title; + - const url = `${config.url}/@${user.username}/${page.name}`; + +block title + = `${title} | ${instanceName}` + +block desc + meta(name='description' content= page.summary) + +block og + meta(property='og:type' content='article') + meta(property='og:title' content= title) + meta(property='og:description' content= page.summary) + meta(property='og:url' content= url) + meta(property='og:image' content= page.eyeCatchingImage ? page.eyeCatchingImage.thumbnailUrl : user.avatarUrl) + +block meta + meta(name='misskey:user-username' content=user.username) + meta(name='misskey:user-id' content=user.id) + meta(name='misskey:page-id' content=page.id) + + meta(name='twitter:card' content='summary') + + // todo + if user.twitter + meta(name='twitter:creator' content=`@${user.twitter.screenName}`) |