Merge ccdc861e43
into 6c5d3113c6
This commit is contained in:
commit
70e4042c29
|
@ -9288,6 +9288,18 @@ export interface Locale extends ILocale {
|
|||
* 特殊
|
||||
*/
|
||||
"specialBlocks": string;
|
||||
/**
|
||||
* タイトルを入力
|
||||
*/
|
||||
"inputTitleHere": string;
|
||||
/**
|
||||
* ここに移動
|
||||
*/
|
||||
"moveToHere": string;
|
||||
/**
|
||||
* このブロックを削除しますか?
|
||||
*/
|
||||
"blockDeleteAreYouSure": string;
|
||||
"blocks": {
|
||||
/**
|
||||
* テキスト
|
||||
|
|
|
@ -2449,6 +2449,9 @@ _pages:
|
|||
contentBlocks: "コンテンツ"
|
||||
inputBlocks: "入力"
|
||||
specialBlocks: "特殊"
|
||||
inputTitleHere: "タイトルを入力"
|
||||
moveToHere: "ここに移動"
|
||||
blockDeleteAreYouSure: "このブロックを削除しますか?"
|
||||
blocks:
|
||||
text: "テキスト"
|
||||
textarea: "テキストエリア"
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
export const MAX_NOTE_TEXT_LENGTH = 3000;
|
||||
export const MAX_PAGE_CONTENT_BYTES = 1024 * 1024 * 1.5; // 1.5MB
|
||||
|
||||
export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min
|
||||
export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days
|
||||
|
|
|
@ -47,6 +47,7 @@ import { NoteDeleteService } from './NoteDeleteService.js';
|
|||
import { NotePiningService } from './NotePiningService.js';
|
||||
import { NoteReadService } from './NoteReadService.js';
|
||||
import { NotificationService } from './NotificationService.js';
|
||||
import { PageService } from './PageService.js';
|
||||
import { PollService } from './PollService.js';
|
||||
import { PushNotificationService } from './PushNotificationService.js';
|
||||
import { QueryService } from './QueryService.js';
|
||||
|
@ -190,6 +191,7 @@ const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting
|
|||
const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
|
||||
const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService };
|
||||
const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService };
|
||||
const $PageService: Provider = { provide: 'PageService', useExisting: PageService };
|
||||
const $PollService: Provider = { provide: 'PollService', useExisting: PollService };
|
||||
const $ProxyAccountService: Provider = { provide: 'ProxyAccountService', useExisting: ProxyAccountService };
|
||||
const $PushNotificationService: Provider = { provide: 'PushNotificationService', useExisting: PushNotificationService };
|
||||
|
@ -341,6 +343,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
NotePiningService,
|
||||
NoteReadService,
|
||||
NotificationService,
|
||||
PageService,
|
||||
PollService,
|
||||
ProxyAccountService,
|
||||
PushNotificationService,
|
||||
|
@ -488,6 +491,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$NotePiningService,
|
||||
$NoteReadService,
|
||||
$NotificationService,
|
||||
$PageService,
|
||||
$PollService,
|
||||
$ProxyAccountService,
|
||||
$PushNotificationService,
|
||||
|
@ -636,6 +640,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
NotePiningService,
|
||||
NoteReadService,
|
||||
NotificationService,
|
||||
PageService,
|
||||
PollService,
|
||||
ProxyAccountService,
|
||||
PushNotificationService,
|
||||
|
@ -782,6 +787,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$NotePiningService,
|
||||
$NoteReadService,
|
||||
$NotificationService,
|
||||
$PageService,
|
||||
$PollService,
|
||||
$ProxyAccountService,
|
||||
$PushNotificationService,
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import _Ajv from 'ajv';
|
||||
import { type PagesRepository } from '@/models/_.js';
|
||||
import { pageBlockSchema } from '@/models/Page.js';
|
||||
|
||||
/**
|
||||
* ページ関係のService
|
||||
*/
|
||||
@Injectable()
|
||||
export class PageService {
|
||||
constructor(
|
||||
@Inject(DI.pagesRepository)
|
||||
private pagesRepository: PagesRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* ページのコンテンツを検証する.
|
||||
* @param content コンテンツ
|
||||
*/
|
||||
public validatePageContent(content: unknown[]) {
|
||||
const Ajv = _Ajv.default;
|
||||
const ajv = new Ajv({
|
||||
useDefaults: true,
|
||||
});
|
||||
ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/);
|
||||
const validator = ajv.compile({
|
||||
type: 'array',
|
||||
items: pageBlockSchema,
|
||||
});
|
||||
const valid = validator(content);
|
||||
|
||||
if (valid) {
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
valid: false,
|
||||
errors: validator.errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 人気のあるページ一覧を取得する.
|
||||
*/
|
||||
public async featured(opts?: { offset?: number, limit: number }) {
|
||||
const builder = this.pagesRepository.createQueryBuilder('page')
|
||||
.andWhere('page.likedCount > 0')
|
||||
.andWhere('page.visibility = :visibility', { visibility: 'public' })
|
||||
.addOrderBy('page.likedCount', 'DESC')
|
||||
.addOrderBy('page.updatedAt', 'DESC')
|
||||
.addOrderBy('page.id', 'DESC');
|
||||
|
||||
if (opts?.offset) {
|
||||
builder.skip(opts.offset);
|
||||
}
|
||||
|
||||
builder.take(opts?.limit ?? 10);
|
||||
|
||||
return await builder.getMany();
|
||||
}
|
||||
}
|
|
@ -118,3 +118,118 @@ export class MiPage {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const pageNameSchema = { type: 'string', pattern: /^[a-zA-Z0-9_-]{1,256}$/.source } as const;
|
||||
|
||||
//#region ページブロックのスキーマ(バリデーション用)
|
||||
|
||||
/**
|
||||
* 併せてpackedPageBlockSchemaも更新すること
|
||||
* (そっちはAPIの戻り型の定義なので以下の定義とは若干異なる)
|
||||
*
|
||||
* packages/backend/src/models/json-schema/page.ts
|
||||
*/
|
||||
|
||||
const blockBaseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', nullable: false },
|
||||
type: { type: 'string', nullable: false },
|
||||
},
|
||||
required: ['id', 'type'],
|
||||
} as const;
|
||||
|
||||
const textBlockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...blockBaseSchema.properties,
|
||||
type: { type: 'string', nullable: false, enum: ['text'] },
|
||||
text: { type: 'string', nullable: false },
|
||||
},
|
||||
required: [
|
||||
...blockBaseSchema.required,
|
||||
'text',
|
||||
],
|
||||
} as const;
|
||||
|
||||
const headingBlockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...blockBaseSchema.properties,
|
||||
type: { type: 'string', nullable: false, enum: ['heading'] },
|
||||
level: { type: 'number', nullable: false },
|
||||
text: { type: 'string', nullable: false },
|
||||
},
|
||||
required: [
|
||||
...blockBaseSchema.required,
|
||||
'level',
|
||||
'text',
|
||||
],
|
||||
} as const;
|
||||
|
||||
const imageBlockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...blockBaseSchema.properties,
|
||||
type: { type: 'string', nullable: false, enum: ['image'] },
|
||||
fileId: { type: 'string', nullable: true },
|
||||
},
|
||||
required: [
|
||||
...blockBaseSchema.required,
|
||||
'fileId',
|
||||
],
|
||||
} as const;
|
||||
|
||||
const noteBlockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...blockBaseSchema.properties,
|
||||
type: { type: 'string', nullable: false, enum: ['note']},
|
||||
detailed: { type: 'boolean', nullable: true },
|
||||
note: { type: 'string', format: 'misskey:id', nullable: false },
|
||||
},
|
||||
required: [
|
||||
...blockBaseSchema.required,
|
||||
'note',
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** @deprecated 要素を入れ子にする必要が(一旦)なくなったので非推奨。headingBlockを使用すること */
|
||||
const sectionBlockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...blockBaseSchema.properties,
|
||||
type: { type: 'string', nullable: false, enum: ['section'] },
|
||||
title: { type: 'string', nullable: false },
|
||||
children: {
|
||||
type: 'array', nullable: false,
|
||||
items: {
|
||||
oneOf: [
|
||||
textBlockSchema,
|
||||
{ $ref: '#' },
|
||||
headingBlockSchema,
|
||||
imageBlockSchema,
|
||||
noteBlockSchema,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: [
|
||||
...blockBaseSchema.required,
|
||||
'title',
|
||||
'children',
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const pageBlockSchema = {
|
||||
type: 'object',
|
||||
oneOf: [
|
||||
textBlockSchema,
|
||||
sectionBlockSchema,
|
||||
headingBlockSchema,
|
||||
imageBlockSchema,
|
||||
noteBlockSchema,
|
||||
],
|
||||
} as const;
|
||||
|
||||
//#endregion
|
||||
|
|
|
@ -3,7 +3,14 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
const blockBaseSchema = {
|
||||
/**
|
||||
* ページブロックのスキーマを変更したら、併せてpageBlockSchemaも更新すること
|
||||
* (そっちは入力バリデーション用の定義なので以下の定義とは若干異なる)
|
||||
*
|
||||
* packages/backend/src/models/Page.ts
|
||||
*/
|
||||
|
||||
const packedBlockBaseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
|
@ -17,10 +24,10 @@ const blockBaseSchema = {
|
|||
},
|
||||
} as const;
|
||||
|
||||
const textBlockSchema = {
|
||||
const packedTextBlockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...blockBaseSchema.properties,
|
||||
...packedBlockBaseSchema.properties,
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
|
@ -33,10 +40,31 @@ const textBlockSchema = {
|
|||
},
|
||||
} as const;
|
||||
|
||||
const sectionBlockSchema = {
|
||||
const packedHeadingBlockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...blockBaseSchema.properties,
|
||||
...packedBlockBaseSchema.properties,
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ['heading'],
|
||||
},
|
||||
level: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
text: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** @deprecated 要素を入れ子にする必要が(一旦)なくなったので非推奨。headingBlockを使用すること */
|
||||
const packedSectionBlockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...packedBlockBaseSchema.properties,
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
|
@ -59,10 +87,10 @@ const sectionBlockSchema = {
|
|||
},
|
||||
} as const;
|
||||
|
||||
const imageBlockSchema = {
|
||||
const packedImageBlockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...blockBaseSchema.properties,
|
||||
...packedBlockBaseSchema.properties,
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
|
@ -75,10 +103,10 @@ const imageBlockSchema = {
|
|||
},
|
||||
} as const;
|
||||
|
||||
const noteBlockSchema = {
|
||||
const packedNoteBlockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...blockBaseSchema.properties,
|
||||
...packedBlockBaseSchema.properties,
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
|
@ -98,10 +126,11 @@ const noteBlockSchema = {
|
|||
export const packedPageBlockSchema = {
|
||||
type: 'object',
|
||||
oneOf: [
|
||||
textBlockSchema,
|
||||
sectionBlockSchema,
|
||||
imageBlockSchema,
|
||||
noteBlockSchema,
|
||||
packedTextBlockSchema,
|
||||
packedSectionBlockSchema,
|
||||
packedHeadingBlockSchema,
|
||||
packedImageBlockSchema,
|
||||
packedNoteBlockSchema,
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -7,11 +7,13 @@ import ms from 'ms';
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { DriveFilesRepository, PagesRepository } from '@/models/_.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { MiPage } from '@/models/Page.js';
|
||||
import { MiPage, pageNameSchema } from '@/models/Page.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { PageService } from '@/core/PageService.js';
|
||||
import { PageEntityService } from '@/core/entities/PageEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { MAX_PAGE_CONTENT_BYTES } from '@/const.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['pages'],
|
||||
|
@ -44,6 +46,16 @@ export const meta = {
|
|||
code: 'NAME_ALREADY_EXISTS',
|
||||
id: '4650348e-301c-499a-83c9-6aa988c66bc1',
|
||||
},
|
||||
contentTooLarge: {
|
||||
message: 'Content is too large.',
|
||||
code: 'CONTENT_TOO_LARGE',
|
||||
id: '2a93fcc9-4cd7-4885-9e5b-be56ed8f4d4f',
|
||||
},
|
||||
invalidParam: {
|
||||
message: 'Invalid param.',
|
||||
code: 'INVALID_PARAM',
|
||||
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@ -51,9 +63,10 @@ export const paramDef = {
|
|||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
name: { type: 'string', minLength: 1 },
|
||||
name: { ...pageNameSchema, minLength: 1 },
|
||||
summary: { type: 'string', nullable: true },
|
||||
content: { type: 'array', items: {
|
||||
// misskey-jsの型生成に対応していないスキーマを使用しているため別途バリデーションする
|
||||
type: 'object', additionalProperties: true,
|
||||
} },
|
||||
variables: { type: 'array', items: {
|
||||
|
@ -65,7 +78,7 @@ export const paramDef = {
|
|||
alignCenter: { type: 'boolean', default: false },
|
||||
hideTitleWhenPinned: { type: 'boolean', default: false },
|
||||
},
|
||||
required: ['title', 'name', 'content', 'variables', 'script'],
|
||||
required: ['title', 'name', 'content'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
|
@ -77,10 +90,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private pageService: PageService,
|
||||
private pageEntityService: PageEntityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
if (new Blob([JSON.stringify(ps.content)]).size > MAX_PAGE_CONTENT_BYTES) {
|
||||
throw new ApiError(meta.errors.contentTooLarge);
|
||||
}
|
||||
|
||||
const validateResult = this.pageService.validatePageContent(ps.content);
|
||||
if (!validateResult.valid) {
|
||||
const errors = validateResult.errors!;
|
||||
throw new ApiError(meta.errors.invalidParam, {
|
||||
param: errors[0].schemaPath,
|
||||
reason: errors[0].message,
|
||||
});
|
||||
}
|
||||
|
||||
let eyeCatchingImage = null;
|
||||
if (ps.eyeCatchingImageId != null) {
|
||||
eyeCatchingImage = await this.driveFilesRepository.findOneBy({
|
||||
|
@ -109,8 +136,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
name: ps.name,
|
||||
summary: ps.summary,
|
||||
content: ps.content,
|
||||
variables: ps.variables,
|
||||
script: ps.script,
|
||||
//variables: ps.variables, もう使用されていない(動的ページ)
|
||||
//script: ps.script, もう使用されていない(動的ページ)
|
||||
eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null,
|
||||
userId: me.id,
|
||||
visibility: 'public',
|
||||
|
|
|
@ -3,11 +3,10 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { PagesRepository } from '@/models/_.js';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { PageService } from '@/core/PageService.js';
|
||||
import { PageEntityService } from '@/core/entities/PageEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['pages'],
|
||||
|
@ -27,27 +26,25 @@ export const meta = {
|
|||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
properties: {
|
||||
offset: { type: 'integer', minimum: 0, default: 0 },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.pagesRepository)
|
||||
private pagesRepository: PagesRepository,
|
||||
|
||||
private pageService: PageService,
|
||||
private pageEntityService: PageEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.pagesRepository.createQueryBuilder('page')
|
||||
.where('page.visibility = \'public\'')
|
||||
.andWhere('page.likedCount > 0')
|
||||
.orderBy('page.likedCount', 'DESC');
|
||||
|
||||
const pages = await query.limit(10).getMany();
|
||||
|
||||
return await this.pageEntityService.packMany(pages, me);
|
||||
const result = await this.pageService.featured({
|
||||
offset: ps.offset,
|
||||
limit: ps.limit,
|
||||
});
|
||||
return await this.pageEntityService.packMany(result, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,10 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import type { PagesRepository, DriveFilesRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { MAX_PAGE_CONTENT_BYTES } from '@/const.js';
|
||||
import { pageNameSchema } from '@/models/Page.js';
|
||||
import { PageService } from '@/core/PageService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['pages'],
|
||||
|
@ -48,6 +51,16 @@ export const meta = {
|
|||
code: 'NAME_ALREADY_EXISTS',
|
||||
id: '2298a392-d4a1-44c5-9ebb-ac1aeaa5a9ab',
|
||||
},
|
||||
contentTooLarge: {
|
||||
message: 'Content is too large.',
|
||||
code: 'CONTENT_TOO_LARGE',
|
||||
id: '2a93fcc9-4cd7-4885-9e5b-be56ed8f4d4f',
|
||||
},
|
||||
invalidParam: {
|
||||
message: 'Invalid param.',
|
||||
code: 'INVALID_PARAM',
|
||||
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@ -56,9 +69,10 @@ export const paramDef = {
|
|||
properties: {
|
||||
pageId: { type: 'string', format: 'misskey:id' },
|
||||
title: { type: 'string' },
|
||||
name: { type: 'string', minLength: 1 },
|
||||
name: { ...pageNameSchema, minLength: 1 },
|
||||
summary: { type: 'string', nullable: true },
|
||||
content: { type: 'array', items: {
|
||||
// misskey-jsの型生成に対応していないスキーマを使用しているため別途バリデーションする
|
||||
type: 'object', additionalProperties: true,
|
||||
} },
|
||||
variables: { type: 'array', items: {
|
||||
|
@ -81,6 +95,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private pageService: PageService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const page = await this.pagesRepository.findOneBy({ id: ps.pageId });
|
||||
|
@ -91,6 +107,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.accessDenied);
|
||||
}
|
||||
|
||||
if (ps.content != null) {
|
||||
if (new Blob([JSON.stringify(ps.content)]).size > MAX_PAGE_CONTENT_BYTES) {
|
||||
throw new ApiError(meta.errors.contentTooLarge);
|
||||
}
|
||||
|
||||
const validateResult = this.pageService.validatePageContent(ps.content);
|
||||
if (!validateResult.valid) {
|
||||
const errors = validateResult.errors!;
|
||||
throw new ApiError(meta.errors.invalidParam, {
|
||||
param: errors[0].schemaPath,
|
||||
reason: errors[0].message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (ps.eyeCatchingImageId != null) {
|
||||
const eyeCatchingImage = await this.driveFilesRepository.findOneBy({
|
||||
id: ps.eyeCatchingImageId,
|
||||
|
@ -118,8 +149,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
name: ps.name,
|
||||
summary: ps.summary === undefined ? page.summary : ps.summary,
|
||||
content: ps.content,
|
||||
variables: ps.variables,
|
||||
script: ps.script,
|
||||
//variables: ps.variables, もう使用されていない(動的ページ)
|
||||
//script: ps.script, もう使用されていない(動的ページ)
|
||||
alignCenter: ps.alignCenter,
|
||||
hideTitleWhenPinned: ps.hideTitleWhenPinned,
|
||||
font: ps.font,
|
||||
|
|
|
@ -253,9 +253,11 @@ import { isEnabledUrlPreview } from '@/instance.js';
|
|||
import { getAppearNote } from '@/scripts/get-appear-note.js';
|
||||
import { type Keymap } from '@/scripts/hotkey.js';
|
||||
|
||||
type Tab = 'replies' | 'renotes' | 'reactions';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
initialTab: string;
|
||||
initialTab?: Tab;
|
||||
}>(), {
|
||||
initialTab: 'replies',
|
||||
});
|
||||
|
@ -339,7 +341,7 @@ provide('react', (reaction: string) => {
|
|||
});
|
||||
});
|
||||
|
||||
const tab = ref(props.initialTab);
|
||||
const tab = ref<Tab>(props.initialTab);
|
||||
const reactionTabType = ref<string | null>(null);
|
||||
|
||||
const renotesPagination = computed<Paging>(() => ({
|
||||
|
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div ref="rootEl">
|
||||
<div ref="rootEl" :class="$style.root">
|
||||
<div ref="headerEl" :class="$style.header">
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
|
@ -84,8 +84,16 @@ defineExpose({
|
|||
</script>
|
||||
|
||||
<style lang='scss' module>
|
||||
.root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.body {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
z-index: 0;
|
||||
--MI-stickyTop: v-bind("childStickyTop + 'px'");
|
||||
--MI-stickyBottom: v-bind("childStickyBottom + 'px'");
|
||||
|
@ -93,12 +101,14 @@ defineExpose({
|
|||
|
||||
.header {
|
||||
position: sticky;
|
||||
flex-shrink: 0;
|
||||
top: var(--MI-stickyTop, 0);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: sticky;
|
||||
flex-shrink: 0;
|
||||
bottom: var(--MI-stickyBottom, 0);
|
||||
z-index: 1;
|
||||
}
|
||||
|
|
|
@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<!-- eslint-disable vue/no-mutating-props -->
|
||||
<XContainer :draggable="true" @remove="() => emit('remove')">
|
||||
<XContainer
|
||||
:draggable="true"
|
||||
:blockId="modelValue.id"
|
||||
@remove="() => emit('remove')"
|
||||
@move="(direction) => emit('move', direction)"
|
||||
>
|
||||
<template #header><i class="ti ti-photo"></i> {{ i18n.ts._pages.blocks.image }}</template>
|
||||
<template #func>
|
||||
<button @click="choose()">
|
||||
|
@ -36,6 +41,7 @@ const props = defineProps<{
|
|||
const emit = defineEmits<{
|
||||
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'image' }): void;
|
||||
(ev: 'remove'): void;
|
||||
(ev: 'move', direction: 'up' | 'down'): void;
|
||||
}>();
|
||||
|
||||
const file = ref<Misskey.entities.DriveFile | null>(null);
|
||||
|
|
|
@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<!-- eslint-disable vue/no-mutating-props -->
|
||||
<XContainer :draggable="true" @remove="() => emit('remove')">
|
||||
<XContainer
|
||||
:draggable="true"
|
||||
:blockId="modelValue.id"
|
||||
@remove="() => emit('remove')"
|
||||
@move="(direction) => emit('move', direction)"
|
||||
>
|
||||
<template #header><i class="ti ti-note"></i> {{ i18n.ts._pages.blocks.note }}</template>
|
||||
|
||||
<section style="padding: 16px;" class="_gaps_s">
|
||||
|
@ -15,8 +20,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkInput>
|
||||
<MkSwitch v-model="props.modelValue.detailed"><span>{{ i18n.ts._pages.blocks._note.detailed }}</span></MkSwitch>
|
||||
|
||||
<MkNote v-if="note && !props.modelValue.detailed" :key="note.id + ':normal'" v-model:note="note" style="margin-bottom: 16px;"/>
|
||||
<MkNoteDetailed v-if="note && props.modelValue.detailed" :key="note.id + ':detail'" v-model:note="note" style="margin-bottom: 16px;"/>
|
||||
<MkNote v-if="note && !props.modelValue.detailed" :key="note.id + ':normal'" v-model:note="note" :class="$style.note"/>
|
||||
<MkNoteDetailed v-if="note && props.modelValue.detailed" :key="note.id + ':detail'" v-model:note="note" :class="$style.note"/>
|
||||
</section>
|
||||
</XContainer>
|
||||
</template>
|
||||
|
@ -39,6 +44,8 @@ const props = defineProps<{
|
|||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'note' }): void;
|
||||
(ev: 'remove'): void;
|
||||
(ev: 'move', direction: 'up' | 'down'): void;
|
||||
}>();
|
||||
|
||||
const id = ref(props.modelValue.note);
|
||||
|
@ -63,3 +70,11 @@ watch(id, async () => {
|
|||
immediate: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
.note {
|
||||
border-radius: var(--MI-radius);
|
||||
border: 1px solid var(--MI_THEME-divider);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<!-- eslint-disable vue/no-mutating-props -->
|
||||
<XContainer :draggable="true" @remove="() => emit('remove')">
|
||||
<XContainer
|
||||
:draggable="true"
|
||||
:blockId="modelValue.id"
|
||||
@remove="() => emit('remove')"
|
||||
@move="(direction) => emit('move', direction)"
|
||||
>
|
||||
<template #header><i class="ti ti-note"></i> {{ props.modelValue.title }}</template>
|
||||
<template #func>
|
||||
<button class="_button" @click="rename()">
|
||||
|
@ -41,6 +46,7 @@ const props = defineProps<{
|
|||
const emit = defineEmits<{
|
||||
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'section' }): void;
|
||||
(ev: 'remove'): void;
|
||||
(ev: 'move', direction: 'up' | 'down'): void;
|
||||
}>();
|
||||
|
||||
const children = ref(deepClone(props.modelValue.children ?? []));
|
||||
|
|
|
@ -5,19 +5,31 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<!-- eslint-disable vue/no-mutating-props -->
|
||||
<XContainer :draggable="true" @remove="() => emit('remove')">
|
||||
<template #header><i class="ti ti-align-left"></i> {{ i18n.ts._pages.blocks.text }}</template>
|
||||
|
||||
<XContainer
|
||||
:draggable="true"
|
||||
:blockId="modelValue.id"
|
||||
@remove="() => emit('remove')"
|
||||
@move="(direction) => emit('move', direction)"
|
||||
>
|
||||
<template #header><i class="ti ti-align-left"></i></template>
|
||||
<template #actions>
|
||||
<button class="_button" :class="$style.previewToggleRoot" @click="toggleEnablePreview">
|
||||
<MkSwitchButton :class="$style.previewToggleSwitch" :checked="enablePreview" @toggle="toggleEnablePreview"></MkSwitchButton>{{ i18n.ts.preview }}
|
||||
</button>
|
||||
</template>
|
||||
<template #default="{ focus }">
|
||||
<section>
|
||||
<textarea ref="inputEl" v-model="text" :class="$style.textarea"></textarea>
|
||||
<div v-if="enablePreview" ref="previewEl" :class="$style.previewRoot"><Mfm :text="text"></Mfm></div>
|
||||
<textarea v-else ref="inputEl" v-model="text" :class="$style.textarea" @input.passive="calcTextAreaHeight"></textarea>
|
||||
</section>
|
||||
</template>
|
||||
</XContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
import { watch, ref, shallowRef, onMounted, onUnmounted } from 'vue';
|
||||
import { watch, ref, computed, useTemplateRef, onMounted, onUnmounted, nextTick } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkSwitchButton from '@/components/MkSwitch.button.vue';
|
||||
import XContainer from '../page-editor.container.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { Autocomplete } from '@/scripts/autocomplete.js';
|
||||
|
@ -28,12 +40,37 @@ const props = defineProps<{
|
|||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'text' }): void;
|
||||
(ev: 'remove'): void;
|
||||
(ev: 'move', direction: 'up' | 'down'): void;
|
||||
}>();
|
||||
|
||||
let autocomplete: Autocomplete;
|
||||
|
||||
const inputEl = useTemplateRef('inputEl');
|
||||
const inputHeight = ref(150);
|
||||
const previewEl = useTemplateRef('previewEl');
|
||||
const previewHeight = ref(150);
|
||||
const editorHeight = computed(() => Math.max(inputHeight.value, previewHeight.value));
|
||||
|
||||
function calcTextAreaHeight() {
|
||||
if (!inputEl.value) return;
|
||||
inputEl.value.setAttribute('style', 'min-height: auto');
|
||||
inputHeight.value = Math.max(150, inputEl.value.scrollHeight ?? 0);
|
||||
inputEl.value.removeAttribute('style');
|
||||
}
|
||||
|
||||
const enablePreview = ref(false);
|
||||
function toggleEnablePreview() {
|
||||
enablePreview.value = !enablePreview.value;
|
||||
|
||||
if (enablePreview.value === true) {
|
||||
nextTick(() => {
|
||||
previewHeight.value = Math.max(150, previewEl.value?.scrollHeight ?? 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const text = ref(props.modelValue.text ?? '');
|
||||
const inputEl = shallowRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
watch(text, () => {
|
||||
emit('update:modelValue', {
|
||||
|
@ -43,6 +80,7 @@ watch(text, () => {
|
|||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!inputEl.value) return;
|
||||
autocomplete = new Autocomplete(inputEl.value, text);
|
||||
});
|
||||
|
||||
|
@ -59,7 +97,7 @@ onUnmounted(() => {
|
|||
appearance: none;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
min-height: 150px;
|
||||
min-height: v-bind("editorHeight + 'px'");
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 16px;
|
||||
|
@ -68,4 +106,19 @@ onUnmounted(() => {
|
|||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.previewRoot {
|
||||
padding: 16px;
|
||||
min-height: v-bind("editorHeight + 'px'");
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.previewToggleRoot {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.previewToggleSwitch {
|
||||
--height: 1.35em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -2,21 +2,59 @@
|
|||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<Sortable :modelValue="modelValue" tag="div" itemKey="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swapThreshold="0.5" @update:modelValue="v => emit('update:modelValue', v)">
|
||||
<template #item="{element}">
|
||||
<div :class="$style.item">
|
||||
<!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 -->
|
||||
<component :is="getComponent(element.type)" :modelValue="element" @update:modelValue="updateItem" @remove="() => removeItem(element)"/>
|
||||
<div
|
||||
@dragstart.capture="dragStart"
|
||||
@dragend.capture="dragEnd"
|
||||
@drop.capture="dragEnd"
|
||||
>
|
||||
<div
|
||||
data-after-id="__FIRST__"
|
||||
:class="[$style.insertBetweenRoot, {
|
||||
[$style.insertBetweenDraggingOver]: draggingOverAfterId === '__FIRST__' && draggingBlockId !== modelValue[0]?.id,
|
||||
}]"
|
||||
@click="insertNewBlock('__FIRST__')"
|
||||
@dragover="insertBetweenDragOver($event, '__FIRST__')"
|
||||
@dragleave="insertBetweenDragLeave"
|
||||
@drop="insertBetweenDrop($event, '__FIRST__')"
|
||||
>
|
||||
<div :class="$style.insertBetweenBorder"></div>
|
||||
<span :class="$style.insertBetweenText"><i v-if="!isDragging" class="ti ti-plus"></i> {{ isDragging ? i18n.ts._pages.moveToHere : i18n.ts.add }}</span>
|
||||
</div>
|
||||
|
||||
<div v-for="block, index in modelValue" :key="block.id" :class="$style.item">
|
||||
<component
|
||||
:is="getComponent(block.type)"
|
||||
:modelValue="block"
|
||||
@update:modelValue="updateItem"
|
||||
@remove="() => removeItem(block)"
|
||||
@move="(direction: 'up' | 'down') => moveItem(block.id, direction)"
|
||||
/>
|
||||
<div
|
||||
:data-after-id="block.id"
|
||||
:class="[$style.insertBetweenRoot, {
|
||||
[$style.insertBetweenDraggingOver]: draggingOverAfterId === block.id && draggingBlockId !== block.id && draggingBlockId !== modelValue[index + 1]?.id,
|
||||
}]"
|
||||
@click="insertNewBlock(block.id)"
|
||||
@dragover="insertBetweenDragOver($event, block.id, modelValue[index + 1]?.id)"
|
||||
@dragleave="insertBetweenDragLeave"
|
||||
@drop="insertBetweenDrop($event, block.id, modelValue[index + 1]?.id)"
|
||||
>
|
||||
<div :class="$style.insertBetweenBorder"></div>
|
||||
<span :class="$style.insertBetweenText"><i v-if="!isDragging" class="ti ti-plus"></i> {{ isDragging ? i18n.ts._pages.moveToHere : i18n.ts.add }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Sortable>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import { getScrollContainer } from '@@/js/scroll.js';
|
||||
import { getPageBlockList } from '@/pages/page-editor/common.js';
|
||||
import XSection from './els/page-editor.el.section.vue';
|
||||
import XText from './els/page-editor.el.text.vue';
|
||||
import XImage from './els/page-editor.el.image.vue';
|
||||
|
@ -32,9 +70,8 @@ function getComponent(type: string) {
|
|||
}
|
||||
}
|
||||
|
||||
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
|
||||
|
||||
const props = defineProps<{
|
||||
scrollContainer?: HTMLElement | null;
|
||||
modelValue: Misskey.entities.Page['content'];
|
||||
}>();
|
||||
|
||||
|
@ -42,7 +79,151 @@ const emit = defineEmits<{
|
|||
(ev: 'update:modelValue', value: Misskey.entities.Page['content']): void;
|
||||
}>();
|
||||
|
||||
function updateItem(v) {
|
||||
const isDragging = ref(false);
|
||||
const draggingOverAfterId = ref<string | null>(null);
|
||||
const draggingBlockId = ref<string | null>(null);
|
||||
|
||||
function dragStart(ev: DragEvent) {
|
||||
if (ev.target instanceof HTMLElement) {
|
||||
const blockId = ev.target.dataset.blockId;
|
||||
if (blockId != null) {
|
||||
console.log('dragStart', blockId);
|
||||
isDragging.value = true;
|
||||
draggingBlockId.value = blockId;
|
||||
document.addEventListener('dragover', watchForMouseMove);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function dragEnd() {
|
||||
isDragging.value = false;
|
||||
draggingBlockId.value = null;
|
||||
document.removeEventListener('dragover', watchForMouseMove);
|
||||
}
|
||||
|
||||
function watchForMouseMove(ev: DragEvent) {
|
||||
if (isDragging.value) {
|
||||
// 画面上部・下部1/4のときにスクロールする
|
||||
const scrollContainer = getScrollContainer(props.scrollContainer ?? null) ?? document.scrollingElement;
|
||||
if (scrollContainer != null) {
|
||||
const rect = scrollContainer.getBoundingClientRect();
|
||||
const y = ev.clientY - rect.top;
|
||||
const h = rect.height;
|
||||
const scrollSpeed = 30;
|
||||
if (y < h / 4) {
|
||||
const acceralation = Math.max(0, 1 - (y / (h / 4)));
|
||||
scrollContainer.scrollBy({
|
||||
top: -scrollSpeed * acceralation,
|
||||
});
|
||||
} else if (y > (h / 4 * 3)) {
|
||||
const acceralation = Math.max(0, 1 - ((h - y) / (h / 4)));
|
||||
scrollContainer.scrollBy({
|
||||
top: scrollSpeed * acceralation,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function insertBetweenDragOver(ev: DragEvent, id: string, nextId?: string) {
|
||||
if (
|
||||
draggingBlockId.value === id ||
|
||||
draggingBlockId.value === nextId ||
|
||||
![...(ev.dataTransfer?.types ?? [])].includes('application/x-misskey-pageblock-id')
|
||||
) return;
|
||||
|
||||
ev.preventDefault();
|
||||
if (ev.target instanceof HTMLElement) {
|
||||
const afterId = ev.target.dataset.afterId;
|
||||
if (afterId != null) {
|
||||
draggingOverAfterId.value = afterId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function insertBetweenDragLeave() {
|
||||
draggingOverAfterId.value = null;
|
||||
}
|
||||
|
||||
function insertBetweenDrop(ev: DragEvent, id: string, nextId?: string) {
|
||||
if (
|
||||
draggingBlockId.value === id ||
|
||||
draggingBlockId.value === nextId ||
|
||||
![...(ev.dataTransfer?.types ?? [])].includes('application/x-misskey-pageblock-id')
|
||||
) return;
|
||||
|
||||
ev.preventDefault();
|
||||
if (ev.target instanceof HTMLElement) {
|
||||
const afterId = ev.target.dataset.afterId; // insert after this
|
||||
const moveId = ev.dataTransfer?.getData('application/x-misskey-pageblock-id');
|
||||
if (afterId != null && moveId != null) {
|
||||
const oldValue = props.modelValue.filter((x) => x.id !== moveId);
|
||||
const afterIdAt = afterId === '__FIRST__' ? 0 : oldValue.findIndex((x) => x.id === afterId);
|
||||
const movingBlock = props.modelValue.find((x) => x.id === moveId);
|
||||
if (afterId === '__FIRST__' && movingBlock != null) {
|
||||
const newValue = [
|
||||
movingBlock,
|
||||
...oldValue,
|
||||
];
|
||||
emit('update:modelValue', newValue);
|
||||
} else if (afterIdAt >= 0 && movingBlock != null) {
|
||||
const newValue = [
|
||||
...oldValue.slice(0, afterIdAt + 1),
|
||||
movingBlock,
|
||||
...oldValue.slice(afterIdAt + 1),
|
||||
];
|
||||
emit('update:modelValue', newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
isDragging.value = false;
|
||||
draggingOverAfterId.value = null;
|
||||
}
|
||||
|
||||
async function insertNewBlock(id: string) {
|
||||
const { canceled, result: type } = await os.select({
|
||||
title: i18n.ts._pages.chooseBlock,
|
||||
items: getPageBlockList(),
|
||||
});
|
||||
if (canceled || type == null) return;
|
||||
|
||||
const blockId = uuid();
|
||||
|
||||
let newValue: Misskey.entities.Page['content'];
|
||||
|
||||
if (id === '__FIRST__') {
|
||||
newValue = [
|
||||
{ id: blockId, type },
|
||||
...props.modelValue,
|
||||
];
|
||||
} else {
|
||||
const afterIdAt = props.modelValue.findIndex((x) => x.id === id);
|
||||
newValue = [
|
||||
...props.modelValue.slice(0, afterIdAt + 1),
|
||||
{ id: blockId, type },
|
||||
...props.modelValue.slice(afterIdAt + 1),
|
||||
];
|
||||
}
|
||||
|
||||
emit('update:modelValue', newValue);
|
||||
}
|
||||
|
||||
function moveItem(id: string, direction: 'up' | 'down') {
|
||||
const i = props.modelValue.findIndex(x => x.id === id);
|
||||
if (i === -1) return;
|
||||
|
||||
const newValue = [...props.modelValue];
|
||||
const [removed] = newValue.splice(i, 1);
|
||||
if (direction === 'up') {
|
||||
newValue.splice(i - 1, 0, removed);
|
||||
} else {
|
||||
newValue.splice(i + 1, 0, removed);
|
||||
}
|
||||
|
||||
emit('update:modelValue', newValue);
|
||||
}
|
||||
|
||||
function updateItem(v: Misskey.entities.PageBlock) {
|
||||
const i = props.modelValue.findIndex(x => x.id === v.id);
|
||||
const newValue = [
|
||||
...props.modelValue.slice(0, i),
|
||||
|
@ -52,8 +233,8 @@ function updateItem(v) {
|
|||
emit('update:modelValue', newValue);
|
||||
}
|
||||
|
||||
function removeItem(el) {
|
||||
const i = props.modelValue.findIndex(x => x.id === el.id);
|
||||
function removeItem(v: Misskey.entities.PageBlock) {
|
||||
const i = props.modelValue.findIndex(x => x.id === v.id);
|
||||
const newValue = [
|
||||
...props.modelValue.slice(0, i),
|
||||
...props.modelValue.slice(i + 1),
|
||||
|
@ -63,9 +244,62 @@ function removeItem(el) {
|
|||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.item {
|
||||
& + .item {
|
||||
margin-top: 16px;
|
||||
.insertBetweenRoot {
|
||||
height: calc(var(--MI-margin) * 2);
|
||||
width: 100%;
|
||||
padding: 8px 0;
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
|
||||
.insertBetweenBorder {
|
||||
display: block;
|
||||
}
|
||||
.insertBetweenText {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.insertBetweenBorder {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translateY(-50%);
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
border-radius: 2px;
|
||||
background-color: var(--MI_THEME-accent);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.insertBetweenText {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--MI_THEME-fgOnAccent);
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
line-height: 24px;
|
||||
border-radius: 999px;
|
||||
display: none;
|
||||
background-color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
.insertBetweenBorder,
|
||||
.insertBetweenText {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.insertBetweenDraggingOver {
|
||||
.insertBetweenBorder {
|
||||
display: block;
|
||||
}
|
||||
.insertBetweenText {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -4,33 +4,67 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div class="cpjygsrt">
|
||||
<header>
|
||||
<div class="title"><slot name="header"></slot></div>
|
||||
<div class="buttons">
|
||||
<slot name="func"></slot>
|
||||
<button v-if="removable" class="_button" @click="remove()">
|
||||
<div
|
||||
ref="containerRootEl"
|
||||
:class="[$style.blockContainerRoot, {
|
||||
[$style.dragging]: isDragging,
|
||||
[$style.draggingOver]: isDraggingOver,
|
||||
}]"
|
||||
@focus.capture="toggleFocus"
|
||||
@blur.capture="toggleFocus"
|
||||
@dragover="dragOver"
|
||||
@dragleave="dragLeave"
|
||||
@drop="drop"
|
||||
>
|
||||
<header :class="$style.blockContainerHeader" tabindex="1">
|
||||
<div :class="$style.title"><slot name="header"></slot></div>
|
||||
<div :class="$style.buttons">
|
||||
<div v-if="$slots.actions != null"><slot name="actions"></slot></div>
|
||||
<button v-if="removable" :class="$style.blockContainerActionButton" class="_button" @click="remove()">
|
||||
<i class="ti ti-trash"></i>
|
||||
</button>
|
||||
<button v-if="draggable" class="drag-handle _button">
|
||||
<template v-if="draggable">
|
||||
<div :class="$style.divider"></div>
|
||||
<button
|
||||
:class="$style.blockContainerActionButton"
|
||||
class="_button"
|
||||
@click="() => emit('move', 'up')"
|
||||
>
|
||||
<i class="ti ti-arrow-up"></i>
|
||||
</button>
|
||||
<button
|
||||
:class="$style.blockContainerActionButton"
|
||||
class="_button"
|
||||
@click="() => emit('move', 'down')"
|
||||
>
|
||||
<i class="ti ti-arrow-down"></i>
|
||||
</button>
|
||||
<button
|
||||
draggable="true"
|
||||
:class="$style.blockContainerActionButton"
|
||||
class="_button"
|
||||
:data-block-id="blockId"
|
||||
@dragstart="dragStart"
|
||||
@dragend="dragEnd"
|
||||
>
|
||||
<i class="ti ti-menu-2"></i>
|
||||
</button>
|
||||
<button class="_button" @click="toggleContent(!showBody)">
|
||||
<template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
|
||||
<template v-else><i class="ti ti-chevron-down"></i></template>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
<div v-show="showBody" class="body">
|
||||
<slot></slot>
|
||||
<div :class="$style.blockContainerBody" tabindex="0">
|
||||
<slot :focus="focus"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
blockId: string;
|
||||
expanded?: boolean;
|
||||
removable?: boolean;
|
||||
draggable?: boolean;
|
||||
|
@ -40,24 +74,103 @@ const props = withDefaults(defineProps<{
|
|||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'toggle', show: boolean): void;
|
||||
(ev: 'remove'): void;
|
||||
(ev: 'move', direction: 'up' | 'down'): void;
|
||||
}>();
|
||||
|
||||
const showBody = ref(props.expanded);
|
||||
async function remove() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.ts._pages.blockDeleteAreYouSure,
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
function toggleContent(show: boolean) {
|
||||
showBody.value = show;
|
||||
emit('toggle', show);
|
||||
emit('remove');
|
||||
}
|
||||
|
||||
function remove() {
|
||||
emit('remove');
|
||||
const containerRootEl = useTemplateRef('containerRootEl');
|
||||
const focus = ref(false);
|
||||
function toggleFocus() {
|
||||
focus.value = containerRootEl.value?.contains(document.activeElement) ?? false;
|
||||
}
|
||||
|
||||
const isDragging = ref(false);
|
||||
function dragStart(ev: DragEvent) {
|
||||
ev.dataTransfer?.setData('application/x-misskey-pageblock-id', props.blockId);
|
||||
isDragging.value = true;
|
||||
}
|
||||
function dragEnd() {
|
||||
isDragging.value = false;
|
||||
}
|
||||
|
||||
const isDraggingOver = ref(false);
|
||||
function dragOver(ev: DragEvent) {
|
||||
if (isDragging.value) {
|
||||
// ブロックの中にドロップできるのは自分自身だけ
|
||||
ev.preventDefault();
|
||||
isDraggingOver.value = true;
|
||||
}
|
||||
}
|
||||
function dragLeave() {
|
||||
isDraggingOver.value = false;
|
||||
}
|
||||
function drop() {
|
||||
// 自分自身しかドロップできないので何もしない
|
||||
isDraggingOver.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.cpjygsrt {
|
||||
<style lang="scss" module>
|
||||
.blockContainerRoot {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.blockContainerHeader {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: translateY(-100%);
|
||||
z-index: 1;
|
||||
display: none;
|
||||
gap: 8px;
|
||||
|
||||
height: 42px;
|
||||
padding: 6px 8px;
|
||||
background-color: var(--MI_THEME-panel);
|
||||
border: 2px solid var(--MI_THEME-accent);
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
|
||||
> .title {
|
||||
line-height: 26px;
|
||||
padding-left: 2px;
|
||||
padding-right: 8px;
|
||||
border-right: 0.5px solid var(--MI_THEME-divider);
|
||||
}
|
||||
|
||||
> .buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
> .divider {
|
||||
width: 0.5px;
|
||||
height: 26px;
|
||||
background-color: var(--MI_THEME-divider);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.blockContainerActionButton {
|
||||
display: block;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.blockContainerBody {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--MI_THEME-panel);
|
||||
|
@ -67,62 +180,45 @@ function remove() {
|
|||
&:hover {
|
||||
border: solid 2px var(--MI_THEME-X13);
|
||||
}
|
||||
|
||||
&.warn {
|
||||
border: solid 2px #dec44c;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border: solid 2px #f00;
|
||||
}
|
||||
|
||||
> header {
|
||||
> .title {
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
line-height: 42px;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 1px rgba(#000, 0.07);
|
||||
|
||||
> i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .buttons {
|
||||
.blockContainerRoot.dragging {
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
> button {
|
||||
padding: 0;
|
||||
width: 42px;
|
||||
font-size: 0.9em;
|
||||
line-height: 42px;
|
||||
bottom: 0;
|
||||
background: var(--MI_THEME-bg);
|
||||
z-index: 1;
|
||||
border-radius: 8px 0 8px 8px;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: move;
|
||||
&.draggingOver::after {
|
||||
outline: dashed 2px var(--MI_THEME-accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.blockContainerHeader {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.blockContainerBody {
|
||||
border: solid 2px var(--MI_THEME-accent);
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
> .body {
|
||||
::v-deep(.juejbjww), ::v-deep(.eiipwacr) {
|
||||
&:not(.inline):first-child {
|
||||
margin-top: 28px;
|
||||
@container (min-width: 700px) {
|
||||
.blockContainerRoot:focus-within {
|
||||
.blockContainerHeader {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&:not(.inline):last-child {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.blockContainerBody {
|
||||
border: solid 2px var(--MI_THEME-accent);
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,64 +4,59 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :contentMax="700">
|
||||
<div class="jqqmcavi">
|
||||
<MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="ti ti-external-link"></i> {{ i18n.ts._pages.viewPage }}</MkButton>
|
||||
<MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
|
||||
<MkButton v-if="pageId" inline class="button" @click="duplicate"><i class="ti ti-copy"></i> {{ i18n.ts.duplicate }}</MkButton>
|
||||
<MkButton v-if="pageId && !readonly" inline class="button" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
|
||||
<MkStickyContainer ref="containerEl">
|
||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :contentMax="800">
|
||||
<div v-if="fetchStatus === 'loading'">
|
||||
<MkLoading/>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'settings'">
|
||||
<div class="_gaps_m">
|
||||
<MkInput v-model="title">
|
||||
<template #label>{{ i18n.ts._pages.title }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput v-model="summary">
|
||||
<template #label>{{ i18n.ts._pages.summary }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput v-model="name">
|
||||
<template #prefix>{{ url }}/@{{ author.username }}/pages/</template>
|
||||
<template #label>{{ i18n.ts._pages.url }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkSwitch v-model="alignCenter">{{ i18n.ts._pages.alignCenter }}</MkSwitch>
|
||||
|
||||
<MkSelect v-model="font">
|
||||
<template #label>{{ i18n.ts._pages.font }}</template>
|
||||
<option value="serif">{{ i18n.ts._pages.fontSerif }}</option>
|
||||
<option value="sans-serif">{{ i18n.ts._pages.fontSansSerif }}</option>
|
||||
</MkSelect>
|
||||
|
||||
<MkSwitch v-model="hideTitleWhenPinned">{{ i18n.ts._pages.hideTitleWhenPinned }}</MkSwitch>
|
||||
|
||||
<div class="eyeCatch">
|
||||
<MkButton v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage"><i class="ti ti-plus"></i> {{ i18n.ts._pages.eyeCatchingImageSet }}</MkButton>
|
||||
<div v-else-if="eyeCatchingImage">
|
||||
<img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name" style="max-width: 100%;"/>
|
||||
<MkButton v-if="!readonly" @click="removeEyeCatchingImage()"><i class="ti ti-trash"></i> {{ i18n.ts._pages.eyeCatchingImageRemove }}</MkButton>
|
||||
<div v-else-if="fetchStatus === 'done' && page != null" class="_gaps" :class="$style.pageMain">
|
||||
<div :class="$style.pageBanner">
|
||||
<div v-if="page?.eyeCatchingImageId" :class="$style.pageBannerImage">
|
||||
<MkMediaImage
|
||||
:image="page.eyeCatchingImage!"
|
||||
:cover="true"
|
||||
:disableImageLink="true"
|
||||
:class="$style.thumbnail"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.pageBannerTitle" class="_gaps_s">
|
||||
<input v-model="title" :class="$style.titleForm" :placeholder="i18n.ts._pages.inputTitleHere"/>
|
||||
<div :class="$style.pageBannerTitleSub">
|
||||
<div v-if="page?.user" :class="$style.pageBannerTitleUser">
|
||||
<MkAvatar :user="page.user" :class="$style.avatar" indicator/> <MkUserName :user="page.user" :nowrap="false"/>
|
||||
</div>
|
||||
<div :class="$style.pageBannerTitleSubActions">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<XPage v-if="enableGlobalPreview" key="preview" :page="page" />
|
||||
<XBlocks v-else key="editor" v-model="content" :scrollContainer="containerEl?.rootEl"/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="tab === 'contents'">
|
||||
<div :class="$style.contents">
|
||||
<XBlocks v-model="content" class="content"/>
|
||||
|
||||
<MkButton v-if="!readonly" rounded class="add" @click="add()"><i class="ti ti-plus"></i></MkButton>
|
||||
</div>
|
||||
<div v-else-if="fetchStatus === 'notMe'" class="_fullInfo">
|
||||
This page is not yours
|
||||
</div>
|
||||
</MkSpacer>
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<div :class="$style.footerInner">
|
||||
<div :class="$style.footerActionSwitchWrapper">
|
||||
<MkSwitch v-model="enableGlobalPreview">{{ i18n.ts.preview }}</MkSwitch>
|
||||
</div>
|
||||
<div :class="$style.footerActionButtons" class="_buttons">
|
||||
<MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, provide, watch, ref } from 'vue';
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import XBlocks from './page-editor.blocks.vue';
|
||||
|
@ -69,189 +64,85 @@ import MkButton from '@/components/MkButton.vue';
|
|||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkMediaImage from '@/components/MkMediaImage.vue';
|
||||
import XPage from '@/components/page/page.vue';
|
||||
import { url } from '@@/js/config.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { selectFile } from '@/scripts/select-file.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { signinRequired } from '@/account.js';
|
||||
import { mainRouter } from '@/router/main.js';
|
||||
import { getPageBlockList } from '@/pages/page-editor/common.js';
|
||||
import type { SlimPage } from '@/types/page.js';
|
||||
|
||||
const props = defineProps<{
|
||||
initPageId?: string;
|
||||
initPageName?: string;
|
||||
initUser?: string;
|
||||
}>();
|
||||
|
||||
const tab = ref('settings');
|
||||
const author = ref($i);
|
||||
const readonly = ref(false);
|
||||
const page = ref<Misskey.entities.Page | null>(null);
|
||||
const pageId = ref<string | null>(null);
|
||||
const currentName = ref<string | null>(null);
|
||||
const title = ref('');
|
||||
const summary = ref<string | null>(null);
|
||||
const name = ref(Date.now().toString());
|
||||
const eyeCatchingImage = ref<Misskey.entities.DriveFile | null>(null);
|
||||
const eyeCatchingImageId = ref<string | null>(null);
|
||||
const font = ref('sans-serif');
|
||||
const content = ref<Misskey.entities.Page['content']>([]);
|
||||
const alignCenter = ref(false);
|
||||
const hideTitleWhenPinned = ref(false);
|
||||
const $i = signinRequired();
|
||||
|
||||
provide('readonly', readonly.value);
|
||||
|
||||
watch(eyeCatchingImageId, async () => {
|
||||
if (eyeCatchingImageId.value == null) {
|
||||
eyeCatchingImage.value = null;
|
||||
const fetchStatus = ref<'loading' | 'done' | 'notMe'>('loading');
|
||||
const page = ref<Partial<SlimPage> | null>(null);
|
||||
const title = computed({
|
||||
get: () => page.value?.title ?? '',
|
||||
set: (value) => {
|
||||
if (page.value) {
|
||||
page.value.title = value;
|
||||
} else {
|
||||
eyeCatchingImage.value = await misskeyApi('drive/files/show', {
|
||||
fileId: eyeCatchingImageId.value,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function getSaveOptions() {
|
||||
return {
|
||||
title: title.value.trim(),
|
||||
name: name.value.trim(),
|
||||
summary: summary.value,
|
||||
font: font.value,
|
||||
script: '',
|
||||
hideTitleWhenPinned: hideTitleWhenPinned.value,
|
||||
alignCenter: alignCenter.value,
|
||||
content: content.value,
|
||||
variables: [],
|
||||
eyeCatchingImageId: eyeCatchingImageId.value,
|
||||
page.value = {
|
||||
title: value,
|
||||
};
|
||||
}
|
||||
|
||||
function save() {
|
||||
const options = getSaveOptions();
|
||||
|
||||
const onError = err => {
|
||||
if (err.id === '3d81ceae-475f-4600-b2a8-2bc116157532') {
|
||||
if (err.info.param === 'name') {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts._pages.invalidNameTitle,
|
||||
text: i18n.ts._pages.invalidNameText,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (err.code === 'NAME_ALREADY_EXISTS') {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts._pages.nameAlreadyExists,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (pageId.value) {
|
||||
options.pageId = pageId.value;
|
||||
misskeyApi('pages/update', options)
|
||||
.then(page => {
|
||||
currentName.value = name.value.trim();
|
||||
os.alert({
|
||||
type: 'success',
|
||||
text: i18n.ts._pages.updated,
|
||||
});
|
||||
}).catch(onError);
|
||||
const content = computed<Misskey.entities.Page['content']>({
|
||||
get: () => page.value?.content ?? [],
|
||||
set: (value) => {
|
||||
if (page.value) {
|
||||
page.value.content = value;
|
||||
} else {
|
||||
misskeyApi('pages/create', options)
|
||||
.then(created => {
|
||||
pageId.value = created.id;
|
||||
currentName.value = name.value.trim();
|
||||
os.alert({
|
||||
type: 'success',
|
||||
text: i18n.ts._pages.created,
|
||||
});
|
||||
mainRouter.push(`/pages/edit/${pageId.value}`);
|
||||
}).catch(onError);
|
||||
page.value = {
|
||||
content: value,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const enableGlobalPreview = ref(false);
|
||||
|
||||
const containerEl = useTemplateRef('containerEl');
|
||||
|
||||
function onTitleUpdated(ev: Event) {
|
||||
title.value = (ev.target as HTMLDivElement).innerText;
|
||||
}
|
||||
|
||||
function del() {
|
||||
os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.tsx.removeAreYouSure({ x: title.value.trim() }),
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) return;
|
||||
misskeyApi('pages/delete', {
|
||||
pageId: pageId.value,
|
||||
}).then(() => {
|
||||
os.alert({
|
||||
type: 'success',
|
||||
text: i18n.ts._pages.deleted,
|
||||
});
|
||||
mainRouter.push('/pages');
|
||||
});
|
||||
});
|
||||
async function save() {
|
||||
|
||||
}
|
||||
|
||||
function duplicate() {
|
||||
title.value = title.value + ' - copy';
|
||||
name.value = name.value + '-copy';
|
||||
misskeyApi('pages/create', getSaveOptions()).then(created => {
|
||||
pageId.value = created.id;
|
||||
currentName.value = name.value.trim();
|
||||
os.alert({
|
||||
type: 'success',
|
||||
text: i18n.ts._pages.created,
|
||||
});
|
||||
mainRouter.push(`/pages/edit/${pageId.value}`);
|
||||
});
|
||||
async function show() {
|
||||
|
||||
}
|
||||
|
||||
async function add() {
|
||||
const { canceled, result: type } = await os.select({
|
||||
type: null,
|
||||
title: i18n.ts._pages.chooseBlock,
|
||||
items: getPageBlockList(),
|
||||
});
|
||||
if (canceled) return;
|
||||
async function del() {
|
||||
|
||||
const id = uuid();
|
||||
content.value.push({ id, type });
|
||||
}
|
||||
|
||||
function setEyeCatchingImage(img) {
|
||||
selectFile(img.currentTarget ?? img.target, null).then(file => {
|
||||
eyeCatchingImageId.value = file.id;
|
||||
});
|
||||
}
|
||||
|
||||
function removeEyeCatchingImage() {
|
||||
eyeCatchingImageId.value = null;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (props.initPageId) {
|
||||
page.value = await misskeyApi('pages/show', {
|
||||
const _page = await misskeyApi('pages/show', {
|
||||
pageId: props.initPageId,
|
||||
});
|
||||
} else if (props.initPageName && props.initUser) {
|
||||
page.value = await misskeyApi('pages/show', {
|
||||
name: props.initPageName,
|
||||
username: props.initUser,
|
||||
});
|
||||
readonly.value = true;
|
||||
if (_page.user.id !== $i.id) {
|
||||
fetchStatus.value = 'notMe';
|
||||
return;
|
||||
}
|
||||
page.value = _page;
|
||||
}
|
||||
|
||||
if (page.value) {
|
||||
author.value = page.value.user;
|
||||
pageId.value = page.value.id;
|
||||
title.value = page.value.title;
|
||||
name.value = page.value.name;
|
||||
currentName.value = page.value.name;
|
||||
summary.value = page.value.summary;
|
||||
font.value = page.value.font;
|
||||
hideTitleWhenPinned.value = page.value.hideTitleWhenPinned;
|
||||
alignCenter.value = page.value.alignCenter;
|
||||
content.value = page.value.content;
|
||||
eyeCatchingImageId.value = page.value.eyeCatchingImageId;
|
||||
} else {
|
||||
if (page.value === null) {
|
||||
const id = uuid();
|
||||
content.value = [{
|
||||
id,
|
||||
|
@ -259,126 +150,152 @@ async function init() {
|
|||
text: 'Hello World!',
|
||||
}];
|
||||
}
|
||||
|
||||
fetchStatus.value = 'done';
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => [{
|
||||
key: 'settings',
|
||||
title: i18n.ts._pages.pageSetting,
|
||||
icon: 'ti ti-settings',
|
||||
}, {
|
||||
key: 'contents',
|
||||
title: i18n.ts._pages.contents,
|
||||
icon: 'ti ti-note',
|
||||
}]);
|
||||
const headerTabs = computed(() => []);
|
||||
|
||||
definePageMetadata(() => ({
|
||||
title: props.initPageId ? i18n.ts._pages.editPage
|
||||
: props.initPageName && props.initUser ? i18n.ts._pages.readPage
|
||||
: i18n.ts._pages.newPage,
|
||||
title: props.initPageId ? i18n.ts._pages.editPage : i18n.ts._pages.newPage,
|
||||
icon: 'ti ti-pencil',
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.contents {
|
||||
&:global {
|
||||
> .add {
|
||||
margin: 16px auto 0 auto;
|
||||
.pageMain {
|
||||
border-radius: var(--MI-radius);
|
||||
padding: 2rem;
|
||||
background: var(--MI_THEME-panel);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.jqqmcavi {
|
||||
margin-bottom: 16px;
|
||||
|
||||
> .button {
|
||||
& + .button {
|
||||
margin-left: 8px;
|
||||
.pageBanner {
|
||||
width: calc(100% + 4rem);
|
||||
margin: -2rem -2rem 0.5rem;
|
||||
border-radius: var(--MI-radius) var(--MI-radius) 0 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pageBannerImage {
|
||||
position: relative;
|
||||
padding-top: 56.25%;
|
||||
|
||||
> .thumbnail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.gwbmwxkm {
|
||||
.pageBannerTitle {
|
||||
position: relative;
|
||||
|
||||
> header {
|
||||
> .title {
|
||||
z-index: 1;
|
||||
.titleForm {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
padding: 6px 12px;
|
||||
font: inherit;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--MI_THEME-fg);
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
line-height: 42px;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 1px rgba(#000, 0.07);
|
||||
border: none;
|
||||
border-bottom: 2px solid var(--MI_THEME-divider);
|
||||
transition: border-color 0.1s ease-out;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
border-radius: var(--MI-radius) var(--MI-radius) 0 0;
|
||||
|
||||
> i {
|
||||
margin-right: 6px;
|
||||
&:hover {
|
||||
border-color: var(--MI_THEME-inputBorderHover);
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--MI_THEME-focus);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
> .buttons {
|
||||
position: absolute;
|
||||
.pageBannerTitleSub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pageBannerTitleUser {
|
||||
--height: 32px;
|
||||
flex-shrink: 0;
|
||||
line-height: var(--height);
|
||||
|
||||
.avatar {
|
||||
height: var(--height);
|
||||
width: var(--height);
|
||||
}
|
||||
}
|
||||
|
||||
.pageBannerTitleSubActions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--MI-marginHalf);
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.editorMenu {
|
||||
position: sticky;
|
||||
top: var(--MI-stickyTop, 0px);
|
||||
left: 0;
|
||||
width: calc(100% + 4rem);
|
||||
margin: 0 -2rem 0;
|
||||
backdrop-filter: var(--MI-blur, blur(15px));
|
||||
background: var(--MI_THEME-acrylicBg);
|
||||
border-bottom: solid .5px var(--MI_THEME-divider);
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
> button {
|
||||
padding: 0;
|
||||
width: 42px;
|
||||
font-size: 0.9em;
|
||||
line-height: 42px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> section {
|
||||
padding: 0 32px 32px 32px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 0 16px 16px 16px;
|
||||
}
|
||||
|
||||
> .view {
|
||||
display: inline-block;
|
||||
margin: 16px 0 0 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
> .content {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
> .eyeCatch {
|
||||
margin-bottom: 16px;
|
||||
|
||||
> div {
|
||||
> img {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qmuvgica {
|
||||
.editorMenuInner {
|
||||
padding: 16px;
|
||||
|
||||
> .variables {
|
||||
margin-bottom: 16px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
> .add {
|
||||
margin-bottom: 16px;
|
||||
.footer {
|
||||
backdrop-filter: var(--MI-blur, blur(15px));
|
||||
background: var(--MI_THEME-acrylicBg);
|
||||
border-top: solid .5px var(--MI_THEME-divider);
|
||||
}
|
||||
|
||||
.footerInner {
|
||||
padding: 16px;
|
||||
margin: 0 auto;
|
||||
max-width: 800px;
|
||||
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footerActionSwitchWrapper {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footerActionButtons {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,384 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :contentMax="700">
|
||||
<div class="jqqmcavi">
|
||||
<MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="ti ti-external-link"></i> {{ i18n.ts._pages.viewPage }}</MkButton>
|
||||
<MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
|
||||
<MkButton v-if="pageId" inline class="button" @click="duplicate"><i class="ti ti-copy"></i> {{ i18n.ts.duplicate }}</MkButton>
|
||||
<MkButton v-if="pageId && !readonly" inline class="button" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'settings'">
|
||||
<div class="_gaps_m">
|
||||
<MkInput v-model="title">
|
||||
<template #label>{{ i18n.ts._pages.title }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput v-model="summary">
|
||||
<template #label>{{ i18n.ts._pages.summary }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput v-model="name">
|
||||
<template #prefix>{{ url }}/@{{ author.username }}/pages/</template>
|
||||
<template #label>{{ i18n.ts._pages.url }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkSwitch v-model="alignCenter">{{ i18n.ts._pages.alignCenter }}</MkSwitch>
|
||||
|
||||
<MkSelect v-model="font">
|
||||
<template #label>{{ i18n.ts._pages.font }}</template>
|
||||
<option value="serif">{{ i18n.ts._pages.fontSerif }}</option>
|
||||
<option value="sans-serif">{{ i18n.ts._pages.fontSansSerif }}</option>
|
||||
</MkSelect>
|
||||
|
||||
<MkSwitch v-model="hideTitleWhenPinned">{{ i18n.ts._pages.hideTitleWhenPinned }}</MkSwitch>
|
||||
|
||||
<div class="eyeCatch">
|
||||
<MkButton v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage"><i class="ti ti-plus"></i> {{ i18n.ts._pages.eyeCatchingImageSet }}</MkButton>
|
||||
<div v-else-if="eyeCatchingImage">
|
||||
<img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name" style="max-width: 100%;"/>
|
||||
<MkButton v-if="!readonly" @click="removeEyeCatchingImage()"><i class="ti ti-trash"></i> {{ i18n.ts._pages.eyeCatchingImageRemove }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="tab === 'contents'">
|
||||
<div :class="$style.contents">
|
||||
<XBlocks v-model="content" class="content"/>
|
||||
|
||||
<MkButton v-if="!readonly" rounded class="add" @click="add()"><i class="ti ti-plus"></i></MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, provide, watch, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import XBlocks from './page-editor.blocks.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import { url } from '@@/js/config.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { selectFile } from '@/scripts/select-file.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { mainRouter } from '@/router/main.js';
|
||||
import { getPageBlockList } from '@/pages/page-editor/common.js';
|
||||
|
||||
const props = defineProps<{
|
||||
initPageId?: string;
|
||||
initPageName?: string;
|
||||
initUser?: string;
|
||||
}>();
|
||||
|
||||
const tab = ref('settings');
|
||||
const author = ref($i);
|
||||
const readonly = ref(false);
|
||||
const page = ref<Misskey.entities.Page | null>(null);
|
||||
const pageId = ref<string | null>(null);
|
||||
const currentName = ref<string | null>(null);
|
||||
const title = ref('');
|
||||
const summary = ref<string | null>(null);
|
||||
const name = ref(Date.now().toString());
|
||||
const eyeCatchingImage = ref<Misskey.entities.DriveFile | null>(null);
|
||||
const eyeCatchingImageId = ref<string | null>(null);
|
||||
const font = ref('sans-serif');
|
||||
const content = ref<Misskey.entities.Page['content']>([]);
|
||||
const alignCenter = ref(false);
|
||||
const hideTitleWhenPinned = ref(false);
|
||||
|
||||
provide('readonly', readonly.value);
|
||||
|
||||
watch(eyeCatchingImageId, async () => {
|
||||
if (eyeCatchingImageId.value == null) {
|
||||
eyeCatchingImage.value = null;
|
||||
} else {
|
||||
eyeCatchingImage.value = await misskeyApi('drive/files/show', {
|
||||
fileId: eyeCatchingImageId.value,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function getSaveOptions() {
|
||||
return {
|
||||
title: title.value.trim(),
|
||||
name: name.value.trim(),
|
||||
summary: summary.value,
|
||||
font: font.value,
|
||||
script: '',
|
||||
hideTitleWhenPinned: hideTitleWhenPinned.value,
|
||||
alignCenter: alignCenter.value,
|
||||
content: content.value,
|
||||
variables: [],
|
||||
eyeCatchingImageId: eyeCatchingImageId.value,
|
||||
};
|
||||
}
|
||||
|
||||
function save() {
|
||||
const options = getSaveOptions();
|
||||
|
||||
const onError = err => {
|
||||
if (err.id === '3d81ceae-475f-4600-b2a8-2bc116157532') {
|
||||
if (err.info.param === 'name') {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts._pages.invalidNameTitle,
|
||||
text: i18n.ts._pages.invalidNameText,
|
||||
});
|
||||
}
|
||||
} else if (err.code === 'NAME_ALREADY_EXISTS') {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts._pages.nameAlreadyExists,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (pageId.value) {
|
||||
options.pageId = pageId.value;
|
||||
misskeyApi('pages/update', options)
|
||||
.then(page => {
|
||||
currentName.value = name.value.trim();
|
||||
os.alert({
|
||||
type: 'success',
|
||||
text: i18n.ts._pages.updated,
|
||||
});
|
||||
}).catch(onError);
|
||||
} else {
|
||||
misskeyApi('pages/create', options)
|
||||
.then(created => {
|
||||
pageId.value = created.id;
|
||||
currentName.value = name.value.trim();
|
||||
os.alert({
|
||||
type: 'success',
|
||||
text: i18n.ts._pages.created,
|
||||
});
|
||||
mainRouter.push(`/pages/edit/${pageId.value}`);
|
||||
}).catch(onError);
|
||||
}
|
||||
}
|
||||
|
||||
function del() {
|
||||
os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.tsx.removeAreYouSure({ x: title.value.trim() }),
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) return;
|
||||
misskeyApi('pages/delete', {
|
||||
pageId: pageId.value,
|
||||
}).then(() => {
|
||||
os.alert({
|
||||
type: 'success',
|
||||
text: i18n.ts._pages.deleted,
|
||||
});
|
||||
mainRouter.push('/pages');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function duplicate() {
|
||||
title.value = title.value + ' - copy';
|
||||
name.value = name.value + '-copy';
|
||||
misskeyApi('pages/create', getSaveOptions()).then(created => {
|
||||
pageId.value = created.id;
|
||||
currentName.value = name.value.trim();
|
||||
os.alert({
|
||||
type: 'success',
|
||||
text: i18n.ts._pages.created,
|
||||
});
|
||||
mainRouter.push(`/pages/edit/${pageId.value}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function add() {
|
||||
const { canceled, result: type } = await os.select({
|
||||
type: null,
|
||||
title: i18n.ts._pages.chooseBlock,
|
||||
items: getPageBlockList(),
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
const id = uuid();
|
||||
content.value.push({ id, type });
|
||||
}
|
||||
|
||||
function setEyeCatchingImage(img) {
|
||||
selectFile(img.currentTarget ?? img.target, null).then(file => {
|
||||
eyeCatchingImageId.value = file.id;
|
||||
});
|
||||
}
|
||||
|
||||
function removeEyeCatchingImage() {
|
||||
eyeCatchingImageId.value = null;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (props.initPageId) {
|
||||
page.value = await misskeyApi('pages/show', {
|
||||
pageId: props.initPageId,
|
||||
});
|
||||
} else if (props.initPageName && props.initUser) {
|
||||
page.value = await misskeyApi('pages/show', {
|
||||
name: props.initPageName,
|
||||
username: props.initUser,
|
||||
});
|
||||
readonly.value = true;
|
||||
}
|
||||
|
||||
if (page.value) {
|
||||
author.value = page.value.user;
|
||||
pageId.value = page.value.id;
|
||||
title.value = page.value.title;
|
||||
name.value = page.value.name;
|
||||
currentName.value = page.value.name;
|
||||
summary.value = page.value.summary;
|
||||
font.value = page.value.font;
|
||||
hideTitleWhenPinned.value = page.value.hideTitleWhenPinned;
|
||||
alignCenter.value = page.value.alignCenter;
|
||||
content.value = page.value.content;
|
||||
eyeCatchingImageId.value = page.value.eyeCatchingImageId;
|
||||
} else {
|
||||
const id = uuid();
|
||||
content.value = [{
|
||||
id,
|
||||
type: 'text',
|
||||
text: 'Hello World!',
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => [{
|
||||
key: 'settings',
|
||||
title: i18n.ts._pages.pageSetting,
|
||||
icon: 'ti ti-settings',
|
||||
}, {
|
||||
key: 'contents',
|
||||
title: i18n.ts._pages.contents,
|
||||
icon: 'ti ti-note',
|
||||
}]);
|
||||
|
||||
definePageMetadata(() => ({
|
||||
title: props.initPageId ? i18n.ts._pages.editPage
|
||||
: props.initPageName && props.initUser ? i18n.ts._pages.readPage
|
||||
: i18n.ts._pages.newPage,
|
||||
icon: 'ti ti-pencil',
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.contents {
|
||||
&:global {
|
||||
> .add {
|
||||
margin: 16px auto 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.jqqmcavi {
|
||||
margin-bottom: 16px;
|
||||
|
||||
> .button {
|
||||
& + .button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gwbmwxkm {
|
||||
position: relative;
|
||||
|
||||
> header {
|
||||
> .title {
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
line-height: 42px;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 1px rgba(#000, 0.07);
|
||||
|
||||
> i {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> section {
|
||||
padding: 0 32px 32px 32px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 0 16px 16px 16px;
|
||||
}
|
||||
|
||||
> .view {
|
||||
display: inline-block;
|
||||
margin: 16px 0 0 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
> .content {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
> .eyeCatch {
|
||||
margin-bottom: 16px;
|
||||
|
||||
> div {
|
||||
> img {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qmuvgica {
|
||||
padding: 16px;
|
||||
|
||||
> .variables {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
> .add {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -285,10 +285,6 @@ function showMenu(ev: MouseEvent) {
|
|||
}
|
||||
} else if ($i && $i.id !== page.value.userId) {
|
||||
menuItems.push({
|
||||
icon: 'ti ti-code',
|
||||
text: i18n.ts._pages.viewSource,
|
||||
action: () => router.push(`/@${props.username}/pages/${props.pageName}/view-source`),
|
||||
}, {
|
||||
icon: 'ti ti-exclamation-circle',
|
||||
text: i18n.ts.reportAbuse,
|
||||
action: reportAbuse,
|
||||
|
|
|
@ -53,7 +53,8 @@ const tab = ref('featured');
|
|||
|
||||
const featuredPagesPagination = {
|
||||
endpoint: 'pages/featured' as const,
|
||||
noPaging: true,
|
||||
limit: 5,
|
||||
offsetMode: true,
|
||||
};
|
||||
const myPagesPagination = {
|
||||
endpoint: 'i/pages' as const,
|
||||
|
|
|
@ -17,9 +17,6 @@ export const page = (loader: AsyncComponentLoader) => defineAsyncComponent({
|
|||
});
|
||||
|
||||
const routes: RouteDef[] = [{
|
||||
path: '/@:initUser/pages/:initPageName/view-source',
|
||||
component: page(() => import('@/pages/page-editor/page-editor.vue')),
|
||||
}, {
|
||||
path: '/@:username/pages/:pageName',
|
||||
component: page(() => import('@/pages/page.vue')),
|
||||
}, {
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as Misskey from 'misskey-js';
|
||||
|
||||
export type SlimPage = Pick<Misskey.entities.Page,
|
||||
'alignCenter' |
|
||||
'attachedFiles' |
|
||||
'content' |
|
||||
'eyeCatchingImage' |
|
||||
'eyeCatchingImageId' |
|
||||
'font' |
|
||||
'title' |
|
||||
'user' |
|
||||
'userId'
|
||||
>;
|
|
@ -1686,6 +1686,7 @@ declare namespace entities {
|
|||
PagesCreateRequest,
|
||||
PagesCreateResponse,
|
||||
PagesDeleteRequest,
|
||||
PagesFeaturedRequest,
|
||||
PagesFeaturedResponse,
|
||||
PagesLikeRequest,
|
||||
PagesShowRequest,
|
||||
|
@ -2849,6 +2850,9 @@ type PagesCreateResponse = operations['pages___create']['responses']['200']['con
|
|||
// @public (undocumented)
|
||||
type PagesDeleteRequest = operations['pages___delete']['requestBody']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type PagesFeaturedRequest = operations['pages___featured']['requestBody']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type PagesFeaturedResponse = operations['pages___featured']['responses']['200']['content']['application/json'];
|
||||
|
||||
|
|
|
@ -461,6 +461,7 @@ import type {
|
|||
PagesCreateRequest,
|
||||
PagesCreateResponse,
|
||||
PagesDeleteRequest,
|
||||
PagesFeaturedRequest,
|
||||
PagesFeaturedResponse,
|
||||
PagesLikeRequest,
|
||||
PagesShowRequest,
|
||||
|
@ -891,7 +892,7 @@ export type Endpoints = {
|
|||
'page-push': { req: PagePushRequest; res: EmptyResponse };
|
||||
'pages/create': { req: PagesCreateRequest; res: PagesCreateResponse };
|
||||
'pages/delete': { req: PagesDeleteRequest; res: EmptyResponse };
|
||||
'pages/featured': { req: EmptyRequest; res: PagesFeaturedResponse };
|
||||
'pages/featured': { req: PagesFeaturedRequest; res: PagesFeaturedResponse };
|
||||
'pages/like': { req: PagesLikeRequest; res: EmptyResponse };
|
||||
'pages/show': { req: PagesShowRequest; res: PagesShowResponse };
|
||||
'pages/unlike': { req: PagesUnlikeRequest; res: EmptyResponse };
|
||||
|
|
|
@ -464,6 +464,7 @@ export type PagePushRequest = operations['page-push']['requestBody']['content'][
|
|||
export type PagesCreateRequest = operations['pages___create']['requestBody']['content']['application/json'];
|
||||
export type PagesCreateResponse = operations['pages___create']['responses']['200']['content']['application/json'];
|
||||
export type PagesDeleteRequest = operations['pages___delete']['requestBody']['content']['application/json'];
|
||||
export type PagesFeaturedRequest = operations['pages___featured']['requestBody']['content']['application/json'];
|
||||
export type PagesFeaturedResponse = operations['pages___featured']['responses']['200']['content']['application/json'];
|
||||
export type PagesLikeRequest = operations['pages___like']['requestBody']['content']['application/json'];
|
||||
export type PagesShowRequest = operations['pages___show']['requestBody']['content']['application/json'];
|
||||
|
|
|
@ -4554,6 +4554,12 @@ export type components = {
|
|||
type: 'section';
|
||||
title: string;
|
||||
children: components['schemas']['PageBlock'][];
|
||||
}, {
|
||||
id: string;
|
||||
/** @enum {string} */
|
||||
type: 'heading';
|
||||
level: number;
|
||||
text: string;
|
||||
}, {
|
||||
id: string;
|
||||
/** @enum {string} */
|
||||
|
@ -23501,10 +23507,10 @@ export type operations = {
|
|||
content: {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
variables: {
|
||||
variables?: {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
script: string;
|
||||
script?: string;
|
||||
/** Format: misskey:id */
|
||||
eyeCatchingImageId?: string | null;
|
||||
/**
|
||||
|
@ -23623,6 +23629,16 @@ export type operations = {
|
|||
* **Credential required**: *No*
|
||||
*/
|
||||
pages___featured: {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
/** @default 0 */
|
||||
offset?: number;
|
||||
/** @default 10 */
|
||||
limit?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK (with results) */
|
||||
200: {
|
||||
|
|
Loading…
Reference in New Issue