From 05b8111c1906c1285c9ddde758eda45b83792244 Mon Sep 17 00:00:00 2001 From: syuilo Date: Mon, 29 Apr 2019 09:11:57 +0900 Subject: 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 --- src/client/app/common/scripts/aiscript.ts | 470 +++++++++++++++++++++ src/client/app/common/scripts/collect-page-vars.ts | 24 ++ src/client/app/common/views/components/dialog.vue | 11 +- .../app/common/views/components/media-image.vue | 2 +- .../components/page-editor/page-editor.block.vue | 25 ++ .../components/page-editor/page-editor.button.vue | 54 +++ .../page-editor/page-editor.container.vue | 135 ++++++ .../components/page-editor/page-editor.image.vue | 78 ++++ .../components/page-editor/page-editor.input.vue | 54 +++ .../page-editor/page-editor.script-block.vue | 263 ++++++++++++ .../components/page-editor/page-editor.section.vue | 133 ++++++ .../components/page-editor/page-editor.switch.vue | 48 +++ .../components/page-editor/page-editor.text.vue | 57 +++ .../views/components/page-editor/page-editor.vue | 452 ++++++++++++++++++++ .../app/common/views/components/page-preview.vue | 141 +++++++ .../app/common/views/pages/page/page.block.vue | 34 ++ .../app/common/views/pages/page/page.button.vue | 42 ++ .../app/common/views/pages/page/page.image.vue | 36 ++ .../app/common/views/pages/page/page.input.vue | 43 ++ .../app/common/views/pages/page/page.section.vue | 55 +++ .../app/common/views/pages/page/page.switch.vue | 33 ++ .../app/common/views/pages/page/page.text.vue | 35 ++ src/client/app/common/views/pages/page/page.vue | 143 +++++++ src/client/app/desktop/script.ts | 4 + .../desktop/views/components/ui.header.account.vue | 27 +- src/client/app/desktop/views/home/pages.vue | 92 ++++ src/client/app/desktop/views/pages/page-editor.vue | 32 ++ src/client/app/desktop/views/pages/page.vue | 36 ++ src/client/app/mobile/script.ts | 4 + src/client/app/mobile/views/components/ui.nav.vue | 5 +- src/client/app/mobile/views/pages/page-editor.vue | 32 ++ src/client/app/mobile/views/pages/page.vue | 36 ++ src/client/app/mobile/views/pages/pages.vue | 94 +++++ src/client/themes/dark.json5 | 3 + src/client/themes/light.json5 | 3 + src/db/postgre.ts | 2 + src/models/entities/page.ts | 105 +++++ src/models/index.ts | 2 + src/models/repositories/page.ts | 61 +++ src/server/api/endpoints/i/pages.ts | 44 ++ src/server/api/endpoints/pages/create.ts | 108 +++++ src/server/api/endpoints/pages/delete.ts | 53 +++ src/server/api/endpoints/pages/show.ts | 74 ++++ src/server/api/endpoints/pages/update.ts | 123 ++++++ src/server/web/index.ts | 37 +- src/server/web/views/note.pug | 1 + src/server/web/views/page.pug | 30 ++ 47 files changed, 3360 insertions(+), 16 deletions(-) create mode 100644 src/client/app/common/scripts/aiscript.ts create mode 100644 src/client/app/common/scripts/collect-page-vars.ts create mode 100644 src/client/app/common/views/components/page-editor/page-editor.block.vue create mode 100644 src/client/app/common/views/components/page-editor/page-editor.button.vue create mode 100644 src/client/app/common/views/components/page-editor/page-editor.container.vue create mode 100644 src/client/app/common/views/components/page-editor/page-editor.image.vue create mode 100644 src/client/app/common/views/components/page-editor/page-editor.input.vue create mode 100644 src/client/app/common/views/components/page-editor/page-editor.script-block.vue create mode 100644 src/client/app/common/views/components/page-editor/page-editor.section.vue create mode 100644 src/client/app/common/views/components/page-editor/page-editor.switch.vue create mode 100644 src/client/app/common/views/components/page-editor/page-editor.text.vue create mode 100644 src/client/app/common/views/components/page-editor/page-editor.vue create mode 100644 src/client/app/common/views/components/page-preview.vue create mode 100644 src/client/app/common/views/pages/page/page.block.vue create mode 100644 src/client/app/common/views/pages/page/page.button.vue create mode 100644 src/client/app/common/views/pages/page/page.image.vue create mode 100644 src/client/app/common/views/pages/page/page.input.vue create mode 100644 src/client/app/common/views/pages/page/page.section.vue create mode 100644 src/client/app/common/views/pages/page/page.switch.vue create mode 100644 src/client/app/common/views/pages/page/page.text.vue create mode 100644 src/client/app/common/views/pages/page/page.vue create mode 100644 src/client/app/desktop/views/home/pages.vue create mode 100644 src/client/app/desktop/views/pages/page-editor.vue create mode 100644 src/client/app/desktop/views/pages/page.vue create mode 100644 src/client/app/mobile/views/pages/page-editor.vue create mode 100644 src/client/app/mobile/views/pages/page.vue create mode 100644 src/client/app/mobile/views/pages/pages.vue create mode 100644 src/models/entities/page.ts create mode 100644 src/models/repositories/page.ts create mode 100644 src/server/api/endpoints/i/pages.ts create mode 100644 src/server/api/endpoints/pages/create.ts create mode 100644 src/server/api/endpoints/pages/delete.ts create mode 100644 src/server/api/endpoints/pages/show.ts create mode 100644 src/server/api/endpoints/pages/update.ts create mode 100644 src/server/web/views/page.pug (limited to 'src') 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; + + 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 = {}): 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 @@ - + + {{ (showCancelButton || input || select || user) ? $t('@.ok') : $t('@.got-it') }} @@ -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 @@ + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@
  • - + {{ $t('profile') }}
  • - + {{ $t('@.drive') }}

  • - + {{ $t('@.favorites') }}
  • - + {{ $t('lists') }}

  • +
  • + + + {{ $t('@.pages') }} + + +
  • - + {{ $t('follow-requests') }}{{ $store.state.i.pendingReceivedFollowRequestsCount }}

    @@ -46,14 +53,14 @@
    • - + {{ $t('@.settings') }}
    • - + {{ $t('admin') }} @@ -76,7 +83,7 @@
      • - + {{ $t('@.signout') }}

      • @@ -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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@
      • {{ $t('@.favorites') }}
      • {{ $t('user-lists') }}
      • {{ $t('@.drive') }}
      • +
      • {{ $t('@.pages') }}
      • {{ $t('search') }}
      • @@ -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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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[]; + + @Column('jsonb', { + default: [] + }) + public variables: Record[]; + + /** + * 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) { + 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; + +@EntityRepository(Page) +export class PageRepository extends Repository { + public async pack( + src: Page, + ): Promise { + const attachedFiles: Promise[] = []; + 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}`) -- cgit v1.2.3-freya