Merge ccdc861e43
into 6c5d3113c6
This commit is contained in:
commit
70e4042c29
|
@ -9288,6 +9288,18 @@ export interface Locale extends ILocale {
|
||||||
* 特殊
|
* 特殊
|
||||||
*/
|
*/
|
||||||
"specialBlocks": string;
|
"specialBlocks": string;
|
||||||
|
/**
|
||||||
|
* タイトルを入力
|
||||||
|
*/
|
||||||
|
"inputTitleHere": string;
|
||||||
|
/**
|
||||||
|
* ここに移動
|
||||||
|
*/
|
||||||
|
"moveToHere": string;
|
||||||
|
/**
|
||||||
|
* このブロックを削除しますか?
|
||||||
|
*/
|
||||||
|
"blockDeleteAreYouSure": string;
|
||||||
"blocks": {
|
"blocks": {
|
||||||
/**
|
/**
|
||||||
* テキスト
|
* テキスト
|
||||||
|
|
|
@ -2449,6 +2449,9 @@ _pages:
|
||||||
contentBlocks: "コンテンツ"
|
contentBlocks: "コンテンツ"
|
||||||
inputBlocks: "入力"
|
inputBlocks: "入力"
|
||||||
specialBlocks: "特殊"
|
specialBlocks: "特殊"
|
||||||
|
inputTitleHere: "タイトルを入力"
|
||||||
|
moveToHere: "ここに移動"
|
||||||
|
blockDeleteAreYouSure: "このブロックを削除しますか?"
|
||||||
blocks:
|
blocks:
|
||||||
text: "テキスト"
|
text: "テキスト"
|
||||||
textarea: "テキストエリア"
|
textarea: "テキストエリア"
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const MAX_NOTE_TEXT_LENGTH = 3000;
|
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_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min
|
||||||
export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days
|
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 { NotePiningService } from './NotePiningService.js';
|
||||||
import { NoteReadService } from './NoteReadService.js';
|
import { NoteReadService } from './NoteReadService.js';
|
||||||
import { NotificationService } from './NotificationService.js';
|
import { NotificationService } from './NotificationService.js';
|
||||||
|
import { PageService } from './PageService.js';
|
||||||
import { PollService } from './PollService.js';
|
import { PollService } from './PollService.js';
|
||||||
import { PushNotificationService } from './PushNotificationService.js';
|
import { PushNotificationService } from './PushNotificationService.js';
|
||||||
import { QueryService } from './QueryService.js';
|
import { QueryService } from './QueryService.js';
|
||||||
|
@ -190,6 +191,7 @@ const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting
|
||||||
const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
|
const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
|
||||||
const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService };
|
const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService };
|
||||||
const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService };
|
const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService };
|
||||||
|
const $PageService: Provider = { provide: 'PageService', useExisting: PageService };
|
||||||
const $PollService: Provider = { provide: 'PollService', useExisting: PollService };
|
const $PollService: Provider = { provide: 'PollService', useExisting: PollService };
|
||||||
const $ProxyAccountService: Provider = { provide: 'ProxyAccountService', useExisting: ProxyAccountService };
|
const $ProxyAccountService: Provider = { provide: 'ProxyAccountService', useExisting: ProxyAccountService };
|
||||||
const $PushNotificationService: Provider = { provide: 'PushNotificationService', useExisting: PushNotificationService };
|
const $PushNotificationService: Provider = { provide: 'PushNotificationService', useExisting: PushNotificationService };
|
||||||
|
@ -341,6 +343,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
NotePiningService,
|
NotePiningService,
|
||||||
NoteReadService,
|
NoteReadService,
|
||||||
NotificationService,
|
NotificationService,
|
||||||
|
PageService,
|
||||||
PollService,
|
PollService,
|
||||||
ProxyAccountService,
|
ProxyAccountService,
|
||||||
PushNotificationService,
|
PushNotificationService,
|
||||||
|
@ -488,6 +491,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
$NotePiningService,
|
$NotePiningService,
|
||||||
$NoteReadService,
|
$NoteReadService,
|
||||||
$NotificationService,
|
$NotificationService,
|
||||||
|
$PageService,
|
||||||
$PollService,
|
$PollService,
|
||||||
$ProxyAccountService,
|
$ProxyAccountService,
|
||||||
$PushNotificationService,
|
$PushNotificationService,
|
||||||
|
@ -636,6 +640,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
NotePiningService,
|
NotePiningService,
|
||||||
NoteReadService,
|
NoteReadService,
|
||||||
NotificationService,
|
NotificationService,
|
||||||
|
PageService,
|
||||||
PollService,
|
PollService,
|
||||||
ProxyAccountService,
|
ProxyAccountService,
|
||||||
PushNotificationService,
|
PushNotificationService,
|
||||||
|
@ -782,6 +787,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
$NotePiningService,
|
$NotePiningService,
|
||||||
$NoteReadService,
|
$NoteReadService,
|
||||||
$NotificationService,
|
$NotificationService,
|
||||||
|
$PageService,
|
||||||
$PollService,
|
$PollService,
|
||||||
$ProxyAccountService,
|
$ProxyAccountService,
|
||||||
$PushNotificationService,
|
$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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const blockBaseSchema = {
|
/**
|
||||||
|
* ページブロックのスキーマを変更したら、併せてpageBlockSchemaも更新すること
|
||||||
|
* (そっちは入力バリデーション用の定義なので以下の定義とは若干異なる)
|
||||||
|
*
|
||||||
|
* packages/backend/src/models/Page.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
const packedBlockBaseSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
id: {
|
id: {
|
||||||
|
@ -17,10 +24,10 @@ const blockBaseSchema = {
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const textBlockSchema = {
|
const packedTextBlockSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
...blockBaseSchema.properties,
|
...packedBlockBaseSchema.properties,
|
||||||
type: {
|
type: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
|
@ -33,10 +40,31 @@ const textBlockSchema = {
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const sectionBlockSchema = {
|
const packedHeadingBlockSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
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: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
|
@ -59,10 +87,10 @@ const sectionBlockSchema = {
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const imageBlockSchema = {
|
const packedImageBlockSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
...blockBaseSchema.properties,
|
...packedBlockBaseSchema.properties,
|
||||||
type: {
|
type: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
|
@ -75,10 +103,10 @@ const imageBlockSchema = {
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const noteBlockSchema = {
|
const packedNoteBlockSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
...blockBaseSchema.properties,
|
...packedBlockBaseSchema.properties,
|
||||||
type: {
|
type: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
|
@ -98,10 +126,11 @@ const noteBlockSchema = {
|
||||||
export const packedPageBlockSchema = {
|
export const packedPageBlockSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
oneOf: [
|
oneOf: [
|
||||||
textBlockSchema,
|
packedTextBlockSchema,
|
||||||
sectionBlockSchema,
|
packedSectionBlockSchema,
|
||||||
imageBlockSchema,
|
packedHeadingBlockSchema,
|
||||||
noteBlockSchema,
|
packedImageBlockSchema,
|
||||||
|
packedNoteBlockSchema,
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
|
@ -7,11 +7,13 @@ import ms from 'ms';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { DriveFilesRepository, PagesRepository } from '@/models/_.js';
|
import type { DriveFilesRepository, PagesRepository } from '@/models/_.js';
|
||||||
import { IdService } from '@/core/IdService.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 { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
|
import { PageService } from '@/core/PageService.js';
|
||||||
import { PageEntityService } from '@/core/entities/PageEntityService.js';
|
import { PageEntityService } from '@/core/entities/PageEntityService.js';
|
||||||
import { DI } from '@/di-symbols.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 = {
|
export const meta = {
|
||||||
tags: ['pages'],
|
tags: ['pages'],
|
||||||
|
@ -44,6 +46,16 @@ export const meta = {
|
||||||
code: 'NAME_ALREADY_EXISTS',
|
code: 'NAME_ALREADY_EXISTS',
|
||||||
id: '4650348e-301c-499a-83c9-6aa988c66bc1',
|
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;
|
} as const;
|
||||||
|
|
||||||
|
@ -51,9 +63,10 @@ export const paramDef = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
title: { type: 'string' },
|
title: { type: 'string' },
|
||||||
name: { type: 'string', minLength: 1 },
|
name: { ...pageNameSchema, minLength: 1 },
|
||||||
summary: { type: 'string', nullable: true },
|
summary: { type: 'string', nullable: true },
|
||||||
content: { type: 'array', items: {
|
content: { type: 'array', items: {
|
||||||
|
// misskey-jsの型生成に対応していないスキーマを使用しているため別途バリデーションする
|
||||||
type: 'object', additionalProperties: true,
|
type: 'object', additionalProperties: true,
|
||||||
} },
|
} },
|
||||||
variables: { type: 'array', items: {
|
variables: { type: 'array', items: {
|
||||||
|
@ -65,7 +78,7 @@ export const paramDef = {
|
||||||
alignCenter: { type: 'boolean', default: false },
|
alignCenter: { type: 'boolean', default: false },
|
||||||
hideTitleWhenPinned: { type: 'boolean', default: false },
|
hideTitleWhenPinned: { type: 'boolean', default: false },
|
||||||
},
|
},
|
||||||
required: ['title', 'name', 'content', 'variables', 'script'],
|
required: ['title', 'name', 'content'],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -77,10 +90,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
@Inject(DI.driveFilesRepository)
|
@Inject(DI.driveFilesRepository)
|
||||||
private driveFilesRepository: DriveFilesRepository,
|
private driveFilesRepository: DriveFilesRepository,
|
||||||
|
|
||||||
|
private pageService: PageService,
|
||||||
private pageEntityService: PageEntityService,
|
private pageEntityService: PageEntityService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
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;
|
let eyeCatchingImage = null;
|
||||||
if (ps.eyeCatchingImageId != null) {
|
if (ps.eyeCatchingImageId != null) {
|
||||||
eyeCatchingImage = await this.driveFilesRepository.findOneBy({
|
eyeCatchingImage = await this.driveFilesRepository.findOneBy({
|
||||||
|
@ -109,8 +136,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
name: ps.name,
|
name: ps.name,
|
||||||
summary: ps.summary,
|
summary: ps.summary,
|
||||||
content: ps.content,
|
content: ps.content,
|
||||||
variables: ps.variables,
|
//variables: ps.variables, もう使用されていない(動的ページ)
|
||||||
script: ps.script,
|
//script: ps.script, もう使用されていない(動的ページ)
|
||||||
eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null,
|
eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null,
|
||||||
userId: me.id,
|
userId: me.id,
|
||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
|
|
|
@ -3,11 +3,10 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import type { PagesRepository } from '@/models/_.js';
|
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
|
import { PageService } from '@/core/PageService.js';
|
||||||
import { PageEntityService } from '@/core/entities/PageEntityService.js';
|
import { PageEntityService } from '@/core/entities/PageEntityService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['pages'],
|
tags: ['pages'],
|
||||||
|
@ -27,27 +26,25 @@ export const meta = {
|
||||||
|
|
||||||
export const paramDef = {
|
export const paramDef = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {},
|
properties: {
|
||||||
|
offset: { type: 'integer', minimum: 0, default: 0 },
|
||||||
|
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||||
|
},
|
||||||
required: [],
|
required: [],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.pagesRepository)
|
private pageService: PageService,
|
||||||
private pagesRepository: PagesRepository,
|
|
||||||
|
|
||||||
private pageEntityService: PageEntityService,
|
private pageEntityService: PageEntityService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const query = this.pagesRepository.createQueryBuilder('page')
|
const result = await this.pageService.featured({
|
||||||
.where('page.visibility = \'public\'')
|
offset: ps.offset,
|
||||||
.andWhere('page.likedCount > 0')
|
limit: ps.limit,
|
||||||
.orderBy('page.likedCount', 'DESC');
|
});
|
||||||
|
return await this.pageEntityService.packMany(result, me);
|
||||||
const pages = await query.limit(10).getMany();
|
|
||||||
|
|
||||||
return await this.pageEntityService.packMany(pages, me);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,10 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { PagesRepository, DriveFilesRepository } from '@/models/_.js';
|
import type { PagesRepository, DriveFilesRepository } from '@/models/_.js';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { DI } from '@/di-symbols.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 = {
|
export const meta = {
|
||||||
tags: ['pages'],
|
tags: ['pages'],
|
||||||
|
@ -48,6 +51,16 @@ export const meta = {
|
||||||
code: 'NAME_ALREADY_EXISTS',
|
code: 'NAME_ALREADY_EXISTS',
|
||||||
id: '2298a392-d4a1-44c5-9ebb-ac1aeaa5a9ab',
|
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;
|
} as const;
|
||||||
|
|
||||||
|
@ -56,9 +69,10 @@ export const paramDef = {
|
||||||
properties: {
|
properties: {
|
||||||
pageId: { type: 'string', format: 'misskey:id' },
|
pageId: { type: 'string', format: 'misskey:id' },
|
||||||
title: { type: 'string' },
|
title: { type: 'string' },
|
||||||
name: { type: 'string', minLength: 1 },
|
name: { ...pageNameSchema, minLength: 1 },
|
||||||
summary: { type: 'string', nullable: true },
|
summary: { type: 'string', nullable: true },
|
||||||
content: { type: 'array', items: {
|
content: { type: 'array', items: {
|
||||||
|
// misskey-jsの型生成に対応していないスキーマを使用しているため別途バリデーションする
|
||||||
type: 'object', additionalProperties: true,
|
type: 'object', additionalProperties: true,
|
||||||
} },
|
} },
|
||||||
variables: { type: 'array', items: {
|
variables: { type: 'array', items: {
|
||||||
|
@ -81,6 +95,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
@Inject(DI.driveFilesRepository)
|
@Inject(DI.driveFilesRepository)
|
||||||
private driveFilesRepository: DriveFilesRepository,
|
private driveFilesRepository: DriveFilesRepository,
|
||||||
|
|
||||||
|
private pageService: PageService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const page = await this.pagesRepository.findOneBy({ id: ps.pageId });
|
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);
|
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) {
|
if (ps.eyeCatchingImageId != null) {
|
||||||
const eyeCatchingImage = await this.driveFilesRepository.findOneBy({
|
const eyeCatchingImage = await this.driveFilesRepository.findOneBy({
|
||||||
id: ps.eyeCatchingImageId,
|
id: ps.eyeCatchingImageId,
|
||||||
|
@ -118,8 +149,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
name: ps.name,
|
name: ps.name,
|
||||||
summary: ps.summary === undefined ? page.summary : ps.summary,
|
summary: ps.summary === undefined ? page.summary : ps.summary,
|
||||||
content: ps.content,
|
content: ps.content,
|
||||||
variables: ps.variables,
|
//variables: ps.variables, もう使用されていない(動的ページ)
|
||||||
script: ps.script,
|
//script: ps.script, もう使用されていない(動的ページ)
|
||||||
alignCenter: ps.alignCenter,
|
alignCenter: ps.alignCenter,
|
||||||
hideTitleWhenPinned: ps.hideTitleWhenPinned,
|
hideTitleWhenPinned: ps.hideTitleWhenPinned,
|
||||||
font: ps.font,
|
font: ps.font,
|
||||||
|
|
|
@ -253,9 +253,11 @@ import { isEnabledUrlPreview } from '@/instance.js';
|
||||||
import { getAppearNote } from '@/scripts/get-appear-note.js';
|
import { getAppearNote } from '@/scripts/get-appear-note.js';
|
||||||
import { type Keymap } from '@/scripts/hotkey.js';
|
import { type Keymap } from '@/scripts/hotkey.js';
|
||||||
|
|
||||||
|
type Tab = 'replies' | 'renotes' | 'reactions';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
note: Misskey.entities.Note;
|
note: Misskey.entities.Note;
|
||||||
initialTab: string;
|
initialTab?: Tab;
|
||||||
}>(), {
|
}>(), {
|
||||||
initialTab: 'replies',
|
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 reactionTabType = ref<string | null>(null);
|
||||||
|
|
||||||
const renotesPagination = computed<Paging>(() => ({
|
const renotesPagination = computed<Paging>(() => ({
|
||||||
|
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="rootEl">
|
<div ref="rootEl" :class="$style.root">
|
||||||
<div ref="headerEl" :class="$style.header">
|
<div ref="headerEl" :class="$style.header">
|
||||||
<slot name="header"></slot>
|
<slot name="header"></slot>
|
||||||
</div>
|
</div>
|
||||||
|
@ -84,8 +84,16 @@ defineExpose({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang='scss' module>
|
<style lang='scss' module>
|
||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex-grow: 1;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
--MI-stickyTop: v-bind("childStickyTop + 'px'");
|
--MI-stickyTop: v-bind("childStickyTop + 'px'");
|
||||||
--MI-stickyBottom: v-bind("childStickyBottom + 'px'");
|
--MI-stickyBottom: v-bind("childStickyBottom + 'px'");
|
||||||
|
@ -93,12 +101,14 @@ defineExpose({
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
|
flex-shrink: 0;
|
||||||
top: var(--MI-stickyTop, 0);
|
top: var(--MI-stickyTop, 0);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
|
flex-shrink: 0;
|
||||||
bottom: var(--MI-stickyBottom, 0);
|
bottom: var(--MI-stickyBottom, 0);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- eslint-disable vue/no-mutating-props -->
|
<!-- 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 #header><i class="ti ti-photo"></i> {{ i18n.ts._pages.blocks.image }}</template>
|
||||||
<template #func>
|
<template #func>
|
||||||
<button @click="choose()">
|
<button @click="choose()">
|
||||||
|
@ -36,6 +41,7 @@ const props = defineProps<{
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'image' }): void;
|
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'image' }): void;
|
||||||
(ev: 'remove'): void;
|
(ev: 'remove'): void;
|
||||||
|
(ev: 'move', direction: 'up' | 'down'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const file = ref<Misskey.entities.DriveFile | null>(null);
|
const file = ref<Misskey.entities.DriveFile | null>(null);
|
||||||
|
|
|
@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- eslint-disable vue/no-mutating-props -->
|
<!-- 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>
|
<template #header><i class="ti ti-note"></i> {{ i18n.ts._pages.blocks.note }}</template>
|
||||||
|
|
||||||
<section style="padding: 16px;" class="_gaps_s">
|
<section style="padding: 16px;" class="_gaps_s">
|
||||||
|
@ -15,8 +20,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</MkInput>
|
</MkInput>
|
||||||
<MkSwitch v-model="props.modelValue.detailed"><span>{{ i18n.ts._pages.blocks._note.detailed }}</span></MkSwitch>
|
<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;"/>
|
<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" style="margin-bottom: 16px;"/>
|
<MkNoteDetailed v-if="note && props.modelValue.detailed" :key="note.id + ':detail'" v-model:note="note" :class="$style.note"/>
|
||||||
</section>
|
</section>
|
||||||
</XContainer>
|
</XContainer>
|
||||||
</template>
|
</template>
|
||||||
|
@ -39,6 +44,8 @@ const props = defineProps<{
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'note' }): void;
|
(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);
|
const id = ref(props.modelValue.note);
|
||||||
|
@ -63,3 +70,11 @@ watch(id, async () => {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
});
|
});
|
||||||
</script>
|
</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>
|
<template>
|
||||||
<!-- eslint-disable vue/no-mutating-props -->
|
<!-- 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 #header><i class="ti ti-note"></i> {{ props.modelValue.title }}</template>
|
||||||
<template #func>
|
<template #func>
|
||||||
<button class="_button" @click="rename()">
|
<button class="_button" @click="rename()">
|
||||||
|
@ -41,6 +46,7 @@ const props = defineProps<{
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'section' }): void;
|
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'section' }): void;
|
||||||
(ev: 'remove'): void;
|
(ev: 'remove'): void;
|
||||||
|
(ev: 'move', direction: 'up' | 'down'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const children = ref(deepClone(props.modelValue.children ?? []));
|
const children = ref(deepClone(props.modelValue.children ?? []));
|
||||||
|
|
|
@ -5,19 +5,31 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- eslint-disable vue/no-mutating-props -->
|
<!-- eslint-disable vue/no-mutating-props -->
|
||||||
<XContainer :draggable="true" @remove="() => emit('remove')">
|
<XContainer
|
||||||
<template #header><i class="ti ti-align-left"></i> {{ i18n.ts._pages.blocks.text }}</template>
|
:draggable="true"
|
||||||
|
:blockId="modelValue.id"
|
||||||
<section>
|
@remove="() => emit('remove')"
|
||||||
<textarea ref="inputEl" v-model="text" :class="$style.textarea"></textarea>
|
@move="(direction) => emit('move', direction)"
|
||||||
</section>
|
>
|
||||||
|
<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>
|
||||||
|
<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>
|
</XContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { watch, ref, computed, useTemplateRef, onMounted, onUnmounted, nextTick } from 'vue';
|
||||||
import { watch, ref, shallowRef, onMounted, onUnmounted } from 'vue';
|
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
|
import MkSwitchButton from '@/components/MkSwitch.button.vue';
|
||||||
import XContainer from '../page-editor.container.vue';
|
import XContainer from '../page-editor.container.vue';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { Autocomplete } from '@/scripts/autocomplete.js';
|
import { Autocomplete } from '@/scripts/autocomplete.js';
|
||||||
|
@ -28,12 +40,37 @@ const props = defineProps<{
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'text' }): void;
|
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'text' }): void;
|
||||||
|
(ev: 'remove'): void;
|
||||||
|
(ev: 'move', direction: 'up' | 'down'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let autocomplete: Autocomplete;
|
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 text = ref(props.modelValue.text ?? '');
|
||||||
const inputEl = shallowRef<HTMLTextAreaElement | null>(null);
|
|
||||||
|
|
||||||
watch(text, () => {
|
watch(text, () => {
|
||||||
emit('update:modelValue', {
|
emit('update:modelValue', {
|
||||||
|
@ -43,6 +80,7 @@ watch(text, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
if (!inputEl.value) return;
|
||||||
autocomplete = new Autocomplete(inputEl.value, text);
|
autocomplete = new Autocomplete(inputEl.value, text);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -59,7 +97,7 @@ onUnmounted(() => {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
min-height: 150px;
|
min-height: v-bind("editorHeight + 'px'");
|
||||||
border: none;
|
border: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
@ -68,4 +106,19 @@ onUnmounted(() => {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
box-sizing: border-box;
|
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>
|
</style>
|
||||||
|
|
|
@ -2,21 +2,59 @@
|
||||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
SPDX-License-Identifier: AGPL-3.0-only
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<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)">
|
<div
|
||||||
<template #item="{element}">
|
@dragstart.capture="dragStart"
|
||||||
<div :class="$style.item">
|
@dragend.capture="dragEnd"
|
||||||
<!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 -->
|
@drop.capture="dragEnd"
|
||||||
<component :is="getComponent(element.type)" :modelValue="element" @update:modelValue="updateItem" @remove="() => removeItem(element)"/>
|
>
|
||||||
|
<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>
|
||||||
</template>
|
</div>
|
||||||
</Sortable>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { defineAsyncComponent } from 'vue';
|
import { ref } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
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 XSection from './els/page-editor.el.section.vue';
|
||||||
import XText from './els/page-editor.el.text.vue';
|
import XText from './els/page-editor.el.text.vue';
|
||||||
import XImage from './els/page-editor.el.image.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<{
|
const props = defineProps<{
|
||||||
|
scrollContainer?: HTMLElement | null;
|
||||||
modelValue: Misskey.entities.Page['content'];
|
modelValue: Misskey.entities.Page['content'];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
@ -42,7 +79,151 @@ const emit = defineEmits<{
|
||||||
(ev: 'update:modelValue', value: Misskey.entities.Page['content']): void;
|
(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 i = props.modelValue.findIndex(x => x.id === v.id);
|
||||||
const newValue = [
|
const newValue = [
|
||||||
...props.modelValue.slice(0, i),
|
...props.modelValue.slice(0, i),
|
||||||
|
@ -52,8 +233,8 @@ function updateItem(v) {
|
||||||
emit('update:modelValue', newValue);
|
emit('update:modelValue', newValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeItem(el) {
|
function removeItem(v: Misskey.entities.PageBlock) {
|
||||||
const i = props.modelValue.findIndex(x => x.id === el.id);
|
const i = props.modelValue.findIndex(x => x.id === v.id);
|
||||||
const newValue = [
|
const newValue = [
|
||||||
...props.modelValue.slice(0, i),
|
...props.modelValue.slice(0, i),
|
||||||
...props.modelValue.slice(i + 1),
|
...props.modelValue.slice(i + 1),
|
||||||
|
@ -63,9 +244,62 @@ function removeItem(el) {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.item {
|
.insertBetweenRoot {
|
||||||
& + .item {
|
height: calc(var(--MI-margin) * 2);
|
||||||
margin-top: 16px;
|
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>
|
</style>
|
||||||
|
|
|
@ -4,33 +4,67 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="cpjygsrt">
|
<div
|
||||||
<header>
|
ref="containerRootEl"
|
||||||
<div class="title"><slot name="header"></slot></div>
|
:class="[$style.blockContainerRoot, {
|
||||||
<div class="buttons">
|
[$style.dragging]: isDragging,
|
||||||
<slot name="func"></slot>
|
[$style.draggingOver]: isDraggingOver,
|
||||||
<button v-if="removable" class="_button" @click="remove()">
|
}]"
|
||||||
|
@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>
|
<i class="ti ti-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="draggable" class="drag-handle _button">
|
<template v-if="draggable">
|
||||||
<i class="ti ti-menu-2"></i>
|
<div :class="$style.divider"></div>
|
||||||
</button>
|
<button
|
||||||
<button class="_button" @click="toggleContent(!showBody)">
|
:class="$style.blockContainerActionButton"
|
||||||
<template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
|
class="_button"
|
||||||
<template v-else><i class="ti ti-chevron-down"></i></template>
|
@click="() => emit('move', 'up')"
|
||||||
</button>
|
>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div v-show="showBody" class="body">
|
<div :class="$style.blockContainerBody" tabindex="0">
|
||||||
<slot></slot>
|
<slot :focus="focus"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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<{
|
const props = withDefaults(defineProps<{
|
||||||
|
blockId: string;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
removable?: boolean;
|
removable?: boolean;
|
||||||
draggable?: boolean;
|
draggable?: boolean;
|
||||||
|
@ -40,24 +74,103 @@ const props = withDefaults(defineProps<{
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'toggle', show: boolean): void;
|
|
||||||
(ev: 'remove'): 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) {
|
emit('remove');
|
||||||
showBody.value = show;
|
|
||||||
emit('toggle', show);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove() {
|
const containerRootEl = useTemplateRef('containerRootEl');
|
||||||
emit('remove');
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" module>
|
||||||
.cpjygsrt {
|
.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;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--MI_THEME-panel);
|
background: var(--MI_THEME-panel);
|
||||||
|
@ -67,62 +180,45 @@ function remove() {
|
||||||
&:hover {
|
&:hover {
|
||||||
border: solid 2px var(--MI_THEME-X13);
|
border: solid 2px var(--MI_THEME-X13);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.warn {
|
.blockContainerRoot.dragging {
|
||||||
border: solid 2px #dec44c;
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--MI_THEME-bg);
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: 8px 0 8px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.error {
|
&.draggingOver::after {
|
||||||
border: solid 2px #f00;
|
outline: dashed 2px var(--MI_THEME-accent);
|
||||||
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
> header {
|
.blockContainerHeader {
|
||||||
> .title {
|
display: flex;
|
||||||
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 {
|
.blockContainerBody {
|
||||||
margin-right: 6px;
|
border: solid 2px var(--MI_THEME-accent);
|
||||||
}
|
border-top-right-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:empty {
|
@container (min-width: 700px) {
|
||||||
display: none;
|
.blockContainerRoot:focus-within {
|
||||||
}
|
.blockContainerHeader {
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .buttons {
|
.blockContainerBody {
|
||||||
position: absolute;
|
border: solid 2px var(--MI_THEME-accent);
|
||||||
z-index: 2;
|
border-top-right-radius: 0;
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
|
|
||||||
> button {
|
|
||||||
padding: 0;
|
|
||||||
width: 42px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
line-height: 42px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-handle {
|
|
||||||
cursor: move;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
> .body {
|
|
||||||
::v-deep(.juejbjww), ::v-deep(.eiipwacr) {
|
|
||||||
&:not(.inline):first-child {
|
|
||||||
margin-top: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(.inline):last-child {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,64 +4,59 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<MkStickyContainer>
|
<MkStickyContainer ref="containerEl">
|
||||||
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
|
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||||
<MkSpacer :contentMax="700">
|
<MkSpacer :contentMax="800">
|
||||||
<div class="jqqmcavi">
|
<div v-if="fetchStatus === 'loading'">
|
||||||
<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>
|
<MkLoading/>
|
||||||
<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>
|
||||||
|
<div v-else-if="fetchStatus === 'done' && page != null" class="_gaps" :class="$style.pageMain">
|
||||||
<div v-if="tab === 'settings'">
|
<div :class="$style.pageBanner">
|
||||||
<div class="_gaps_m">
|
<div v-if="page?.eyeCatchingImageId" :class="$style.pageBannerImage">
|
||||||
<MkInput v-model="title">
|
<MkMediaImage
|
||||||
<template #label>{{ i18n.ts._pages.title }}</template>
|
:image="page.eyeCatchingImage!"
|
||||||
</MkInput>
|
:cover="true"
|
||||||
|
:disableImageLink="true"
|
||||||
<MkInput v-model="summary">
|
:class="$style.thumbnail"
|
||||||
<template #label>{{ i18n.ts._pages.summary }}</template>
|
/>
|
||||||
</MkInput>
|
</div>
|
||||||
|
</div>
|
||||||
<MkInput v-model="name">
|
<div :class="$style.pageBannerTitle" class="_gaps_s">
|
||||||
<template #prefix>{{ url }}/@{{ author.username }}/pages/</template>
|
<input v-model="title" :class="$style.titleForm" :placeholder="i18n.ts._pages.inputTitleHere"/>
|
||||||
<template #label>{{ i18n.ts._pages.url }}</template>
|
<div :class="$style.pageBannerTitleSub">
|
||||||
</MkInput>
|
<div v-if="page?.user" :class="$style.pageBannerTitleUser">
|
||||||
|
<MkAvatar :user="page.user" :class="$style.avatar" indicator/> <MkUserName :user="page.user" :nowrap="false"/>
|
||||||
<MkSwitch v-model="alignCenter">{{ i18n.ts._pages.alignCenter }}</MkSwitch>
|
</div>
|
||||||
|
<div :class="$style.pageBannerTitleSubActions">
|
||||||
<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>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
|
<XPage v-if="enableGlobalPreview" key="preview" :page="page" />
|
||||||
<div v-else-if="tab === 'contents'">
|
<XBlocks v-else key="editor" v-model="content" :scrollContainer="containerEl?.rootEl"/>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="fetchStatus === 'notMe'" class="_fullInfo">
|
||||||
|
This page is not yours
|
||||||
|
</div>
|
||||||
</MkSpacer>
|
</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>
|
</MkStickyContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, provide, watch, ref } from 'vue';
|
import { computed, ref, useTemplateRef } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import XBlocks from './page-editor.blocks.vue';
|
import XBlocks from './page-editor.blocks.vue';
|
||||||
|
@ -69,189 +64,85 @@ import MkButton from '@/components/MkButton.vue';
|
||||||
import MkSelect from '@/components/MkSelect.vue';
|
import MkSelect from '@/components/MkSelect.vue';
|
||||||
import MkSwitch from '@/components/MkSwitch.vue';
|
import MkSwitch from '@/components/MkSwitch.vue';
|
||||||
import MkInput from '@/components/MkInput.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 { url } from '@@/js/config.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||||
import { selectFile } from '@/scripts/select-file.js';
|
import { selectFile } from '@/scripts/select-file.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata.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 { mainRouter } from '@/router/main.js';
|
||||||
import { getPageBlockList } from '@/pages/page-editor/common.js';
|
import { getPageBlockList } from '@/pages/page-editor/common.js';
|
||||||
|
import type { SlimPage } from '@/types/page.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
initPageId?: string;
|
initPageId?: string;
|
||||||
initPageName?: string;
|
|
||||||
initUser?: string;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const tab = ref('settings');
|
const $i = signinRequired();
|
||||||
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);
|
const fetchStatus = ref<'loading' | 'done' | 'notMe'>('loading');
|
||||||
|
const page = ref<Partial<SlimPage> | null>(null);
|
||||||
watch(eyeCatchingImageId, async () => {
|
const title = computed({
|
||||||
if (eyeCatchingImageId.value == null) {
|
get: () => page.value?.title ?? '',
|
||||||
eyeCatchingImage.value = null;
|
set: (value) => {
|
||||||
} else {
|
if (page.value) {
|
||||||
eyeCatchingImage.value = await misskeyApi('drive/files/show', {
|
page.value.title = value;
|
||||||
fileId: eyeCatchingImageId.value,
|
} else {
|
||||||
});
|
page.value = {
|
||||||
}
|
title: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const content = computed<Misskey.entities.Page['content']>({
|
||||||
|
get: () => page.value?.content ?? [],
|
||||||
|
set: (value) => {
|
||||||
|
if (page.value) {
|
||||||
|
page.value.content = value;
|
||||||
|
} else {
|
||||||
|
page.value = {
|
||||||
|
content: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function getSaveOptions() {
|
const enableGlobalPreview = ref(false);
|
||||||
return {
|
|
||||||
title: title.value.trim(),
|
const containerEl = useTemplateRef('containerEl');
|
||||||
name: name.value.trim(),
|
|
||||||
summary: summary.value,
|
function onTitleUpdated(ev: Event) {
|
||||||
font: font.value,
|
title.value = (ev.target as HTMLDivElement).innerText;
|
||||||
script: '',
|
|
||||||
hideTitleWhenPinned: hideTitleWhenPinned.value,
|
|
||||||
alignCenter: alignCenter.value,
|
|
||||||
content: content.value,
|
|
||||||
variables: [],
|
|
||||||
eyeCatchingImageId: eyeCatchingImageId.value,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function save() {
|
async 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() {
|
async function show() {
|
||||||
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() {
|
async function del() {
|
||||||
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() {
|
async function init() {
|
||||||
if (props.initPageId) {
|
if (props.initPageId) {
|
||||||
page.value = await misskeyApi('pages/show', {
|
const _page = await misskeyApi('pages/show', {
|
||||||
pageId: props.initPageId,
|
pageId: props.initPageId,
|
||||||
});
|
});
|
||||||
} else if (props.initPageName && props.initUser) {
|
if (_page.user.id !== $i.id) {
|
||||||
page.value = await misskeyApi('pages/show', {
|
fetchStatus.value = 'notMe';
|
||||||
name: props.initPageName,
|
return;
|
||||||
username: props.initUser,
|
}
|
||||||
});
|
page.value = _page;
|
||||||
readonly.value = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page.value) {
|
if (page.value === null) {
|
||||||
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();
|
const id = uuid();
|
||||||
content.value = [{
|
content.value = [{
|
||||||
id,
|
id,
|
||||||
|
@ -259,126 +150,152 @@ async function init() {
|
||||||
text: 'Hello World!',
|
text: 'Hello World!',
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fetchStatus.value = 'done';
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|
||||||
const headerActions = computed(() => []);
|
const headerActions = computed(() => []);
|
||||||
|
|
||||||
const headerTabs = 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(() => ({
|
definePageMetadata(() => ({
|
||||||
title: props.initPageId ? i18n.ts._pages.editPage
|
title: props.initPageId ? i18n.ts._pages.editPage : i18n.ts._pages.newPage,
|
||||||
: props.initPageName && props.initUser ? i18n.ts._pages.readPage
|
|
||||||
: i18n.ts._pages.newPage,
|
|
||||||
icon: 'ti ti-pencil',
|
icon: 'ti ti-pencil',
|
||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.contents {
|
.pageMain {
|
||||||
&:global {
|
border-radius: var(--MI-radius);
|
||||||
> .add {
|
padding: 2rem;
|
||||||
margin: 16px auto 0 auto;
|
background: var(--MI_THEME-panel);
|
||||||
}
|
box-sizing: border-box;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
.pageBanner {
|
||||||
.jqqmcavi {
|
width: calc(100% + 4rem);
|
||||||
margin-bottom: 16px;
|
margin: -2rem -2rem 0.5rem;
|
||||||
|
border-radius: var(--MI-radius) var(--MI-radius) 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
> .button {
|
.pageBannerImage {
|
||||||
& + .button {
|
position: relative;
|
||||||
margin-left: 8px;
|
padding-top: 56.25%;
|
||||||
}
|
|
||||||
|
> .thumbnail {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.gwbmwxkm {
|
.pageBannerTitle {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
> header {
|
.titleForm {
|
||||||
> .title {
|
appearance: none;
|
||||||
z-index: 1;
|
-webkit-appearance: none;
|
||||||
margin: 0;
|
box-sizing: border-box;
|
||||||
padding: 0 16px;
|
display: block;
|
||||||
line-height: 42px;
|
padding: 6px 12px;
|
||||||
font-size: 0.9em;
|
font: inherit;
|
||||||
font-weight: bold;
|
font-size: 2rem;
|
||||||
box-shadow: 0 1px rgba(#000, 0.07);
|
font-weight: 700;
|
||||||
|
color: var(--MI_THEME-fg);
|
||||||
|
margin: 0;
|
||||||
|
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 {
|
&:hover {
|
||||||
margin-right: 6px;
|
border-color: var(--MI_THEME-inputBorderHover);
|
||||||
}
|
|
||||||
|
|
||||||
&:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
> .buttons {
|
&:focus {
|
||||||
position: absolute;
|
outline: none;
|
||||||
z-index: 2;
|
border-color: var(--MI_THEME-accent);
|
||||||
top: 0;
|
}
|
||||||
right: 0;
|
|
||||||
|
|
||||||
> button {
|
&:focus-visible {
|
||||||
padding: 0;
|
outline: 2px solid var(--MI_THEME-focus);
|
||||||
width: 42px;
|
outline-offset: -2px;
|
||||||
font-size: 0.9em;
|
|
||||||
line-height: 42px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
> section {
|
.pageBannerTitleSub {
|
||||||
padding: 0 32px 32px 32px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
.pageBannerTitleUser {
|
||||||
padding: 0 16px 16px 16px;
|
--height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: var(--height);
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
height: var(--height);
|
||||||
|
width: var(--height);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
> .view {
|
.pageBannerTitleSubActions {
|
||||||
display: inline-block;
|
flex-shrink: 0;
|
||||||
margin: 16px 0 0 0;
|
display: flex;
|
||||||
font-size: 14px;
|
align-items: center;
|
||||||
}
|
gap: var(--MI-marginHalf);
|
||||||
|
margin-left: auto;
|
||||||
> .content {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .eyeCatch {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
|
|
||||||
> div {
|
|
||||||
> img {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.qmuvgica {
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorMenuInner {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
> .variables {
|
.footer {
|
||||||
margin-bottom: 16px;
|
backdrop-filter: var(--MI-blur, blur(15px));
|
||||||
}
|
background: var(--MI_THEME-acrylicBg);
|
||||||
|
border-top: solid .5px var(--MI_THEME-divider);
|
||||||
|
}
|
||||||
|
|
||||||
> .add {
|
.footerInner {
|
||||||
margin-bottom: 16px;
|
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>
|
</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) {
|
} else if ($i && $i.id !== page.value.userId) {
|
||||||
menuItems.push({
|
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',
|
icon: 'ti ti-exclamation-circle',
|
||||||
text: i18n.ts.reportAbuse,
|
text: i18n.ts.reportAbuse,
|
||||||
action: reportAbuse,
|
action: reportAbuse,
|
||||||
|
|
|
@ -53,7 +53,8 @@ const tab = ref('featured');
|
||||||
|
|
||||||
const featuredPagesPagination = {
|
const featuredPagesPagination = {
|
||||||
endpoint: 'pages/featured' as const,
|
endpoint: 'pages/featured' as const,
|
||||||
noPaging: true,
|
limit: 5,
|
||||||
|
offsetMode: true,
|
||||||
};
|
};
|
||||||
const myPagesPagination = {
|
const myPagesPagination = {
|
||||||
endpoint: 'i/pages' as const,
|
endpoint: 'i/pages' as const,
|
||||||
|
|
|
@ -17,9 +17,6 @@ export const page = (loader: AsyncComponentLoader) => defineAsyncComponent({
|
||||||
});
|
});
|
||||||
|
|
||||||
const routes: RouteDef[] = [{
|
const routes: RouteDef[] = [{
|
||||||
path: '/@:initUser/pages/:initPageName/view-source',
|
|
||||||
component: page(() => import('@/pages/page-editor/page-editor.vue')),
|
|
||||||
}, {
|
|
||||||
path: '/@:username/pages/:pageName',
|
path: '/@:username/pages/:pageName',
|
||||||
component: page(() => import('@/pages/page.vue')),
|
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,
|
PagesCreateRequest,
|
||||||
PagesCreateResponse,
|
PagesCreateResponse,
|
||||||
PagesDeleteRequest,
|
PagesDeleteRequest,
|
||||||
|
PagesFeaturedRequest,
|
||||||
PagesFeaturedResponse,
|
PagesFeaturedResponse,
|
||||||
PagesLikeRequest,
|
PagesLikeRequest,
|
||||||
PagesShowRequest,
|
PagesShowRequest,
|
||||||
|
@ -2849,6 +2850,9 @@ type PagesCreateResponse = operations['pages___create']['responses']['200']['con
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type PagesDeleteRequest = operations['pages___delete']['requestBody']['content']['application/json'];
|
type PagesDeleteRequest = operations['pages___delete']['requestBody']['content']['application/json'];
|
||||||
|
|
||||||
|
// @public (undocumented)
|
||||||
|
type PagesFeaturedRequest = operations['pages___featured']['requestBody']['content']['application/json'];
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type PagesFeaturedResponse = operations['pages___featured']['responses']['200']['content']['application/json'];
|
type PagesFeaturedResponse = operations['pages___featured']['responses']['200']['content']['application/json'];
|
||||||
|
|
||||||
|
|
|
@ -461,6 +461,7 @@ import type {
|
||||||
PagesCreateRequest,
|
PagesCreateRequest,
|
||||||
PagesCreateResponse,
|
PagesCreateResponse,
|
||||||
PagesDeleteRequest,
|
PagesDeleteRequest,
|
||||||
|
PagesFeaturedRequest,
|
||||||
PagesFeaturedResponse,
|
PagesFeaturedResponse,
|
||||||
PagesLikeRequest,
|
PagesLikeRequest,
|
||||||
PagesShowRequest,
|
PagesShowRequest,
|
||||||
|
@ -891,7 +892,7 @@ export type Endpoints = {
|
||||||
'page-push': { req: PagePushRequest; res: EmptyResponse };
|
'page-push': { req: PagePushRequest; res: EmptyResponse };
|
||||||
'pages/create': { req: PagesCreateRequest; res: PagesCreateResponse };
|
'pages/create': { req: PagesCreateRequest; res: PagesCreateResponse };
|
||||||
'pages/delete': { req: PagesDeleteRequest; res: EmptyResponse };
|
'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/like': { req: PagesLikeRequest; res: EmptyResponse };
|
||||||
'pages/show': { req: PagesShowRequest; res: PagesShowResponse };
|
'pages/show': { req: PagesShowRequest; res: PagesShowResponse };
|
||||||
'pages/unlike': { req: PagesUnlikeRequest; res: EmptyResponse };
|
'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 PagesCreateRequest = operations['pages___create']['requestBody']['content']['application/json'];
|
||||||
export type PagesCreateResponse = operations['pages___create']['responses']['200']['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 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 PagesFeaturedResponse = operations['pages___featured']['responses']['200']['content']['application/json'];
|
||||||
export type PagesLikeRequest = operations['pages___like']['requestBody']['content']['application/json'];
|
export type PagesLikeRequest = operations['pages___like']['requestBody']['content']['application/json'];
|
||||||
export type PagesShowRequest = operations['pages___show']['requestBody']['content']['application/json'];
|
export type PagesShowRequest = operations['pages___show']['requestBody']['content']['application/json'];
|
||||||
|
|
|
@ -4554,6 +4554,12 @@ export type components = {
|
||||||
type: 'section';
|
type: 'section';
|
||||||
title: string;
|
title: string;
|
||||||
children: components['schemas']['PageBlock'][];
|
children: components['schemas']['PageBlock'][];
|
||||||
|
}, {
|
||||||
|
id: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
type: 'heading';
|
||||||
|
level: number;
|
||||||
|
text: string;
|
||||||
}, {
|
}, {
|
||||||
id: string;
|
id: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
|
@ -23501,10 +23507,10 @@ export type operations = {
|
||||||
content: {
|
content: {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}[];
|
}[];
|
||||||
variables: {
|
variables?: {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}[];
|
}[];
|
||||||
script: string;
|
script?: string;
|
||||||
/** Format: misskey:id */
|
/** Format: misskey:id */
|
||||||
eyeCatchingImageId?: string | null;
|
eyeCatchingImageId?: string | null;
|
||||||
/**
|
/**
|
||||||
|
@ -23623,6 +23629,16 @@ export type operations = {
|
||||||
* **Credential required**: *No*
|
* **Credential required**: *No*
|
||||||
*/
|
*/
|
||||||
pages___featured: {
|
pages___featured: {
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
/** @default 0 */
|
||||||
|
offset?: number;
|
||||||
|
/** @default 10 */
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description OK (with results) */
|
/** @description OK (with results) */
|
||||||
200: {
|
200: {
|
||||||
|
|
Loading…
Reference in New Issue