* wip

* wip

* wip

* Update page-editor.vue

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Update page-editor.variable.core.vue

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Update aiscript.ts

* wip

* Update package.json

* wip

* wip

* wip

* wip

* wip

* Update page.vue

* wip

* wip

* wip

* wip

* more info

* wip fn

* wip

* wip

* wip
This commit is contained in:
syuilo
2019-04-29 09:11:57 +09:00
committed by GitHub
parent 747a0b1791
commit 05b8111c19
52 changed files with 3583 additions and 37 deletions

View File

@ -0,0 +1,470 @@
/**
* AiScript
* evaluator & type checker
*/
import autobind from 'autobind-decorator';
import * as seedrandom from 'seedrandom';
import {
faSuperscript,
faAlignLeft,
faShareAlt,
faSquareRootAlt,
faPlus,
faMinus,
faTimes,
faDivide,
faList,
faQuoteRight,
faEquals,
faGreaterThan,
faLessThan,
faGreaterThanEqual,
faLessThanEqual,
faExclamation,
faNotEqual,
faDice,
faSortNumericUp,
} from '@fortawesome/free-solid-svg-icons';
import { faFlag } from '@fortawesome/free-regular-svg-icons';
import { version } from '../../config';
export type Block = {
id: string;
type: string;
args: Block[];
value: any;
};
export type Variable = Block & {
name: string;
};
type Type = 'string' | 'number' | 'boolean' | 'stringArray';
type TypeError = {
arg: number;
expect: Type;
actual: Type;
};
const funcDefs = {
if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: faShareAlt, },
not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: faFlag, },
or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: faFlag, },
and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: faFlag, },
add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faPlus, },
subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faMinus, },
multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faTimes, },
divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faDivide, },
eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: faEquals, },
notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: faNotEqual, },
gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faGreaterThan, },
lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faLessThan, },
gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faGreaterThanEqual, },
ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faLessThanEqual, },
rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: faDice, },
random: { in: ['number'], out: 'boolean', category: 'random', icon: faDice, },
randomPick: { in: [0], out: 0, category: 'random', icon: faDice, },
dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: faDice, },
dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: faDice, },
dailyRandomPick: { in: [0], out: 0, category: 'random', icon: faDice, },
};
const blockDefs = [
{ type: 'text', out: 'string', category: 'value', icon: faQuoteRight, },
{ type: 'multiLineText', out: 'string', category: 'value', icon: faAlignLeft, },
{ type: 'textList', out: 'stringArray', category: 'value', icon: faList, },
{ type: 'number', out: 'number', category: 'value', icon: faSortNumericUp, },
{ type: 'ref', out: null, category: 'value', icon: faSuperscript, },
{ type: 'in', out: null, category: 'value', icon: faSuperscript, },
{ type: 'fn', out: 'function', category: 'value', icon: faSuperscript, },
...Object.entries(funcDefs).map(([k, v]) => ({
type: k, out: v.out || null, category: v.category, icon: v.icon
}))
];
type PageVar = { name: string; value: any; type: Type; };
const envVarsDef = {
AI: 'string',
VERSION: 'string',
LOGIN: 'boolean',
NAME: 'string',
USERNAME: 'string',
USERID: 'string',
NOTES_COUNT: 'number',
FOLLOWERS_COUNT: 'number',
FOLLOWING_COUNT: 'number',
IS_CAT: 'boolean',
MY_NOTES_COUNT: 'number',
MY_FOLLOWERS_COUNT: 'number',
MY_FOLLOWING_COUNT: 'number',
};
export class AiScript {
private variables: Variable[];
private pageVars: PageVar[];
private envVars: Record<keyof typeof envVarsDef, any>;
public static envVarsDef = envVarsDef;
public static blockDefs = blockDefs;
public static funcDefs = funcDefs;
private opts: {
randomSeed?: string; user?: any; visitor?: any;
};
constructor(variables: Variable[] = [], pageVars: PageVar[] = [], opts: AiScript['opts'] = {}) {
this.variables = variables;
this.pageVars = pageVars;
this.opts = opts;
this.envVars = {
AI: 'kawaii',
VERSION: version,
LOGIN: opts.visitor != null,
NAME: opts.visitor ? opts.visitor.name : '',
USERNAME: opts.visitor ? opts.visitor.username : '',
USERID: opts.visitor ? opts.visitor.id : '',
NOTES_COUNT: opts.visitor ? opts.visitor.notesCount : 0,
FOLLOWERS_COUNT: opts.visitor ? opts.visitor.followersCount : 0,
FOLLOWING_COUNT: opts.visitor ? opts.visitor.followingCount : 0,
IS_CAT: opts.visitor ? opts.visitor.isCat : false,
MY_NOTES_COUNT: opts.user ? opts.user.notesCount : 0,
MY_FOLLOWERS_COUNT: opts.user ? opts.user.followersCount : 0,
MY_FOLLOWING_COUNT: opts.user ? opts.user.followingCount : 0,
};
}
@autobind
public injectVars(vars: Variable[]) {
this.variables = vars;
}
@autobind
public injectPageVars(pageVars: PageVar[]) {
this.pageVars = pageVars;
}
@autobind
public updatePageVar(name: string, value: any) {
this.pageVars.find(v => v.name === name).value = value;
}
@autobind
public updateRandomSeed(seed: string) {
this.opts.randomSeed = seed;
}
@autobind
public static isLiteralBlock(v: Block) {
if (v.type === null) return true;
if (v.type === 'text') return true;
if (v.type === 'multiLineText') return true;
if (v.type === 'textList') return true;
if (v.type === 'number') return true;
if (v.type === 'ref') return true;
if (v.type === 'fn') return true;
if (v.type === 'in') return true;
return false;
}
@autobind
public typeCheck(v: Block): TypeError | null {
if (AiScript.isLiteralBlock(v)) return null;
const def = AiScript.funcDefs[v.type];
if (def == null) {
throw new Error('Unknown type: ' + v.type);
}
const generic: Type[] = [];
for (let i = 0; i < def.in.length; i++) {
const arg = def.in[i];
const type = this.typeInference(v.args[i]);
if (type === null) continue;
if (typeof arg === 'number') {
if (generic[arg] === undefined) {
generic[arg] = type;
} else if (type !== generic[arg]) {
return {
arg: i,
expect: generic[arg],
actual: type
};
}
} else if (type !== arg) {
return {
arg: i,
expect: arg,
actual: type
};
}
}
return null;
}
@autobind
public getExpectedType(v: Block, slot: number): Type | null {
const def = AiScript.funcDefs[v.type];
if (def == null) {
throw new Error('Unknown type: ' + v.type);
}
const generic: Type[] = [];
for (let i = 0; i < def.in.length; i++) {
const arg = def.in[i];
const type = this.typeInference(v.args[i]);
if (type === null) continue;
if (typeof arg === 'number') {
if (generic[arg] === undefined) {
generic[arg] = type;
}
}
}
if (typeof def.in[slot] === 'number') {
return generic[def.in[slot]] || null;
} else {
return def.in[slot];
}
}
@autobind
public typeInference(v: Block): Type | null {
if (v.type === null) return null;
if (v.type === 'text') return 'string';
if (v.type === 'multiLineText') return 'string';
if (v.type === 'textList') return 'stringArray';
if (v.type === 'number') return 'number';
if (v.type === 'ref') {
const variable = this.variables.find(va => va.name === v.value);
if (variable) {
return this.typeInference(variable);
}
const pageVar = this.pageVars.find(va => va.name === v.value);
if (pageVar) {
return pageVar.type;
}
const envVar = AiScript.envVarsDef[v.value];
if (envVar) {
return envVar;
}
return null;
}
if (v.type === 'fn') return null; // todo
if (v.type === 'in') return null; // todo
const generic: Type[] = [];
const def = AiScript.funcDefs[v.type];
for (let i = 0; i < def.in.length; i++) {
const arg = def.in[i];
if (typeof arg === 'number') {
const type = this.typeInference(v.args[i]);
if (generic[arg] === undefined) {
generic[arg] = type;
} else {
if (type !== generic[arg]) {
generic[arg] = null;
}
}
}
}
if (typeof def.out === 'number') {
return generic[def.out];
} else {
return def.out;
}
}
@autobind
public getVarsByType(type: Type | null): Variable[] {
if (type == null) return this.variables;
return this.variables.filter(x => (this.typeInference(x) === null) || (this.typeInference(x) === type));
}
@autobind
public getVarByName(name: string): Variable {
return this.variables.find(x => x.name === name);
}
@autobind
public getEnvVarsByType(type: Type | null): string[] {
if (type == null) return Object.keys(AiScript.envVarsDef);
return Object.entries(AiScript.envVarsDef).filter(([k, v]) => type === v).map(([k, v]) => k);
}
@autobind
public getPageVarsByType(type: Type | null): string[] {
if (type == null) return this.pageVars.map(v => v.name);
return this.pageVars.filter(v => type === v.type).map(v => v.name);
}
@autobind
private interpolate(str: string, values: { name: string, value: any }[]) {
return str.replace(/\{(.+?)\}/g, match =>
(this.getVariableValue(match.slice(1, -1).trim(), values) || '').toString());
}
@autobind
public evaluateVars() {
const values: { name: string, value: any }[] = [];
for (const v of this.variables) {
values.push({
name: v.name,
value: this.evaluate(v, values)
});
}
for (const v of this.pageVars) {
values.push({
name: v.name,
value: v.value
});
}
for (const [k, v] of Object.entries(this.envVars)) {
values.push({
name: k,
value: v
});
}
return values;
}
@autobind
private evaluate(block: Block, values: { name: string, value: any }[], slotArg: Record<string, any> = {}): any {
if (block.type === null) {
return null;
}
if (block.type === 'number') {
return parseInt(block.value, 10);
}
if (block.type === 'text' || block.type === 'multiLineText') {
return this.interpolate(block.value, values);
}
if (block.type === 'textList') {
return block.value.trim().split('\n');
}
if (block.type === 'ref') {
return this.getVariableValue(block.value, values);
}
if (block.type === 'in') {
return slotArg[block.value];
}
if (block.type === 'fn') { // ユーザー関数定義
return {
slots: block.value.slots,
exec: slotArg => this.evaluate(block.value.expression, values, slotArg)
};
}
if (block.type.startsWith('fn:')) { // ユーザー関数呼び出し
const fnName = block.type.split(':')[1];
const fn = this.getVariableValue(fnName, values);
for (let i = 0; i < fn.slots.length; i++) {
const name = fn.slots[i];
slotArg[name] = this.evaluate(block.args[i], values);
}
return fn.exec(slotArg);
}
if (block.args === undefined) return null;
const date = new Date();
const day = `${this.opts.visitor ? this.opts.visitor.id : ''} ${date.getFullYear()}/${date.getMonth()}/${date.getDate()}`;
const funcs: { [p in keyof typeof funcDefs]: any } = {
not: (a) => !a,
eq: (a, b) => a === b,
notEq: (a, b) => a !== b,
gt: (a, b) => a > b,
lt: (a, b) => a < b,
gtEq: (a, b) => a >= b,
ltEq: (a, b) => a <= b,
or: (a, b) => a || b,
and: (a, b) => a && b,
if: (bool, a, b) => bool ? a : b,
add: (a, b) => a + b,
subtract: (a, b) => a - b,
multiply: (a, b) => a * b,
divide: (a, b) => a / b,
random: (probability) => Math.floor(seedrandom(`${this.opts.randomSeed}:${block.id}`)() * 100) < probability,
rannum: (min, max) => min + Math.floor(seedrandom(`${this.opts.randomSeed}:${block.id}`)() * (max - min + 1)),
randomPick: (list) => list[Math.floor(seedrandom(`${this.opts.randomSeed}:${block.id}`)() * list.length)],
dailyRandom: (probability) => Math.floor(seedrandom(`${day}:${block.id}`)() * 100) < probability,
dailyRannum: (min, max) => min + Math.floor(seedrandom(`${day}:${block.id}`)() * (max - min + 1)),
dailyRandomPick: (list) => list[Math.floor(seedrandom(`${day}:${block.id}`)() * list.length)],
};
const fnName = block.type;
const fn = funcs[fnName];
if (fn == null) {
console.error('Unknown function: ' + fnName);
throw new Error('Unknown function: ' + fnName);
}
const args = block.args.map(x => this.evaluate(x, values, slotArg));
return fn(...args);
}
@autobind
private getVariableValue(name: string, values: { name: string, value: any }[]): any {
const v = values.find(v => v.name === name);
if (v) {
return v.value;
}
const pageVar = this.pageVars.find(v => v.name === name);
if (pageVar) {
return pageVar.value;
}
if (AiScript.envVarsDef[name]) {
return this.envVars[name].value;
}
throw new Error(`Script: No such variable '${name}'`);
}
@autobind
public isUsedName(name: string) {
if (this.variables.some(v => v.name === name)) {
return true;
}
if (this.pageVars.some(v => v.name === name)) {
return true;
}
if (AiScript.envVarsDef[name]) {
return true;
}
return false;
}
}

View File

@ -0,0 +1,24 @@
export function collectPageVars(content) {
const pageVars = [];
const collect = (xs: any[]) => {
for (const x of xs) {
if (x.type === 'input') {
pageVars.push({
name: x.name,
type: x.inputType,
value: x.default
});
} else if (x.type === 'switch') {
pageVars.push({
name: x.name,
type: 'boolean',
value: x.default
});
} else if (x.children) {
collect(x.children);
}
}
};
collect(content);
return pageVars;
}

View File

@ -22,7 +22,14 @@
<ui-input v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></ui-input>
<ui-input v-if="user" v-model="userInputValue" autofocus @keydown="onInputKeydown"><template #prefix>@</template></ui-input>
<ui-select v-if="select" v-model="selectedValue" autofocus>
<option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
<template v-if="select.items">
<option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
</template>
<template v-else>
<optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label">
<option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option>
</optgroup>
</template>
</ui-select>
<ui-horizon-group no-grow class="buttons fit-bottom" v-if="!splash && (showOkButton || showCancelButton)">
<ui-button @click="ok" v-if="showOkButton" primary :autofocus="!input && !select && !user">{{ (showCancelButton || input || select || user) ? $t('@.ok') : $t('@.got-it') }}</ui-button>
@ -230,7 +237,7 @@ export default Vue.extend({
font-size 32px
&.success
color #37ec92
color #85da5a
&.error
color #ec4137

View File

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

View File

@ -0,0 +1,25 @@
<template>
<component :is="'x-' + value.type" :value="value" @input="v => updateItem(v)" @remove="() => $emit('remove', value)" :key="value.id"/>
</template>
<script lang="ts">
import Vue from 'vue';
import XSection from './page-editor.section.vue';
import XText from './page-editor.text.vue';
import XImage from './page-editor.image.vue';
import XButton from './page-editor.button.vue';
import XInput from './page-editor.input.vue';
import XSwitch from './page-editor.switch.vue';
export default Vue.extend({
components: {
XSection, XText, XImage, XButton, XInput, XSwitch
},
props: {
value: {
required: true
}
},
});
</script>

View File

@ -0,0 +1,54 @@
<template>
<x-container @remove="() => $emit('remove')">
<template #header><fa :icon="faBolt"/> {{ $t('blocks.button') }}</template>
<section class="xfhsjczc">
<ui-input v-model="value.text"><span>{{ $t('blocks._button.text') }}</span></ui-input>
<ui-select v-model="value.action">
<template #label>{{ $t('blocks._button.action') }}</template>
<option value="dialog">{{ $t('blocks._button._action.dialog') }}</option>
<option value="resetRandom">{{ $t('blocks._button._action.resetRandom') }}</option>
</ui-select>
<ui-input v-if="value.action === 'dialog'" v-model="value.content"><span>{{ $t('blocks._button._action._dialog.content') }}</span></ui-input>
</section>
</x-container>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import { faBolt } from '@fortawesome/free-solid-svg-icons';
import XContainer from './page-editor.container.vue';
export default Vue.extend({
i18n: i18n('pages'),
components: {
XContainer
},
props: {
value: {
required: true
},
},
data() {
return {
faBolt
};
},
created() {
if (this.value.text == null) Vue.set(this.value, 'text', '');
if (this.value.action == null) Vue.set(this.value, 'action', 'dialog');
if (this.value.content == null) Vue.set(this.value, 'content', null);
},
});
</script>
<style lang="stylus" scoped>
.xfhsjczc
padding 0 16px 0 16px
</style>

View File

@ -0,0 +1,135 @@
<template>
<div class="cpjygsrt" :class="{ error: error != null, warn: warn != null }">
<header>
<div class="title"><slot name="header"></slot></div>
<div class="buttons">
<slot name="func"></slot>
<button v-if="removable" @click="remove()">
<fa :icon="faTrashAlt"/>
</button>
<button @click="toggleContent(!showBody)">
<template v-if="showBody"><fa icon="angle-up"/></template>
<template v-else><fa icon="angle-down"/></template>
</button>
</div>
</header>
<p v-show="showBody" class="error" v-if="error != null">{{ $t('script.typeError', { slot: error.arg + 1, expect: $t(`script.types.${error.expect}`), actual: $t(`script.types.${error.actual}`) }) }}</p>
<p v-show="showBody" class="warn" v-if="warn != null">{{ $t('script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p>
<div v-show="showBody">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faTrashAlt } from '@fortawesome/free-regular-svg-icons';
import i18n from '../../../../i18n';
export default Vue.extend({
i18n: i18n('pages'),
props: {
expanded: {
type: Boolean,
default: true
},
removable: {
type: Boolean,
default: true
},
error: {
required: false,
default: null
},
warn: {
required: false,
default: null
}
},
data() {
return {
showBody: this.expanded,
faTrashAlt
};
},
methods: {
toggleContent(show: boolean) {
this.showBody = show;
this.$emit('toggle', show);
},
remove() {
this.$emit('remove');
}
}
});
</script>
<style lang="stylus" scoped>
.cpjygsrt
overflow hidden
background var(--face)
border solid 2px var(--pageBlockBorder)
border-radius 6px
&:hover
border solid 2px var(--pageBlockBorderHover)
&.warn
border solid 2px #dec44c
&.error
border solid 2px #f00
& + .cpjygsrt
margin-top 16px
> header
> .title
z-index 1
margin 0
padding 0 16px
line-height 42px
font-size 0.9em
font-weight bold
color var(--faceHeaderText)
box-shadow 0 1px rgba(#000, 0.07)
> [data-icon]
margin-right 6px
&:empty
display none
> .buttons
position absolute
z-index 2
top 0
right 0
> button
padding 0
width 42px
font-size 0.9em
line-height 42px
color var(--faceTextButton)
&:hover
color var(--faceTextButtonHover)
&:active
color var(--faceTextButtonActive)
> .warn
color #b19e49
margin 0
padding 16px 16px 0 16px
font-size 14px
> .error
color #f00
margin 0
padding 16px 16px 0 16px
font-size 14px
</style>

View File

@ -0,0 +1,78 @@
<template>
<x-container @remove="() => $emit('remove')">
<template #header><fa :icon="faImage"/> {{ $t('blocks.image') }}</template>
<template #func>
<button @click="choose()">
<fa :icon="faFolderOpen"/>
</button>
</template>
<section class="oyyftmcf">
<x-file-thumbnail class="preview" v-if="file" :file="file" :detail="true" fit="contain" @click="choose()"/>
</section>
</x-container>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import { faImage, faFolderOpen } from '@fortawesome/free-regular-svg-icons';
import XContainer from './page-editor.container.vue';
import XFileThumbnail from '../drive-file-thumbnail.vue';
export default Vue.extend({
i18n: i18n('pages'),
components: {
XContainer, XFileThumbnail
},
props: {
value: {
required: true
},
},
data() {
return {
file: null,
faPencilAlt, faImage, faFolderOpen
};
},
created() {
if (this.value.fileId === undefined) Vue.set(this.value, 'fileId', null);
},
mounted() {
if (this.value.fileId == null) {
this.choose();
} else {
this.$root.api('drive/files/show', {
fileId: this.value.fileId
}).then(file => {
this.file = file;
});
}
},
methods: {
async choose() {
this.$chooseDriveFile({
multiple: false
}).then(file => {
this.file = file;
this.value.fileId = file.id;
});
},
}
});
</script>
<style lang="stylus" scoped>
.oyyftmcf
> .preview
height 150px
</style>

View File

@ -0,0 +1,54 @@
<template>
<x-container @remove="() => $emit('remove')">
<template #header><fa :icon="faBolt"/> {{ $t('blocks.input') }}</template>
<section class="dnvasjon">
<ui-input v-model="value.name"><template #prefix><fa :icon="faSquareRootAlt"/></template><span>{{ $t('blocks._input.name') }}</span></ui-input>
<ui-input v-model="value.text"><span>{{ $t('blocks._input.text') }}</span></ui-input>
<ui-select v-model="value.inputType">
<template #label>{{ $t('blocks._input.inputType') }}</template>
<option value="string">{{ $t('blocks._input._inputType.string') }}</option>
<option value="number">{{ $t('blocks._input._inputType.number') }}</option>
</ui-select>
<ui-input v-model="value.default" :type="value.inputType"><span>{{ $t('blocks._input.default') }}</span></ui-input>
</section>
</x-container>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import { faBolt, faSquareRootAlt } from '@fortawesome/free-solid-svg-icons';
import XContainer from './page-editor.container.vue';
export default Vue.extend({
i18n: i18n('pages'),
components: {
XContainer
},
props: {
value: {
required: true
},
},
data() {
return {
faBolt, faSquareRootAlt
};
},
created() {
if (this.value.name == null) Vue.set(this.value, 'name', '');
if (this.value.inputType == null) Vue.set(this.value, 'inputType', 'string');
},
});
</script>
<style lang="stylus" scoped>
.dnvasjon
padding 0 16px 0 16px
</style>

View File

@ -0,0 +1,263 @@
<template>
<x-container :removable="removable" @remove="() => $emit('remove')" :error="error" :warn="warn">
<template #header><fa v-if="icon" :icon="icon"/> <template v-if="title">{{ title }} <span class="turmquns" v-if="typeText">({{ typeText }})</span></template><template v-else-if="typeText">{{ typeText }}</template></template>
<template #func>
<button @click="changeType()">
<fa :icon="faPencilAlt"/>
</button>
</template>
<section v-if="value.type === null" class="pbglfege" @click="changeType()">
{{ $t('script.emptySlot') }}
</section>
<section v-else-if="value.type === 'text'" class="tbwccoaw">
<input v-model="value.value"/>
</section>
<section v-else-if="value.type === 'multiLineText'" class="tbwccoaw">
<textarea v-model="value.value"></textarea>
</section>
<section v-else-if="value.type === 'textList'" class="frvuzvoi">
<ui-textarea v-model="value.value"></ui-textarea>
</section>
<section v-else-if="value.type === 'number'" class="tbwccoaw">
<input v-model="value.value" type="number"/>
</section>
<section v-else-if="value.type === 'ref'" class="hpdwcrvs">
<select v-model="value.value">
<option v-for="v in aiScript.getVarsByType(getExpectedType ? getExpectedType() : null).filter(x => x.name !== name)" :value="v.name">{{ v.name }}</option>
<optgroup :label="$t('script.pageVariables')">
<option v-for="v in aiScript.getPageVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option>
</optgroup>
<optgroup :label="$t('script.enviromentVariables')">
<option v-for="v in aiScript.getEnvVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option>
</optgroup>
</select>
</section>
<section v-else-if="value.type === 'in'" class="hpdwcrvs">
<select v-model="value.value">
<option v-for="v in fnSlots" :value="v">{{ v }}</option>
</select>
</section>
<section v-else-if="value.type === 'fn'" class="" style="padding:16px;">
<ui-textarea v-model="slots"></ui-textarea>
<x-v v-if="value.value.expression" v-model="value.value.expression" :title="$t(`script.blocks._fn.arg1`)" :get-expected-type="() => null" :ai-script="aiScript" :fn-slots="value.value.slots" :name="name"/>
</section>
<section v-else-if="value.type.startsWith('fn:')" class="" style="padding:16px;">
<x-v v-for="(x, i) in value.args" v-model="value.args[i]" :title="aiScript.getVarByName(value.type.split(':')[1]).value.slots[i]" :get-expected-type="() => null" :ai-script="aiScript" :name="name" :key="i"/>
</section>
<section v-else class="" style="padding:16px;">
<x-v v-for="(x, i) in value.args" v-model="value.args[i]" :title="$t(`script.blocks._${value.type}.arg${i + 1}`)" :get-expected-type="() => _getExpectedType(i)" :ai-script="aiScript" :name="name" :fn-slots="fnSlots" :key="i"/>
</section>
</x-container>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import XContainer from './page-editor.container.vue';
import { faSuperscript, faPencilAlt, faSquareRootAlt } from '@fortawesome/free-solid-svg-icons';
import { AiScript } from '../../../scripts/aiscript';
import * as uuid from 'uuid';
export default Vue.extend({
i18n: i18n('pages'),
components: {
XContainer
},
inject: ['getScriptBlockList'],
props: {
getExpectedType: {
required: false,
default: null
},
value: {
required: true
},
title: {
required: false
},
removable: {
required: false,
default: false
},
aiScript: {
required: true,
},
name: {
required: true,
},
fnSlots: {
required: false,
},
},
data() {
return {
AiScript,
error: null,
warn: null,
slots: '',
faSuperscript, faPencilAlt, faSquareRootAlt
};
},
computed: {
icon(): any {
if (this.value.type === null) return null;
if (this.value.type.startsWith('fn:')) return null;
return AiScript.blockDefs.find(x => x.type === this.value.type).icon;
},
typeText(): any {
if (this.value.type === null) return null;
return this.$t(`script.blocks.${this.value.type}`);
},
},
watch: {
slots() {
this.value.value.slots = this.slots.split('\n');
}
},
beforeCreate() {
this.$options.components.XV = require('./page-editor.script-block.vue').default;
},
created() {
if (this.value.value == null) Vue.set(this.value, 'value', null);
if (this.value.value && this.value.value.slots) this.slots = this.value.value.slots.join('\n');
this.$watch('value.type', (t) => {
this.warn = null;
if (this.value.type === 'fn') {
const id = uuid.v4();
this.value.value = {};
Vue.set(this.value.value, 'slots', []);
Vue.set(this.value.value, 'expression', { id, type: null });
return;
}
if (this.value.type && this.value.type.startsWith('fn:')) {
const fnName = this.value.type.split(':')[1];
const fn = this.aiScript.getVarByName(fnName);
const empties = [];
for (let i = 0; i < fn.value.slots.length; i++) {
const id = uuid.v4();
empties.push({ id, type: null });
}
Vue.set(this.value, 'args', empties);
return;
}
if (AiScript.isLiteralBlock(this.value)) return;
const empties = [];
for (let i = 0; i < AiScript.funcDefs[this.value.type].in.length; i++) {
const id = uuid.v4();
empties.push({ id, type: null });
}
Vue.set(this.value, 'args', empties);
for (let i = 0; i < AiScript.funcDefs[this.value.type].in.length; i++) {
const inType = AiScript.funcDefs[this.value.type].in[i];
if (typeof inType !== 'number') {
if (inType === 'number') this.value.args[i].type = 'number';
if (inType === 'string') this.value.args[i].type = 'text';
}
}
});
this.$watch('value.args', (args) => {
if (args == null) {
this.warn = null;
return;
}
const emptySlotIndex = args.findIndex(x => x.type === null);
if (emptySlotIndex !== -1 && emptySlotIndex < args.length) {
this.warn = {
slot: emptySlotIndex
};
} else {
this.warn = null;
}
}, {
deep: true
});
this.$watch('aiScript.variables', () => {
if (this.type != null && this.value) {
this.error = this.aiScript.typeCheck(this.value);
}
}, {
deep: true
});
},
methods: {
async changeType() {
const { canceled, result: type } = await this.$root.dialog({
type: null,
title: this.$t('select-type'),
select: {
groupedItems: this.getScriptBlockList(this.getExpectedType ? this.getExpectedType() : null)
},
showCancelButton: true
});
if (canceled) return;
this.value.type = type;
},
_getExpectedType(slot: number) {
return this.aiScript.getExpectedType(this.value, slot);
}
}
});
</script>
<style lang="stylus" scoped>
.turmquns
opacity 0.7
.pbglfege
opacity 0.5
padding 16px
text-align center
cursor pointer
color var(--text)
.tbwccoaw
> input
> textarea
display block
-webkit-appearance none
-moz-appearance none
appearance none
width 100%
max-width 100%
min-width 100%
border none
box-shadow none
padding 16px
font-size 16px
background transparent
color var(--text)
> textarea
min-height 100px
.hpdwcrvs
padding 16px
> select
display block
padding 4px
font-size 16px
width 100%
</style>

View File

@ -0,0 +1,133 @@
<template>
<x-container @remove="() => $emit('remove')">
<template #header><fa :icon="faStickyNote"/> {{ value.title }}</template>
<template #func>
<button @click="rename()">
<fa :icon="faPencilAlt"/>
</button>
<button @click="add()">
<fa :icon="faPlus"/>
</button>
</template>
<section class="ilrvjyvi">
<div class="children">
<x-block v-for="child in value.children" :value="child" @input="v => updateItem(v)" @remove="() => remove(child)" :key="child.id"/>
</div>
</section>
</x-container>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import { faPlus, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
import XContainer from './page-editor.container.vue';
import * as uuid from 'uuid';
export default Vue.extend({
i18n: i18n('pages'),
components: {
XContainer
},
props: {
value: {
required: true
},
},
data() {
return {
faStickyNote, faPlus, faPencilAlt
};
},
beforeCreate() {
this.$options.components.XBlock = require('./page-editor.block.vue').default
},
created() {
if (this.value.title == null) Vue.set(this.value, 'title', null);
if (this.value.children == null) Vue.set(this.value, 'children', []);
},
mounted() {
if (this.value.title == null) {
this.rename();
}
},
methods: {
async rename() {
const { canceled, result: title } = await this.$root.dialog({
title: 'Enter title',
input: {
type: 'text',
default: this.value.title
},
showCancelButton: true
});
if (canceled) return;
this.value.title = title;
},
async add() {
const { canceled, result: type } = await this.$root.dialog({
type: null,
title: this.$t('choose-block'),
select: {
items: [{
value: 'section', text: this.$t('blocks.section')
}, {
value: 'text', text: this.$t('blocks.text')
}, {
value: 'image', text: this.$t('blocks.image')
}, {
value: 'button', text: this.$t('blocks.button')
}, {
value: 'input', text: this.$t('blocks.input')
}, {
value: 'switch', text: this.$t('blocks.switch')
}]
},
showCancelButton: true
});
if (canceled) return;
const id = uuid.v4();
this.value.children.push({ id, type });
},
updateItem(v) {
const i = this.value.children.findIndex(x => x.id === v.id);
const newValue = [
...this.value.children.slice(0, i),
v,
...this.value.children.slice(i + 1)
];
this.value.children = newValue;
this.$emit('input', this.value);
},
remove(el) {
const i = this.value.children.findIndex(x => x.id === el.id);
const newValue = [
...this.value.children.slice(0, i),
...this.value.children.slice(i + 1)
];
this.value.children = newValue;
this.$emit('input', this.value);
}
}
});
</script>
<style lang="stylus" scoped>
.ilrvjyvi
> .children
padding 16px
</style>

View File

@ -0,0 +1,48 @@
<template>
<x-container @remove="() => $emit('remove')">
<template #header><fa :icon="faBolt"/> {{ $t('blocks.switch') }}</template>
<section class="kjuadyyj">
<ui-input v-model="value.name"><template #prefix><fa :icon="faSquareRootAlt"/></template><span>{{ $t('blocks._switch.name') }}</span></ui-input>
<ui-input v-model="value.text"><span>{{ $t('blocks._switch.text') }}</span></ui-input>
<ui-switch v-model="value.default"><span>{{ $t('blocks._switch.default') }}</span></ui-switch>
</section>
</x-container>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import { faBolt, faSquareRootAlt } from '@fortawesome/free-solid-svg-icons';
import XContainer from './page-editor.container.vue';
export default Vue.extend({
i18n: i18n('pages'),
components: {
XContainer
},
props: {
value: {
required: true
},
},
data() {
return {
faBolt, faSquareRootAlt
};
},
created() {
if (this.value.name == null) Vue.set(this.value, 'name', '');
},
});
</script>
<style lang="stylus" scoped>
.kjuadyyj
padding 0 16px 16px 16px
</style>

View File

@ -0,0 +1,57 @@
<template>
<x-container @remove="() => $emit('remove')">
<template #header><fa :icon="faAlignLeft"/> {{ $t('blocks.text') }}</template>
<section class="ihymsbbe">
<textarea v-model="value.text"></textarea>
</section>
</x-container>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import { faAlignLeft } from '@fortawesome/free-solid-svg-icons';
import XContainer from './page-editor.container.vue';
export default Vue.extend({
i18n: i18n('pages'),
components: {
XContainer
},
props: {
value: {
required: true
},
},
data() {
return {
faAlignLeft,
};
},
created() {
if (this.value.text == null) Vue.set(this.value, 'text', '');
},
});
</script>
<style lang="stylus" scoped>
.ihymsbbe
> textarea
display block
-webkit-appearance none
-moz-appearance none
appearance none
width 100%
min-width 100%
min-height 150px
border none
box-shadow none
padding 16px
background transparent
color var(--text)
</style>

View File

@ -0,0 +1,452 @@
<template>
<div>
<div class="gwbmwxkm" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }">
<header>
<div class="title"><fa :icon="faStickyNote"/> {{ pageId ? $t('edit-page') : $t('new-page') }}</div>
<div class="buttons">
<button @click="del()"><fa :icon="faTrashAlt"/></button>
<button @click="() => showOptions = !showOptions"><fa :icon="faCog"/></button>
<button @click="save()"><fa :icon="faSave"/></button>
</div>
</header>
<section>
<ui-input v-model="title">
<span>{{ $t('title') }}</span>
</ui-input>
<template v-if="showOptions">
<ui-input v-model="summary">
<span>{{ $t('summary') }}</span>
</ui-input>
<ui-input v-model="name">
<template #prefix>{{ url }}/@{{ $store.state.i.username }}/pages/</template>
<span>{{ $t('url') }}</span>
</ui-input>
<ui-switch v-model="alignCenter">{{ $t('align-center') }}</ui-switch>
<ui-select v-model="font">
<template #label>{{ $t('font') }}</template>
<option value="serif">{{ $t('fontSerif') }}</option>
<option value="sans-serif">{{ $t('fontSansSerif') }}</option>
</ui-select>
<div class="eyeCatch">
<ui-button v-if="eyeCatchingImageId == null" @click="setEyeCatchingImage()"><fa :icon="faPlus"/> {{ $t('set-eye-catchig-image') }}</ui-button>
<div v-else-if="eyeCatchingImage">
<img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name"/>
<ui-button @click="removeEyeCatchingImage()"><fa :icon="faTrashAlt"/> {{ $t('remove-eye-catchig-image') }}</ui-button>
</div>
</div>
</template>
<div class="content" v-for="child in content">
<x-block :value="child" @input="v => updateItem(v)" @remove="() => remove(child)" :key="child.id"/>
</div>
<ui-button @click="add()"><fa :icon="faPlus"/></ui-button>
</section>
</div>
<ui-container :body-togglable="true">
<template #header><fa :icon="faSquareRootAlt"/> {{ $t('variables') }}</template>
<div class="qmuvgica">
<div class="variables" v-show="variables.length > 0">
<template v-for="variable in variables">
<x-variable
:value="variable"
:removable="true"
@input="v => updateVariable(v)"
@remove="() => removeVariable(variable)"
:key="variable.name"
:ai-script="aiScript"
:name="variable.name"
:title="variable.name"
/>
</template>
</div>
<ui-button @click="addVariable()" class="add"><fa :icon="faPlus"/></ui-button>
<ui-info><span v-html="$t('variables-info')"></span><a @click="() => moreDetails = true" style="display:block;">{{ $t('more-details') }}</a></ui-info>
<template v-if="moreDetails">
<ui-info><span v-html="$t('variables-info2')"></span></ui-info>
<ui-info><span v-html="$t('variables-info3')"></span></ui-info>
</template>
</div>
</ui-container>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import { faICursor, faPlus, faSquareRootAlt, faCog } from '@fortawesome/free-solid-svg-icons';
import { faSave, faStickyNote, faTrashAlt } from '@fortawesome/free-regular-svg-icons';
import XVariable from './page-editor.script-block.vue';
import XBlock from './page-editor.block.vue';
import * as uuid from 'uuid';
import { AiScript } from '../../../scripts/aiscript';
import { url } from '../../../../config';
import { collectPageVars } from '../../../scripts/collect-page-vars';
export default Vue.extend({
i18n: i18n('pages'),
components: {
XVariable, XBlock
},
props: {
page: {
type: String,
required: false
}
},
data() {
return {
pageId: null,
title: '',
summary: null,
name: Date.now().toString(),
eyeCatchingImage: null,
eyeCatchingImageId: null,
font: 'sans-serif',
content: [],
alignCenter: false,
variables: [],
aiScript: null,
showOptions: false,
moreDetails: false,
url,
faPlus, faICursor, faSave, faStickyNote, faSquareRootAlt, faCog, faTrashAlt
};
},
watch: {
async eyeCatchingImageId() {
if (this.eyeCatchingImageId == null) {
this.eyeCatchingImage = null;
} else {
this.eyeCatchingImage = await this.$root.api('drive/files/show', {
fileId: this.eyeCatchingImageId,
});
}
},
},
created() {
this.aiScript = new AiScript();
this.$watch('variables', () => {
this.aiScript.injectVars(this.variables);
}, { deep: true });
this.$watch('content', () => {
this.aiScript.injectPageVars(collectPageVars(this.content));
}, { deep: true });
if (this.page) {
this.$root.api('pages/show', {
pageId: this.page,
}).then(page => {
this.pageId = page.id;
this.title = page.title;
this.name = page.name;
this.summary = page.summary;
this.font = page.font;
this.alignCenter = page.alignCenter;
this.content = page.content;
this.variables = page.variables;
this.eyeCatchingImageId = page.eyeCatchingImageId;
});
} else {
const id = uuid.v4();
this.content = [{
id,
type: 'text',
text: 'Hello World!'
}];
}
},
provide() {
return {
getScriptBlockList: this.getScriptBlockList
}
},
methods: {
save() {
if (this.pageId) {
this.$root.api('pages/update', {
pageId: this.pageId,
title: this.title.trim(),
name: this.name.trim(),
summary: this.summary,
font: this.font,
alignCenter: this.alignCenter,
content: this.content,
variables: this.variables,
eyeCatchingImageId: this.eyeCatchingImageId,
}).then(page => {
this.$root.dialog({
type: 'success',
text: this.$t('page-updated')
});
});
} else {
this.$root.api('pages/create', {
title: this.title.trim(),
name: this.name.trim(),
summary: this.summary,
font: this.font,
alignCenter: this.alignCenter,
content: this.content,
variables: this.variables,
eyeCatchingImageId: this.eyeCatchingImageId,
}).then(page => {
this.pageId = page.id;
this.$root.dialog({
type: 'success',
text: this.$t('page-created')
});
this.$router.push(`/i/pages/edit/${this.pageId}`);
});
}
},
del() {
this.$root.dialog({
type: 'warning',
text: this.$t('are-you-sure-delete'),
showCancelButton: true
}).then(({ canceled }) => {
if (canceled) return;
this.$root.api('pages/delete', {
pageId: this.pageId,
}).then(() => {
this.$root.dialog({
type: 'success',
text: this.$t('page-deleted')
});
this.$router.push(`/i/pages`);
});
});
},
async add() {
const { canceled, result: type } = await this.$root.dialog({
type: null,
title: this.$t('choose-block'),
select: {
items: [{
value: 'section', text: this.$t('blocks.section')
}, {
value: 'text', text: this.$t('blocks.text')
}, {
value: 'image', text: this.$t('blocks.image')
}, {
value: 'button', text: this.$t('blocks.button')
}, {
value: 'input', text: this.$t('blocks.input')
}, {
value: 'switch', text: this.$t('blocks.switch')
}]
},
showCancelButton: true
});
if (canceled) return;
const id = uuid.v4();
this.content.push({ id, type });
},
async addVariable() {
let { canceled, result: name } = await this.$root.dialog({
title: this.$t('enter-variable-name'),
input: {
type: 'text',
},
showCancelButton: true
});
if (canceled) return;
name = name.trim();
if (this.aiScript.isUsedName(name)) {
this.$root.dialog({
type: 'error',
text: this.$t('the-variable-name-is-already-used')
});
return;
}
const id = uuid.v4();
this.variables.push({ id, name, type: null });
},
updateItem(v) {
const i = this.content.findIndex(x => x.id === v.id);
const newValue = [
...this.content.slice(0, i),
v,
...this.content.slice(i + 1)
];
this.content = newValue;
},
remove(el) {
const i = this.content.findIndex(x => x.id === el.id);
const newValue = [
...this.content.slice(0, i),
...this.content.slice(i + 1)
];
this.content = newValue;
},
removeVariable(v) {
const i = this.variables.findIndex(x => x.name === v.name);
const newValue = [
...this.variables.slice(0, i),
...this.variables.slice(i + 1)
];
this.variables = newValue;
},
getScriptBlockList(type: string = null) {
const list = [];
const blocks = AiScript.blockDefs.filter(block => type === null || block.out === null || block.out === type);
for (const block of blocks) {
const category = list.find(x => x.category === block.category);
if (category) {
category.items.push({
value: block.type,
text: this.$t(`script.blocks.${block.type}`)
});
} else {
list.push({
category: block.category,
label: this.$t(`script.categories.${block.category}`),
items: [{
value: block.type,
text: this.$t(`script.blocks.${block.type}`)
}]
});
}
}
const userFns = this.variables.filter(x => x.type === 'fn');
if (userFns.length > 0) {
list.unshift({
label: this.$t(`script.categories.fn`),
items: userFns.map(v => ({
value: 'fn:' + v.name,
text: v.name
}))
});
}
return list;
},
setEyeCatchingImage() {
this.$chooseDriveFile({
multiple: false
}).then(file => {
this.eyeCatchingImageId = file.id;
});
},
removeEyeCatchingImage() {
this.eyeCatchingImageId = null;
}
}
});
</script>
<style lang="stylus" scoped>
.gwbmwxkm
overflow hidden
background var(--face)
margin-bottom 16px
&.round
border-radius 6px
&.shadow
box-shadow 0 3px 8px rgba(0, 0, 0, 0.2)
> header
background var(--faceHeader)
> .title
z-index 1
margin 0
padding 0 16px
line-height 42px
font-size 0.9em
font-weight bold
color var(--faceHeaderText)
box-shadow 0 var(--lineWidth) rgba(#000, 0.07)
> [data-icon]
margin-right 6px
&:empty
display none
> .buttons
position absolute
z-index 2
top 0
right 0
> button
padding 0
width 42px
font-size 0.9em
line-height 42px
color var(--faceTextButton)
&:hover
color var(--faceTextButtonHover)
&:active
color var(--faceTextButtonActive)
> section
padding 0 32px 32px 32px
@media (max-width 500px)
padding 0 16px 16px 16px
> .content
margin-bottom 16px
> .eyeCatch
margin-bottom 16px
> div
> img
max-width 100%
.qmuvgica
padding 32px
@media (max-width 500px)
padding 16px
> .variables
margin-bottom 16px
> .add
margin-bottom 16px
</style>

View File

@ -0,0 +1,141 @@
<template>
<router-link :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj" tabindex="-1" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }">
<div class="thumbnail" v-if="page.eyeCatchingImage" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div>
<article>
<header>
<h1 :title="page.title">{{ page.title }}</h1>
</header>
<p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p>
<footer>
<img class="icon" :src="page.user.avatarUrl"/>
<p>{{ page.user | userName }}</p>
</footer>
</article>
</router-link>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
page: {
type: Object,
required: true
},
},
});
</script>
<style lang="stylus" scoped>
.vhpxefrj
display block
overflow hidden
width 100%
background var(--face)
&.round
border-radius 8px
&.shadow
box-shadow 0 4px 16px rgba(#000, 0.1)
@media (min-width 500px)
box-shadow 0 8px 32px rgba(#000, 0.1)
> .thumbnail
position absolute
width 100px
height 100%
background-position center
background-size cover
display flex
justify-content center
align-items center
> button
font-size 3.5em
opacity: 0.7
&:hover
font-size 4em
opacity 0.9
& + article
left 100px
width calc(100% - 100px)
> article
padding 16px
> header
margin-bottom 8px
> h1
margin 0
font-size 1em
color var(--urlPreviewTitle)
> p
margin 0
color var(--urlPreviewText)
font-size 0.8em
> footer
margin-top 8px
height 16px
> img
display inline-block
width 16px
height 16px
margin-right 4px
vertical-align top
> p
display inline-block
margin 0
color var(--urlPreviewInfo)
font-size 0.8em
line-height 16px
vertical-align top
@media (max-width 700px)
> .thumbnail
position relative
width 100%
height 100px
& + article
left 0
width 100%
@media (max-width 550px)
font-size 12px
> .thumbnail
height 80px
> article
padding 12px
@media (max-width 500px)
font-size 10px
> .thumbnail
height 70px
> article
padding 8px
> header
margin-bottom 4px
> footer
margin-top 4px
> img
width 12px
height 12px
</style>

View File

@ -0,0 +1,34 @@
<template>
<component :is="'x-' + value.type" :value="value" :page="page" :script="script" :key="value.id" :h="h"/>
</template>
<script lang="ts">
import Vue from 'vue';
import XText from './page.text.vue';
import XSection from './page.section.vue';
import XImage from './page.image.vue';
import XButton from './page.button.vue';
import XInput from './page.input.vue';
import XSwitch from './page.switch.vue';
export default Vue.extend({
components: {
XText, XSection, XImage, XButton, XInput, XSwitch
},
props: {
value: {
required: true
},
script: {
required: true
},
page: {
required: true
},
h: {
required: true
}
},
});
</script>

View File

@ -0,0 +1,42 @@
<template>
<div>
<ui-button class="kudkigyw" @click="click()">{{ value.text }}</ui-button>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
value: {
required: true
},
script: {
required: true
}
},
methods: {
click() {
if (this.value.action === 'dialog') {
this.script.reEval();
this.$root.dialog({
text: this.script.interpolate(this.value.content)
});
} else if (this.value.action === 'resetRandom') {
this.script.aiScript.updateRandomSeed(Math.random());
this.script.reEval();
}
}
}
});
</script>
<style lang="stylus" scoped>
.kudkigyw
display inline-block
min-width 300px
max-width 450px
margin 8px 0
</style>

View File

@ -0,0 +1,36 @@
<template>
<div class="lzyxtsnt">
<img v-if="image" :src="image.url"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
value: {
required: true
},
page: {
required: true
},
},
data() {
return {
image: null,
};
},
created() {
this.image = this.page.attachedFiles.find(x => x.id === this.value.fileId);
}
});
</script>
<style lang="stylus" scoped>
.lzyxtsnt
> img
max-width 100%
</style>

View File

@ -0,0 +1,43 @@
<template>
<div>
<ui-input class="kudkigyw" v-model="v" :type="value.inputType">{{ value.text }}</ui-input>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
value: {
required: true
},
script: {
required: true
}
},
data() {
return {
v: this.value.default,
};
},
watch: {
v() {
let v = this.v;
if (this.value.inputType === 'number') v = parseInt(v, 10);
this.script.aiScript.updatePageVar(this.value.name, v);
this.script.reEval();
}
}
});
</script>
<style lang="stylus" scoped>
.kudkigyw
display inline-block
min-width 300px
max-width 450px
margin 8px 0
</style>

View File

@ -0,0 +1,55 @@
<template>
<section class="sdgxphyu">
<component :is="'h' + h">{{ value.title }}</component>
<div class="children">
<x-block v-for="child in value.children" :value="child" :page="page" :script="script" :key="child.id" :h="h + 1"/>
</div>
</section>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
value: {
required: true
},
script: {
required: true
},
page: {
required: true
},
h: {
required: true
}
},
beforeCreate() {
this.$options.components.XBlock = require('./page.block.vue').default
},
});
</script>
<style lang="stylus" scoped>
.sdgxphyu
margin 1.5em 0
> h2
font-size 1.35em
margin 0 0 0.5em 0
> h3
font-size 1em
margin 0 0 0.5em 0
> h4
font-size 1em
margin 0 0 0.5em 0
> .children
//padding 16px
</style>

View File

@ -0,0 +1,33 @@
<template>
<div>
<ui-switch v-model="v">{{ value.text }}</ui-switch>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
value: {
required: true
},
script: {
required: true
}
},
data() {
return {
v: this.value.default,
};
},
watch: {
v() {
this.script.aiScript.updatePageVar(this.value.name, this.v);
this.script.reEval();
}
}
});
</script>

View File

@ -0,0 +1,35 @@
<template>
<div class="">
<mfm :text="text" :is-note="false" :i="$store.state.i" :key="text"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
value: {
required: true
},
script: {
required: true
}
},
data() {
return {
text: this.script.interpolate(this.value.text),
};
},
created() {
this.$watch('script.vars', () => {
this.text = this.script.interpolate(this.value.text);
}, { deep: true });
}
});
</script>
<style lang="stylus" scoped>
</style>

View File

@ -0,0 +1,143 @@
<template>
<div v-if="page" class="iroscrza" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners, center: page.alignCenter }" :style="{ fontFamily: page.font }">
<header>
<div class="title">{{ page.title }}</div>
</header>
<div v-if="script">
<x-block v-for="child in page.content" :value="child" @input="v => updateBlock(v)" :page="page" :script="script" :key="child.id" :h="2"/>
</div>
<footer>
<small>@{{ page.user.username }}</small>
<router-link v-if="$store.getters.isSignedIn && $store.state.i.id === page.userId" :to="`/i/pages/edit/${page.id}`">{{ $t('edit-this-page') }}</router-link>
</footer>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import { faICursor, faPlus, faSquareRootAlt } from '@fortawesome/free-solid-svg-icons';
import { faSave, faStickyNote } from '@fortawesome/free-regular-svg-icons';
import XBlock from './page.block.vue';
import { AiScript } from '../../../scripts/aiscript';
import { collectPageVars } from '../../../scripts/collect-page-vars';
class Script {
public aiScript: AiScript;
public vars: any;
constructor(aiScript) {
this.aiScript = aiScript;
this.vars = this.aiScript.evaluateVars();
}
public reEval() {
this.vars = this.aiScript.evaluateVars();
}
public interpolate(str: string) {
return str.replace(/\{(.+?)\}/g, match =>
(this.vars.find(x => x.name === match.slice(1, -1).trim()).value || '').toString());
}
}
export default Vue.extend({
i18n: i18n('pages'),
components: {
XBlock
},
props: {
pageName: {
type: String,
required: true
},
username: {
type: String,
required: true
},
},
data() {
return {
page: null,
script: null,
faPlus, faICursor, faSave, faStickyNote, faSquareRootAlt
};
},
created() {
this.$root.api('pages/show', {
name: this.pageName,
username: this.username,
}).then(page => {
this.page = page;
const pageVars = this.getPageVars();
this.script = new Script(new AiScript(this.page.variables, pageVars, {
randomSeed: Math.random(),
user: page.user,
visitor: this.$store.state.i
}));
});
},
methods: {
getPageVars() {
return collectPageVars(this.page.content);
},
}
});
</script>
<style lang="stylus" scoped>
.iroscrza
overflow hidden
background var(--face)
&.center
text-align center
&.round
border-radius 6px
&.shadow
box-shadow 0 3px 8px rgba(0, 0, 0, 0.2)
> header
> .title
z-index 1
margin 0
padding 32px 64px
font-size 24px
font-weight bold
color var(--text)
box-shadow 0 var(--lineWidth) rgba(#000, 0.07)
@media (max-width 600px)
padding 16px 32px
font-size 20px
> div
color var(--text)
padding 48px 64px
font-size 18px
@media (max-width 600px)
padding 24px 32px
font-size 16px
> footer
color var(--text)
padding 0 64px 38px 64px
@media (max-width 600px)
padding 0 32px 28px 32px
> small
display block
opacity 0.5
</style>

View File

@ -156,7 +156,11 @@ init(async (launch, os) => {
{ path: '/explore', name: 'explore', component: () => import('../common/views/pages/explore.vue').then(m => m.default) },
{ path: '/explore/tags/:tag', name: 'explore-tag', props: true, component: () => import('../common/views/pages/explore.vue').then(m => m.default) },
{ path: '/i/favorites', component: () => import('./views/home/favorites.vue').then(m => m.default) },
{ path: '/i/pages', component: () => import('./views/home/pages.vue').then(m => m.default) },
]},
{ path: '/@:user/pages/:page', props: true, component: () => import('./views/pages/page.vue').then(m => m.default) },
{ path: '/i/pages/new', component: () => import('./views/pages/page-editor.vue').then(m => m.default) },
{ path: '/i/pages/edit/:page', props: true, component: () => import('./views/pages/page-editor.vue').then(m => m.default) },
{ path: '/i/messaging/:user', component: MkMessagingRoom },
{ path: '/i/drive', component: MkDrive },
{ path: '/i/drive/folder/:folder', component: MkDrive },

View File

@ -9,35 +9,42 @@
<ul>
<li>
<router-link :to="`/@${ $store.state.i.username }`">
<i><fa icon="user"/></i>
<i><fa icon="user" fixed-width/></i>
<span>{{ $t('profile') }}</span>
<i><fa icon="angle-right"/></i>
</router-link>
</li>
<li @click="drive">
<p>
<i><fa icon="cloud"/></i>
<i><fa icon="cloud" fixed-width/></i>
<span>{{ $t('@.drive') }}</span>
<i><fa icon="angle-right"/></i>
</p>
</li>
<li>
<router-link to="/i/favorites">
<i><fa icon="star"/></i>
<i><fa icon="star" fixed-width/></i>
<span>{{ $t('@.favorites') }}</span>
<i><fa icon="angle-right"/></i>
</router-link>
</li>
<li @click="list">
<p>
<i><fa icon="list"/></i>
<i><fa icon="list" fixed-width/></i>
<span>{{ $t('lists') }}</span>
<i><fa icon="angle-right"/></i>
</p>
</li>
<li @click="page">
<router-link to="/i/pages">
<i><fa :icon="faStickyNote" fixed-width/></i>
<span>{{ $t('@.pages') }}</span>
<i><fa icon="angle-right"/></i>
</router-link>
</li>
<li @click="followRequests" v-if="($store.state.i.isLocked || $store.state.i.carefulBot)">
<p>
<i><fa :icon="['far', 'envelope']"/></i>
<i><fa :icon="['far', 'envelope']" fixed-width/></i>
<span>{{ $t('follow-requests') }}<i v-if="$store.state.i.pendingReceivedFollowRequestsCount">{{ $store.state.i.pendingReceivedFollowRequestsCount }}</i></span>
<i><fa icon="angle-right"/></i>
</p>
@ -46,14 +53,14 @@
<ul>
<li>
<router-link to="/i/settings">
<i><fa icon="cog"/></i>
<i><fa icon="cog" fixed-width/></i>
<span>{{ $t('@.settings') }}</span>
<i><fa icon="angle-right"/></i>
</router-link>
</li>
<li v-if="$store.state.i.isAdmin || $store.state.i.isModerator">
<a href="/admin">
<i><fa icon="terminal"/></i>
<i><fa icon="terminal" fixed-width/></i>
<span>{{ $t('admin') }}</span>
<i><fa icon="angle-right"/></i>
</a>
@ -76,7 +83,7 @@
<ul>
<li @click="signout">
<p class="signout">
<i><fa icon="power-off"/></i>
<i><fa icon="power-off" fixed-width/></i>
<span>{{ $t('@.signout') }}</span>
</p>
</li>
@ -95,14 +102,14 @@ import MkFollowRequestsWindow from './received-follow-requests-window.vue';
import MkDriveWindow from './drive-window.vue';
import contains from '../../../common/scripts/contains';
import { faHome, faColumns } from '@fortawesome/free-solid-svg-icons';
import { faMoon, faSun } from '@fortawesome/free-regular-svg-icons';
import { faMoon, faSun, faStickyNote } from '@fortawesome/free-regular-svg-icons';
export default Vue.extend({
i18n: i18n('desktop/views/components/ui.header.account.vue'),
data() {
return {
isOpen: false,
faHome, faColumns, faMoon, faSun
faHome, faColumns, faMoon, faSun, faStickyNote
};
},
computed: {

View File

@ -0,0 +1,92 @@
<template>
<div class="rknalgpo" v-if="!fetching">
<ui-button @click="create()"><fa :icon="faPlus"/></ui-button>
<sequential-entrance animation="entranceFromTop" delay="25">
<template v-for="page in pages">
<x-page-preview class="page" :page="page" :key="page.id"/>
</template>
</sequential-entrance>
<ui-button v-if="existMore" @click="fetchMore()">{{ $t('@.load-more') }}</ui-button>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import Progress from '../../../common/scripts/loading';
import { faPlus } from '@fortawesome/free-solid-svg-icons';
import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
import XPagePreview from '../../../common/views/components/page-preview.vue';
export default Vue.extend({
i18n: i18n(),
components: {
XPagePreview
},
data() {
return {
fetching: true,
pages: [],
existMore: false,
moreFetching: false,
faStickyNote, faPlus
};
},
created() {
this.fetch();
},
methods: {
fetch() {
Progress.start();
this.fetching = true;
this.$root.api('i/pages', {
limit: 11
}).then(pages => {
if (pages.length == 11) {
this.existMore = true;
pages.pop();
}
this.pages = pages;
this.fetching = false;
Progress.done();
});
},
fetchMore() {
this.moreFetching = true;
this.$root.api('i/pages', {
limit: 11,
untilId: this.pages[this.pages.length - 1].id
}).then(pages => {
if (pages.length == 11) {
this.existMore = true;
pages.pop();
} else {
this.existMore = false;
}
this.pages = this.pages.concat(pages);
this.moreFetching = false;
});
},
create() {
this.$router.push(`/i/pages/new`);
}
}
});
</script>
<style lang="stylus" scoped>
.rknalgpo
margin 0 auto
> * > .page
margin-bottom 8px
@media (min-width 500px)
> * > .page
margin-bottom 16px
</style>

View File

@ -0,0 +1,32 @@
<template>
<mk-ui>
<main>
<x-page-editor :page="page"/>
</main>
</mk-ui>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
components: {
XPageEditor: () => import('../../../common/views/components/page-editor/page-editor.vue').then(m => m.default)
},
props: {
page: {
type: String,
required: false
}
}
});
</script>
<style lang="stylus" scoped>
main
margin 0 auto
padding 16px
max-width 900px
</style>

View File

@ -0,0 +1,36 @@
<template>
<mk-ui>
<main>
<x-page :page-name="page" :username="user"/>
</main>
</mk-ui>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
components: {
XPage: () => import('../../../common/views/pages/page/page.vue').then(m => m.default)
},
props: {
page: {
type: String,
required: true
},
user: {
type: String,
required: true
},
}
});
</script>
<style lang="stylus" scoped>
main
margin 0 auto
padding 16px
max-width 950px
</style>

View File

@ -135,6 +135,7 @@ init((launch, os) => {
{ path: '/signup', name: 'signup', component: MkSignup },
{ path: '/i/settings', name: 'settings', component: () => import('./views/pages/settings.vue').then(m => m.default) },
{ path: '/i/favorites', name: 'favorites', component: MkFavorites },
{ path: '/i/pages', name: 'pages', component: () => import('./views/pages/pages.vue').then(m => m.default) },
{ path: '/i/lists', name: 'user-lists', component: MkUserLists },
{ path: '/i/lists/:list', name: 'user-list', component: MkUserList },
{ path: '/i/received-follow-requests', name: 'received-follow-requests', component: MkReceivedFollowRequests },
@ -144,6 +145,8 @@ init((launch, os) => {
{ path: '/i/drive', name: 'drive', component: MkDrive },
{ path: '/i/drive/folder/:folder', component: MkDrive },
{ path: '/i/drive/file/:file', component: MkDrive },
{ path: '/i/pages/new', component: () => import('./views/pages/page-editor.vue').then(m => m.default) },
{ path: '/i/pages/edit/:page', props: true, component: () => import('./views/pages/page-editor.vue').then(m => m.default) },
{ path: '/selectdrive', component: MkSelectDrive },
{ path: '/search', component: MkSearch },
{ path: '/tags/:tag', component: MkTag },
@ -156,6 +159,7 @@ init((launch, os) => {
{ path: 'following', component: () => import('../common/views/pages/following.vue').then(m => m.default) },
{ path: 'followers', component: () => import('../common/views/pages/followers.vue').then(m => m.default) },
]},
{ path: '/@:user/pages/:page', props: true, component: () => import('./views/pages/page.vue').then(m => m.default) },
{ path: '/notes/:note', component: MkNote },
{ path: '/authorize-follow', component: MkFollow },
{ path: '*', component: MkNotFound }

View File

@ -29,6 +29,7 @@
<li><router-link to="/i/favorites" :data-active="$route.name == 'favorites'"><i><fa icon="star" fixed-width/></i>{{ $t('@.favorites') }}<i><fa icon="angle-right"/></i></router-link></li>
<li><router-link to="/i/lists" :data-active="$route.name == 'user-lists'"><i><fa icon="list" fixed-width/></i>{{ $t('user-lists') }}<i><fa icon="angle-right"/></i></router-link></li>
<li><router-link to="/i/drive" :data-active="$route.name == 'drive'"><i><fa icon="cloud" fixed-width/></i>{{ $t('@.drive') }}<i><fa icon="angle-right"/></i></router-link></li>
<li><router-link to="/i/pages" :data-active="$route.name == 'pages'"><i><fa :icon="faStickyNote" fixed-width/></i>{{ $t('@.pages') }}<i><fa icon="angle-right"/></i></router-link></li>
</ul>
<ul>
<li><a @click="search"><i><fa icon="search" fixed-width/></i>{{ $t('search') }}<i><fa icon="angle-right"/></i></a></li>
@ -66,7 +67,7 @@ import Vue from 'vue';
import i18n from '../../../i18n';
import { lang } from '../../../config';
import { faNewspaper, faHashtag, faHome, faColumns } from '@fortawesome/free-solid-svg-icons';
import { faMoon, faSun } from '@fortawesome/free-regular-svg-icons';
import { faMoon, faSun, faStickyNote } from '@fortawesome/free-regular-svg-icons';
import { search } from '../../../common/scripts/search';
export default Vue.extend({
@ -86,7 +87,7 @@ export default Vue.extend({
announcements: [],
searching: false,
showNotifications: false,
faNewspaper, faHashtag, faMoon, faSun, faHome, faColumns
faNewspaper, faHashtag, faMoon, faSun, faHome, faColumns, faStickyNote
};
},

View File

@ -0,0 +1,32 @@
<template>
<mk-ui>
<main>
<x-page-editor :page="page"/>
</main>
</mk-ui>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
components: {
XPageEditor: () => import('../../../common/views/components/page-editor/page-editor.vue').then(m => m.default)
},
props: {
page: {
type: String,
required: false
}
}
});
</script>
<style lang="stylus" scoped>
main
margin 0 auto
padding 16px
max-width 1000px
</style>

View File

@ -0,0 +1,36 @@
<template>
<mk-ui>
<main>
<x-page :page-name="page" :username="user"/>
</main>
</mk-ui>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
components: {
XPage: () => import('../../../common/views/pages/page/page.vue').then(m => m.default)
},
props: {
page: {
type: String,
required: true
},
user: {
type: String,
required: true
},
}
});
</script>
<style lang="stylus" scoped>
main
margin 0 auto
padding 16px
max-width 1000px
</style>

View File

@ -0,0 +1,94 @@
<template>
<mk-ui>
<template #header><span style="margin-right:4px;"><fa :icon="faStickyNote"/></span>{{ $t('@.pages') }}</template>
<main>
<ui-button @click="create()"><fa :icon="faPlus"/></ui-button>
<sequential-entrance animation="entranceFromTop" delay="25">
<template v-for="page in pages">
<x-page-preview class="page" :page="page" :key="page.id"/>
</template>
</sequential-entrance>
<ui-button v-if="existMore" @click="fetchMore()">{{ $t('@.load-more') }}</ui-button>
</main>
</mk-ui>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import Progress from '../../../common/scripts/loading';
import { faPlus } from '@fortawesome/free-solid-svg-icons';
import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
import XPagePreview from '../../../common/views/components/page-preview.vue';
export default Vue.extend({
i18n: i18n(),
components: {
XPagePreview
},
data() {
return {
fetching: true,
pages: [],
existMore: false,
moreFetching: false,
faStickyNote, faPlus
};
},
created() {
this.fetch();
},
methods: {
fetch() {
Progress.start();
this.fetching = true;
this.$root.api('i/pages', {
limit: 11
}).then(pages => {
if (pages.length == 11) {
this.existMore = true;
pages.pop();
}
this.pages = pages;
this.fetching = false;
Progress.done();
});
},
fetchMore() {
this.moreFetching = true;
this.$root.api('i/pages', {
limit: 11,
untilId: this.pages[this.pages.length - 1].id
}).then(pages => {
if (pages.length == 11) {
this.existMore = true;
pages.pop();
} else {
this.existMore = false;
}
this.pages = this.pages.concat(pages);
this.moreFetching = false;
});
},
create() {
this.$router.push(`/i/pages/new`);
}
}
});
</script>
<style lang="stylus" scoped>
main
> * > .page
margin-bottom 8px
@media (min-width 500px)
> * > .page
margin-bottom 16px
</style>

View File

@ -232,5 +232,8 @@
adminDashboardCardBg: '$secondary',
adminDashboardCardFg: '$text',
adminDashboardCardDivider: 'rgba(0, 0, 0, 0.3)',
pageBlockBorder: 'rgba(255, 255, 255, 0.1)',
pageBlockBorderHover: 'rgba(255, 255, 255, 0.15)',
},
}

View File

@ -232,5 +232,8 @@
adminDashboardCardBg: '$secondary',
adminDashboardCardFg: '$text',
adminDashboardCardDivider: 'rgba(0, 0, 0, 0.082)',
pageBlockBorder: 'rgba(0, 0, 0, 0.1)',
pageBlockBorderHover: 'rgba(0, 0, 0, 0.15)',
},
}

View File

@ -40,6 +40,7 @@ import { Poll } from '../models/entities/poll';
import { UserKeypair } from '../models/entities/user-keypair';
import { UserPublickey } from '../models/entities/user-publickey';
import { UserProfile } from '../models/entities/user-profile';
import { Page } from '../models/entities/page';
const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
@ -114,6 +115,7 @@ export function initDb(justBorrow = false, sync = false, log = false) {
NoteReaction,
NoteWatching,
NoteUnread,
Page,
Log,
DriveFile,
DriveFolder,

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

@ -0,0 +1,105 @@
import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
import { User } from './user';
import { id } from '../id';
import { DriveFile } from './drive-file';
@Entity()
@Index(['userId', 'name'], { unique: true })
export class Page {
@PrimaryColumn(id())
public id: string;
@Index()
@Column('timestamp with time zone', {
comment: 'The created date of the Page.'
})
public createdAt: Date;
@Index()
@Column('timestamp with time zone', {
comment: 'The updated date of the Page.'
})
public updatedAt: Date;
@Column('varchar', {
length: 256,
})
public title: string;
@Index()
@Column('varchar', {
length: 256,
})
public name: string;
@Column('varchar', {
length: 256, nullable: true
})
public summary: string | null;
@Column('boolean')
public alignCenter: boolean;
@Column('varchar', {
length: 32,
})
public font: string;
@Index()
@Column({
...id(),
comment: 'The ID of author.'
})
public userId: User['id'];
@ManyToOne(type => User, {
onDelete: 'CASCADE'
})
@JoinColumn()
public user: User | null;
@Column({
...id(),
nullable: true,
})
public eyeCatchingImageId: DriveFile['id'] | null;
@ManyToOne(type => DriveFile, {
onDelete: 'CASCADE'
})
@JoinColumn()
public eyeCatchingImage: DriveFile | null;
@Column('jsonb', {
default: []
})
public content: Record<string, any>[];
@Column('jsonb', {
default: []
})
public variables: Record<string, any>[];
/**
* public ... 公開
* followers ... フォロワーのみ
* specified ... visibleUserIds で指定したユーザーのみ
*/
@Column('enum', { enum: ['public', 'followers', 'specified'] })
public visibility: 'public' | 'followers' | 'specified';
@Index()
@Column({
...id(),
array: true, default: '{}'
})
public visibleUserIds: User['id'][];
constructor(data: Partial<Page>) {
if (data == null) return;
for (const [k, v] of Object.entries(data)) {
(this as any)[k] = v;
}
}
}

View File

@ -35,6 +35,7 @@ import { AbuseUserReportRepository } from './repositories/abuse-user-report';
import { AuthSessionRepository } from './repositories/auth-session';
import { UserProfile } from './entities/user-profile';
import { HashtagRepository } from './repositories/hashtag';
import { PageRepository } from './repositories/page';
export const Apps = getCustomRepository(AppRepository);
export const Notes = getCustomRepository(NoteRepository);
@ -72,3 +73,4 @@ export const MessagingMessages = getCustomRepository(MessagingMessageRepository)
export const ReversiGames = getCustomRepository(ReversiGameRepository);
export const ReversiMatchings = getCustomRepository(ReversiMatchingRepository);
export const Logs = getRepository(Log);
export const Pages = getCustomRepository(PageRepository);

View File

@ -0,0 +1,61 @@
import { EntityRepository, Repository } from 'typeorm';
import { Page } from '../entities/page';
import { SchemaType, types, bool } from '../../misc/schema';
import { Users, DriveFiles } from '..';
import { awaitAll } from '../../prelude/await-all';
import { DriveFile } from '../entities/drive-file';
export type PackedPage = SchemaType<typeof packedPageSchema>;
@EntityRepository(Page)
export class PageRepository extends Repository<Page> {
public async pack(
src: Page,
): Promise<PackedPage> {
const attachedFiles: Promise<DriveFile | undefined>[] = [];
const collectFile = (xs: any[]) => {
for (const x of xs) {
if (x.type === 'image') {
attachedFiles.push(DriveFiles.findOne({
id: x.fileId,
userId: src.userId
}));
}
if (x.children) {
collectFile(x.children);
}
}
};
collectFile(src.content);
return await awaitAll({
id: src.id,
createdAt: src.createdAt.toISOString(),
updatedAt: src.updatedAt.toISOString(),
userId: src.userId,
user: Users.pack(src.user || src.userId),
content: src.content,
variables: src.variables,
title: src.title,
name: src.name,
summary: src.summary,
alignCenter: src.alignCenter,
font: src.font,
eyeCatchingImageId: src.eyeCatchingImageId,
eyeCatchingImage: src.eyeCatchingImageId ? await DriveFiles.pack(src.eyeCatchingImageId) : null,
attachedFiles: DriveFiles.packMany(await Promise.all(attachedFiles))
});
}
public packMany(
pages: Page[],
) {
return Promise.all(pages.map(x => this.pack(x)));
}
}
export const packedPageSchema = {
type: types.object,
optional: bool.false, nullable: bool.false,
properties: {
}
};

View File

@ -0,0 +1,44 @@
import $ from 'cafy';
import { ID } from '../../../../misc/cafy-id';
import define from '../../define';
import { Pages } from '../../../../models';
import { makePaginationQuery } from '../../common/make-pagination-query';
export const meta = {
desc: {
'ja-JP': '自分の作成したページ一覧を取得します。',
'en-US': 'Get my pages.'
},
tags: ['account', 'pages'],
requireCredential: true,
kind: 'read:pages',
params: {
limit: {
validator: $.optional.num.range(1, 100),
default: 10
},
sinceId: {
validator: $.optional.type(ID),
},
untilId: {
validator: $.optional.type(ID),
},
}
};
export default define(meta, async (ps, user) => {
const query = makePaginationQuery(Pages.createQueryBuilder('page'), ps.sinceId, ps.untilId)
.andWhere(`page.userId = :meId`, { meId: user.id });
const pages = await query
.take(ps.limit!)
.getMany();
return await Pages.packMany(pages);
});

View File

@ -0,0 +1,108 @@
import $ from 'cafy';
import * as ms from 'ms';
import define from '../../define';
import { ID } from '../../../../misc/cafy-id';
import { types, bool } from '../../../../misc/schema';
import { Pages, DriveFiles } from '../../../../models';
import { genId } from '../../../../misc/gen-id';
import { Page } from '../../../../models/entities/page';
import { ApiError } from '../../error';
export const meta = {
desc: {
'ja-JP': 'ページを作成します。',
},
tags: ['pages'],
requireCredential: true,
kind: 'write:pages',
limit: {
duration: ms('1hour'),
max: 300
},
params: {
title: {
validator: $.str,
},
name: {
validator: $.str,
},
summary: {
validator: $.optional.nullable.str,
},
content: {
validator: $.arr($.obj())
},
variables: {
validator: $.arr($.obj())
},
eyeCatchingImageId: {
validator: $.optional.nullable.type(ID),
},
font: {
validator: $.optional.str.or(['serif', 'sans-serif']),
default: 'sans-serif'
},
alignCenter: {
validator: $.optional.bool,
default: false
},
},
res: {
type: types.object,
optional: bool.false, nullable: bool.false,
ref: 'Page',
},
errors: {
noSuchFile: {
message: 'No such file.',
code: 'NO_SUCH_FILE',
id: 'b7b97489-0f66-4b12-a5ff-b21bd63f6e1c'
},
}
};
export default define(meta, async (ps, user) => {
let eyeCatchingImage = null;
if (ps.eyeCatchingImageId != null) {
eyeCatchingImage = await DriveFiles.findOne({
id: ps.eyeCatchingImageId,
userId: user.id
});
if (eyeCatchingImage == null) {
throw new ApiError(meta.errors.noSuchFile);
}
}
const page = await Pages.save(new Page({
id: genId(),
createdAt: new Date(),
updatedAt: new Date(),
title: ps.title,
name: ps.name,
summary: ps.summary,
content: ps.content,
variables: ps.variables,
eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null,
userId: user.id,
visibility: 'public',
alignCenter: ps.alignCenter,
font: ps.font
}));
return await Pages.pack(page);
});

View File

@ -0,0 +1,53 @@
import $ from 'cafy';
import define from '../../define';
import { ApiError } from '../../error';
import { Pages } from '../../../../models';
import { ID } from '../../../../misc/cafy-id';
export const meta = {
desc: {
'ja-JP': '指定したページを削除します。',
},
tags: ['pages'],
requireCredential: true,
kind: 'write:pages',
params: {
pageId: {
validator: $.type(ID),
desc: {
'ja-JP': '対象のページのID',
'en-US': 'Target page ID.'
}
},
},
errors: {
noSuchPage: {
message: 'No such page.',
code: 'NO_SUCH_PAGE',
id: 'eb0c6e1d-d519-4764-9486-52a7e1c6392a'
},
accessDenied: {
message: 'Access denied.',
code: 'ACCESS_DENIED',
id: '8b741b3e-2c22-44b3-a15f-29949aa1601e'
},
}
};
export default define(meta, async (ps, user) => {
const page = await Pages.findOne(ps.pageId);
if (page == null) {
throw new ApiError(meta.errors.noSuchPage);
}
if (page.userId !== user.id) {
throw new ApiError(meta.errors.accessDenied);
}
await Pages.delete(page.id);
});

View File

@ -0,0 +1,74 @@
import $ from 'cafy';
import define from '../../define';
import { ApiError } from '../../error';
import { Pages, Users } from '../../../../models';
import { types, bool } from '../../../../misc/schema';
import { ID } from '../../../../misc/cafy-id';
import { Page } from '../../../../models/entities/page';
export const meta = {
desc: {
'ja-JP': '指定したページの情報を取得します。',
},
tags: ['pages'],
requireCredential: false,
params: {
pageId: {
validator: $.optional.type(ID),
desc: {
'ja-JP': '対象のページのID',
'en-US': 'Target page ID.'
}
},
name: {
validator: $.optional.str,
},
username: {
validator: $.optional.str,
},
},
res: {
type: types.object,
optional: bool.false, nullable: bool.false,
ref: 'Page',
},
errors: {
noSuchPage: {
message: 'No such page.',
code: 'NO_SUCH_PAGE',
id: '222120c0-3ead-4528-811b-b96f233388d7'
}
}
};
export default define(meta, async (ps, user) => {
let page: Page | undefined;
if (ps.pageId) {
page = await Pages.findOne(ps.pageId);
} else if (ps.name && ps.username) {
const author = await Users.findOne({
host: null,
usernameLower: ps.username.toLowerCase()
});
if (author) {
page = await Pages.findOne({
name: ps.name,
userId: author.id
});
}
}
if (page == null) {
throw new ApiError(meta.errors.noSuchPage);
}
return await Pages.pack(page);
});

View File

@ -0,0 +1,123 @@
import $ from 'cafy';
import * as ms from 'ms';
import define from '../../define';
import { ApiError } from '../../error';
import { Pages, DriveFiles } from '../../../../models';
import { ID } from '../../../../misc/cafy-id';
export const meta = {
desc: {
'ja-JP': '指定したページの情報を更新します。',
},
tags: ['pages'],
requireCredential: true,
kind: 'write:pages',
limit: {
duration: ms('1hour'),
max: 300
},
params: {
pageId: {
validator: $.type(ID),
desc: {
'ja-JP': '対象のページのID',
'en-US': 'Target page ID.'
}
},
title: {
validator: $.str,
},
name: {
validator: $.optional.str,
},
summary: {
validator: $.optional.nullable.str,
},
content: {
validator: $.arr($.obj())
},
variables: {
validator: $.arr($.obj())
},
eyeCatchingImageId: {
validator: $.optional.nullable.type(ID),
},
font: {
validator: $.optional.str.or(['serif', 'sans-serif']),
},
alignCenter: {
validator: $.optional.bool,
},
},
errors: {
noSuchPage: {
message: 'No such page.',
code: 'NO_SUCH_PAGE',
id: '21149b9e-3616-4778-9592-c4ce89f5a864'
},
accessDenied: {
message: 'Access denied.',
code: 'ACCESS_DENIED',
id: '3c15cd52-3b4b-4274-967d-6456fc4f792b'
},
noSuchFile: {
message: 'No such file.',
code: 'NO_SUCH_FILE',
id: 'cfc23c7c-3887-490e-af30-0ed576703c82'
},
}
};
export default define(meta, async (ps, user) => {
const page = await Pages.findOne(ps.pageId);
if (page == null) {
throw new ApiError(meta.errors.noSuchPage);
}
if (page.userId !== user.id) {
throw new ApiError(meta.errors.accessDenied);
}
let eyeCatchingImage = null;
if (ps.eyeCatchingImageId != null) {
eyeCatchingImage = await DriveFiles.findOne({
id: ps.eyeCatchingImageId,
userId: user.id
});
if (eyeCatchingImage == null) {
throw new ApiError(meta.errors.noSuchFile);
}
}
await Pages.update(page.id, {
updatedAt: new Date(),
title: ps.title,
name: ps.name === undefined ? page.name : ps.name,
summary: ps.name === undefined ? page.summary : ps.summary,
content: ps.content,
variables: ps.variables,
alignCenter: ps.alignCenter === undefined ? page.alignCenter : ps.alignCenter,
font: ps.font === undefined ? page.font : ps.font,
eyeCatchingImageId: ps.eyeCatchingImageId === null
? null
: ps.eyeCatchingImageId === undefined
? page.eyeCatchingImageId
: eyeCatchingImage!.id,
});
});

View File

@ -16,7 +16,7 @@ import { fetchMeta } from '../../misc/fetch-meta';
import * as pkg from '../../../package.json';
import { genOpenapiSpec } from '../api/openapi/gen-spec';
import config from '../../config';
import { Users, Notes, Emojis, UserProfiles } from '../../models';
import { Users, Notes, Emojis, UserProfiles, Pages } from '../../models';
import parseAcct from '../../misc/acct/parse';
import getNoteSummary from '../../misc/get-note-summary';
import { ensure } from '../../prelude/ensure';
@ -203,6 +203,41 @@ router.get('/notes/:note', async ctx => {
ctx.status = 404;
});
// Page
router.get('/@:user/pages/:page', async ctx => {
const { username, host } = parseAcct(ctx.params.user);
const user = await Users.findOne({
usernameLower: username.toLowerCase(),
host
});
if (user == null) return;
const page = await Pages.findOne({
name: ctx.params.page,
userId: user.id
});
if (page) {
const _page = await Pages.pack(page);
const meta = await fetchMeta();
await ctx.render('page', {
page: _page,
instanceName: meta.name || 'Misskey'
});
if (['public'].includes(page.visibility)) {
ctx.set('Cache-Control', 'public, max-age=180');
} else {
ctx.set('Cache-Control', 'private, max-age=0, must-revalidate');
}
return;
}
ctx.status = 404;
});
//#endregion
router.get('/info', async ctx => {

View File

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

View File

@ -0,0 +1,30 @@
extends ./base
block vars
- const user = page.user;
- const title = page.title;
- const url = `${config.url}/@${user.username}/${page.name}`;
block title
= `${title} | ${instanceName}`
block desc
meta(name='description' content= page.summary)
block og
meta(property='og:type' content='article')
meta(property='og:title' content= title)
meta(property='og:description' content= page.summary)
meta(property='og:url' content= url)
meta(property='og:image' content= page.eyeCatchingImage ? page.eyeCatchingImage.thumbnailUrl : user.avatarUrl)
block meta
meta(name='misskey:user-username' content=user.username)
meta(name='misskey:user-id' content=user.id)
meta(name='misskey:page-id' content=page.id)
meta(name='twitter:card' content='summary')
// todo
if user.twitter
meta(name='twitter:creator' content=`@${user.twitter.screenName}`)