This commit is contained in:
かっこかり 2024-11-19 20:29:30 +09:00 committed by GitHub
commit 70e4042c29
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1488 additions and 439 deletions

12
locales/index.d.ts vendored
View File

@ -9288,6 +9288,18 @@ export interface Locale extends ILocale {
*
*/
"specialBlocks": string;
/**
*
*/
"inputTitleHere": string;
/**
*
*/
"moveToHere": string;
/**
*
*/
"blockDeleteAreYouSure": string;
"blocks": {
/**
*

View File

@ -2449,6 +2449,9 @@ _pages:
contentBlocks: "コンテンツ"
inputBlocks: "入力"
specialBlocks: "特殊"
inputTitleHere: "タイトルを入力"
moveToHere: "ここに移動"
blockDeleteAreYouSure: "このブロックを削除しますか?"
blocks:
text: "テキスト"
textarea: "テキストエリア"

View File

@ -4,6 +4,7 @@
*/
export const MAX_NOTE_TEXT_LENGTH = 3000;
export const MAX_PAGE_CONTENT_BYTES = 1024 * 1024 * 1.5; // 1.5MB
export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min
export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days

View File

@ -47,6 +47,7 @@ import { NoteDeleteService } from './NoteDeleteService.js';
import { NotePiningService } from './NotePiningService.js';
import { NoteReadService } from './NoteReadService.js';
import { NotificationService } from './NotificationService.js';
import { PageService } from './PageService.js';
import { PollService } from './PollService.js';
import { PushNotificationService } from './PushNotificationService.js';
import { QueryService } from './QueryService.js';
@ -190,6 +191,7 @@ const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting
const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService };
const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService };
const $PageService: Provider = { provide: 'PageService', useExisting: PageService };
const $PollService: Provider = { provide: 'PollService', useExisting: PollService };
const $ProxyAccountService: Provider = { provide: 'ProxyAccountService', useExisting: ProxyAccountService };
const $PushNotificationService: Provider = { provide: 'PushNotificationService', useExisting: PushNotificationService };
@ -341,6 +343,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NotePiningService,
NoteReadService,
NotificationService,
PageService,
PollService,
ProxyAccountService,
PushNotificationService,
@ -488,6 +491,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NotePiningService,
$NoteReadService,
$NotificationService,
$PageService,
$PollService,
$ProxyAccountService,
$PushNotificationService,
@ -636,6 +640,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NotePiningService,
NoteReadService,
NotificationService,
PageService,
PollService,
ProxyAccountService,
PushNotificationService,
@ -782,6 +787,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NotePiningService,
$NoteReadService,
$NotificationService,
$PageService,
$PollService,
$ProxyAccountService,
$PushNotificationService,

View File

@ -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();
}
}

View File

@ -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

View File

@ -3,7 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
const blockBaseSchema = {
/**
* pageBlockSchemaも更新すること
*
*
* packages/backend/src/models/Page.ts
*/
const packedBlockBaseSchema = {
type: 'object',
properties: {
id: {
@ -17,10 +24,10 @@ const blockBaseSchema = {
},
} as const;
const textBlockSchema = {
const packedTextBlockSchema = {
type: 'object',
properties: {
...blockBaseSchema.properties,
...packedBlockBaseSchema.properties,
type: {
type: 'string',
optional: false, nullable: false,
@ -33,10 +40,31 @@ const textBlockSchema = {
},
} as const;
const sectionBlockSchema = {
const packedHeadingBlockSchema = {
type: 'object',
properties: {
...blockBaseSchema.properties,
...packedBlockBaseSchema.properties,
type: {
type: 'string',
optional: false, nullable: false,
enum: ['heading'],
},
level: {
type: 'number',
optional: false, nullable: false,
},
text: {
type: 'string',
optional: false, nullable: false,
},
},
} as const;
/** @deprecated 要素を入れ子にする必要が一旦なくなったので非推奨。headingBlockを使用すること */
const packedSectionBlockSchema = {
type: 'object',
properties: {
...packedBlockBaseSchema.properties,
type: {
type: 'string',
optional: false, nullable: false,
@ -59,10 +87,10 @@ const sectionBlockSchema = {
},
} as const;
const imageBlockSchema = {
const packedImageBlockSchema = {
type: 'object',
properties: {
...blockBaseSchema.properties,
...packedBlockBaseSchema.properties,
type: {
type: 'string',
optional: false, nullable: false,
@ -75,10 +103,10 @@ const imageBlockSchema = {
},
} as const;
const noteBlockSchema = {
const packedNoteBlockSchema = {
type: 'object',
properties: {
...blockBaseSchema.properties,
...packedBlockBaseSchema.properties,
type: {
type: 'string',
optional: false, nullable: false,
@ -98,10 +126,11 @@ const noteBlockSchema = {
export const packedPageBlockSchema = {
type: 'object',
oneOf: [
textBlockSchema,
sectionBlockSchema,
imageBlockSchema,
noteBlockSchema,
packedTextBlockSchema,
packedSectionBlockSchema,
packedHeadingBlockSchema,
packedImageBlockSchema,
packedNoteBlockSchema,
],
} as const;

View File

@ -7,11 +7,13 @@ import ms from 'ms';
import { Inject, Injectable } from '@nestjs/common';
import type { DriveFilesRepository, PagesRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { MiPage } from '@/models/Page.js';
import { MiPage, pageNameSchema } from '@/models/Page.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { PageService } from '@/core/PageService.js';
import { PageEntityService } from '@/core/entities/PageEntityService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
import { ApiError } from '@/server/api/error.js';
import { MAX_PAGE_CONTENT_BYTES } from '@/const.js';
export const meta = {
tags: ['pages'],
@ -44,6 +46,16 @@ export const meta = {
code: 'NAME_ALREADY_EXISTS',
id: '4650348e-301c-499a-83c9-6aa988c66bc1',
},
contentTooLarge: {
message: 'Content is too large.',
code: 'CONTENT_TOO_LARGE',
id: '2a93fcc9-4cd7-4885-9e5b-be56ed8f4d4f',
},
invalidParam: {
message: 'Invalid param.',
code: 'INVALID_PARAM',
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
},
},
} as const;
@ -51,9 +63,10 @@ export const paramDef = {
type: 'object',
properties: {
title: { type: 'string' },
name: { type: 'string', minLength: 1 },
name: { ...pageNameSchema, minLength: 1 },
summary: { type: 'string', nullable: true },
content: { type: 'array', items: {
// misskey-jsの型生成に対応していないスキーマを使用しているため別途バリデーションする
type: 'object', additionalProperties: true,
} },
variables: { type: 'array', items: {
@ -65,7 +78,7 @@ export const paramDef = {
alignCenter: { type: 'boolean', default: false },
hideTitleWhenPinned: { type: 'boolean', default: false },
},
required: ['title', 'name', 'content', 'variables', 'script'],
required: ['title', 'name', 'content'],
} as const;
@Injectable()
@ -77,10 +90,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private pageService: PageService,
private pageEntityService: PageEntityService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
if (new Blob([JSON.stringify(ps.content)]).size > MAX_PAGE_CONTENT_BYTES) {
throw new ApiError(meta.errors.contentTooLarge);
}
const validateResult = this.pageService.validatePageContent(ps.content);
if (!validateResult.valid) {
const errors = validateResult.errors!;
throw new ApiError(meta.errors.invalidParam, {
param: errors[0].schemaPath,
reason: errors[0].message,
});
}
let eyeCatchingImage = null;
if (ps.eyeCatchingImageId != null) {
eyeCatchingImage = await this.driveFilesRepository.findOneBy({
@ -109,8 +136,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
name: ps.name,
summary: ps.summary,
content: ps.content,
variables: ps.variables,
script: ps.script,
//variables: ps.variables, もう使用されていない(動的ページ)
//script: ps.script, もう使用されていない(動的ページ)
eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null,
userId: me.id,
visibility: 'public',

View File

@ -3,11 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { PagesRepository } from '@/models/_.js';
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { PageService } from '@/core/PageService.js';
import { PageEntityService } from '@/core/entities/PageEntityService.js';
import { DI } from '@/di-symbols.js';
export const meta = {
tags: ['pages'],
@ -27,27 +26,25 @@ export const meta = {
export const paramDef = {
type: 'object',
properties: {},
properties: {
offset: { type: 'integer', minimum: 0, default: 0 },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.pagesRepository)
private pagesRepository: PagesRepository,
private pageService: PageService,
private pageEntityService: PageEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.pagesRepository.createQueryBuilder('page')
.where('page.visibility = \'public\'')
.andWhere('page.likedCount > 0')
.orderBy('page.likedCount', 'DESC');
const pages = await query.limit(10).getMany();
return await this.pageEntityService.packMany(pages, me);
const result = await this.pageService.featured({
offset: ps.offset,
limit: ps.limit,
});
return await this.pageEntityService.packMany(result, me);
});
}
}

View File

@ -9,7 +9,10 @@ import { Inject, Injectable } from '@nestjs/common';
import type { PagesRepository, DriveFilesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
import { ApiError } from '@/server/api/error.js';
import { MAX_PAGE_CONTENT_BYTES } from '@/const.js';
import { pageNameSchema } from '@/models/Page.js';
import { PageService } from '@/core/PageService.js';
export const meta = {
tags: ['pages'],
@ -48,6 +51,16 @@ export const meta = {
code: 'NAME_ALREADY_EXISTS',
id: '2298a392-d4a1-44c5-9ebb-ac1aeaa5a9ab',
},
contentTooLarge: {
message: 'Content is too large.',
code: 'CONTENT_TOO_LARGE',
id: '2a93fcc9-4cd7-4885-9e5b-be56ed8f4d4f',
},
invalidParam: {
message: 'Invalid param.',
code: 'INVALID_PARAM',
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
},
},
} as const;
@ -56,9 +69,10 @@ export const paramDef = {
properties: {
pageId: { type: 'string', format: 'misskey:id' },
title: { type: 'string' },
name: { type: 'string', minLength: 1 },
name: { ...pageNameSchema, minLength: 1 },
summary: { type: 'string', nullable: true },
content: { type: 'array', items: {
// misskey-jsの型生成に対応していないスキーマを使用しているため別途バリデーションする
type: 'object', additionalProperties: true,
} },
variables: { type: 'array', items: {
@ -81,6 +95,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private pageService: PageService,
) {
super(meta, paramDef, async (ps, me) => {
const page = await this.pagesRepository.findOneBy({ id: ps.pageId });
@ -91,6 +107,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.accessDenied);
}
if (ps.content != null) {
if (new Blob([JSON.stringify(ps.content)]).size > MAX_PAGE_CONTENT_BYTES) {
throw new ApiError(meta.errors.contentTooLarge);
}
const validateResult = this.pageService.validatePageContent(ps.content);
if (!validateResult.valid) {
const errors = validateResult.errors!;
throw new ApiError(meta.errors.invalidParam, {
param: errors[0].schemaPath,
reason: errors[0].message,
});
}
}
if (ps.eyeCatchingImageId != null) {
const eyeCatchingImage = await this.driveFilesRepository.findOneBy({
id: ps.eyeCatchingImageId,
@ -118,8 +149,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
name: ps.name,
summary: ps.summary === undefined ? page.summary : ps.summary,
content: ps.content,
variables: ps.variables,
script: ps.script,
//variables: ps.variables, もう使用されていない(動的ページ)
//script: ps.script, もう使用されていない(動的ページ)
alignCenter: ps.alignCenter,
hideTitleWhenPinned: ps.hideTitleWhenPinned,
font: ps.font,

View File

@ -253,9 +253,11 @@ import { isEnabledUrlPreview } from '@/instance.js';
import { getAppearNote } from '@/scripts/get-appear-note.js';
import { type Keymap } from '@/scripts/hotkey.js';
type Tab = 'replies' | 'renotes' | 'reactions';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
initialTab: string;
initialTab?: Tab;
}>(), {
initialTab: 'replies',
});
@ -339,7 +341,7 @@ provide('react', (reaction: string) => {
});
});
const tab = ref(props.initialTab);
const tab = ref<Tab>(props.initialTab);
const reactionTabType = ref<string | null>(null);
const renotesPagination = computed<Paging>(() => ({

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div ref="rootEl">
<div ref="rootEl" :class="$style.root">
<div ref="headerEl" :class="$style.header">
<slot name="header"></slot>
</div>
@ -84,8 +84,16 @@ defineExpose({
</script>
<style lang='scss' module>
.root {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
}
.body {
position: relative;
flex-grow: 1;
z-index: 0;
--MI-stickyTop: v-bind("childStickyTop + 'px'");
--MI-stickyBottom: v-bind("childStickyBottom + 'px'");
@ -93,12 +101,14 @@ defineExpose({
.header {
position: sticky;
flex-shrink: 0;
top: var(--MI-stickyTop, 0);
z-index: 1;
}
.footer {
position: sticky;
flex-shrink: 0;
bottom: var(--MI-stickyBottom, 0);
z-index: 1;
}

View File

@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<!-- eslint-disable vue/no-mutating-props -->
<XContainer :draggable="true" @remove="() => emit('remove')">
<XContainer
:draggable="true"
:blockId="modelValue.id"
@remove="() => emit('remove')"
@move="(direction) => emit('move', direction)"
>
<template #header><i class="ti ti-photo"></i> {{ i18n.ts._pages.blocks.image }}</template>
<template #func>
<button @click="choose()">
@ -36,6 +41,7 @@ const props = defineProps<{
const emit = defineEmits<{
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'image' }): void;
(ev: 'remove'): void;
(ev: 'move', direction: 'up' | 'down'): void;
}>();
const file = ref<Misskey.entities.DriveFile | null>(null);

View File

@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<!-- eslint-disable vue/no-mutating-props -->
<XContainer :draggable="true" @remove="() => emit('remove')">
<XContainer
:draggable="true"
:blockId="modelValue.id"
@remove="() => emit('remove')"
@move="(direction) => emit('move', direction)"
>
<template #header><i class="ti ti-note"></i> {{ i18n.ts._pages.blocks.note }}</template>
<section style="padding: 16px;" class="_gaps_s">
@ -15,8 +20,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
<MkSwitch v-model="props.modelValue.detailed"><span>{{ i18n.ts._pages.blocks._note.detailed }}</span></MkSwitch>
<MkNote v-if="note && !props.modelValue.detailed" :key="note.id + ':normal'" v-model:note="note" style="margin-bottom: 16px;"/>
<MkNoteDetailed v-if="note && props.modelValue.detailed" :key="note.id + ':detail'" v-model:note="note" style="margin-bottom: 16px;"/>
<MkNote v-if="note && !props.modelValue.detailed" :key="note.id + ':normal'" v-model:note="note" :class="$style.note"/>
<MkNoteDetailed v-if="note && props.modelValue.detailed" :key="note.id + ':detail'" v-model:note="note" :class="$style.note"/>
</section>
</XContainer>
</template>
@ -39,6 +44,8 @@ const props = defineProps<{
const emit = defineEmits<{
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'note' }): void;
(ev: 'remove'): void;
(ev: 'move', direction: 'up' | 'down'): void;
}>();
const id = ref(props.modelValue.note);
@ -63,3 +70,11 @@ watch(id, async () => {
immediate: true,
});
</script>
<style module>
.note {
border-radius: var(--MI-radius);
border: 1px solid var(--MI_THEME-divider);
margin-bottom: 16px;
}
</style>

View File

@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<!-- eslint-disable vue/no-mutating-props -->
<XContainer :draggable="true" @remove="() => emit('remove')">
<XContainer
:draggable="true"
:blockId="modelValue.id"
@remove="() => emit('remove')"
@move="(direction) => emit('move', direction)"
>
<template #header><i class="ti ti-note"></i> {{ props.modelValue.title }}</template>
<template #func>
<button class="_button" @click="rename()">
@ -41,6 +46,7 @@ const props = defineProps<{
const emit = defineEmits<{
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'section' }): void;
(ev: 'remove'): void;
(ev: 'move', direction: 'up' | 'down'): void;
}>();
const children = ref(deepClone(props.modelValue.children ?? []));

View File

@ -5,19 +5,31 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<!-- eslint-disable vue/no-mutating-props -->
<XContainer :draggable="true" @remove="() => emit('remove')">
<template #header><i class="ti ti-align-left"></i> {{ i18n.ts._pages.blocks.text }}</template>
<XContainer
:draggable="true"
:blockId="modelValue.id"
@remove="() => emit('remove')"
@move="(direction) => emit('move', direction)"
>
<template #header><i class="ti ti-align-left"></i></template>
<template #actions>
<button class="_button" :class="$style.previewToggleRoot" @click="toggleEnablePreview">
<MkSwitchButton :class="$style.previewToggleSwitch" :checked="enablePreview" @toggle="toggleEnablePreview"></MkSwitchButton>{{ i18n.ts.preview }}
</button>
</template>
<template #default="{ focus }">
<section>
<textarea ref="inputEl" v-model="text" :class="$style.textarea"></textarea>
<div v-if="enablePreview" ref="previewEl" :class="$style.previewRoot"><Mfm :text="text"></Mfm></div>
<textarea v-else ref="inputEl" v-model="text" :class="$style.textarea" @input.passive="calcTextAreaHeight"></textarea>
</section>
</template>
</XContainer>
</template>
<script lang="ts" setup>
import { watch, ref, shallowRef, onMounted, onUnmounted } from 'vue';
import { watch, ref, computed, useTemplateRef, onMounted, onUnmounted, nextTick } from 'vue';
import * as Misskey from 'misskey-js';
import MkSwitchButton from '@/components/MkSwitch.button.vue';
import XContainer from '../page-editor.container.vue';
import { i18n } from '@/i18n.js';
import { Autocomplete } from '@/scripts/autocomplete.js';
@ -28,12 +40,37 @@ const props = defineProps<{
const emit = defineEmits<{
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'text' }): void;
(ev: 'remove'): void;
(ev: 'move', direction: 'up' | 'down'): void;
}>();
let autocomplete: Autocomplete;
const inputEl = useTemplateRef('inputEl');
const inputHeight = ref(150);
const previewEl = useTemplateRef('previewEl');
const previewHeight = ref(150);
const editorHeight = computed(() => Math.max(inputHeight.value, previewHeight.value));
function calcTextAreaHeight() {
if (!inputEl.value) return;
inputEl.value.setAttribute('style', 'min-height: auto');
inputHeight.value = Math.max(150, inputEl.value.scrollHeight ?? 0);
inputEl.value.removeAttribute('style');
}
const enablePreview = ref(false);
function toggleEnablePreview() {
enablePreview.value = !enablePreview.value;
if (enablePreview.value === true) {
nextTick(() => {
previewHeight.value = Math.max(150, previewEl.value?.scrollHeight ?? 0);
});
}
}
const text = ref(props.modelValue.text ?? '');
const inputEl = shallowRef<HTMLTextAreaElement | null>(null);
watch(text, () => {
emit('update:modelValue', {
@ -43,6 +80,7 @@ watch(text, () => {
});
onMounted(() => {
if (!inputEl.value) return;
autocomplete = new Autocomplete(inputEl.value, text);
});
@ -59,7 +97,7 @@ onUnmounted(() => {
appearance: none;
width: 100%;
min-width: 100%;
min-height: 150px;
min-height: v-bind("editorHeight + 'px'");
border: none;
box-shadow: none;
padding: 16px;
@ -68,4 +106,19 @@ onUnmounted(() => {
font-size: 14px;
box-sizing: border-box;
}
.previewRoot {
padding: 16px;
min-height: v-bind("editorHeight + 'px'");
box-sizing: border-box;
}
.previewToggleRoot {
display: flex;
gap: 4px;
}
.previewToggleSwitch {
--height: 1.35em;
}
</style>

View File

@ -2,21 +2,59 @@
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<Sortable :modelValue="modelValue" tag="div" itemKey="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swapThreshold="0.5" @update:modelValue="v => emit('update:modelValue', v)">
<template #item="{element}">
<div :class="$style.item">
<!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 -->
<component :is="getComponent(element.type)" :modelValue="element" @update:modelValue="updateItem" @remove="() => removeItem(element)"/>
<div
@dragstart.capture="dragStart"
@dragend.capture="dragEnd"
@drop.capture="dragEnd"
>
<div
data-after-id="__FIRST__"
:class="[$style.insertBetweenRoot, {
[$style.insertBetweenDraggingOver]: draggingOverAfterId === '__FIRST__' && draggingBlockId !== modelValue[0]?.id,
}]"
@click="insertNewBlock('__FIRST__')"
@dragover="insertBetweenDragOver($event, '__FIRST__')"
@dragleave="insertBetweenDragLeave"
@drop="insertBetweenDrop($event, '__FIRST__')"
>
<div :class="$style.insertBetweenBorder"></div>
<span :class="$style.insertBetweenText"><i v-if="!isDragging" class="ti ti-plus"></i> {{ isDragging ? i18n.ts._pages.moveToHere : i18n.ts.add }}</span>
</div>
</template>
</Sortable>
<div v-for="block, index in modelValue" :key="block.id" :class="$style.item">
<component
:is="getComponent(block.type)"
:modelValue="block"
@update:modelValue="updateItem"
@remove="() => removeItem(block)"
@move="(direction: 'up' | 'down') => moveItem(block.id, direction)"
/>
<div
:data-after-id="block.id"
:class="[$style.insertBetweenRoot, {
[$style.insertBetweenDraggingOver]: draggingOverAfterId === block.id && draggingBlockId !== block.id && draggingBlockId !== modelValue[index + 1]?.id,
}]"
@click="insertNewBlock(block.id)"
@dragover="insertBetweenDragOver($event, block.id, modelValue[index + 1]?.id)"
@dragleave="insertBetweenDragLeave"
@drop="insertBetweenDrop($event, block.id, modelValue[index + 1]?.id)"
>
<div :class="$style.insertBetweenBorder"></div>
<span :class="$style.insertBetweenText"><i v-if="!isDragging" class="ti ti-plus"></i> {{ isDragging ? i18n.ts._pages.moveToHere : i18n.ts.add }}</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { defineAsyncComponent } from 'vue';
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import { v4 as uuid } from 'uuid';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { getScrollContainer } from '@@/js/scroll.js';
import { getPageBlockList } from '@/pages/page-editor/common.js';
import XSection from './els/page-editor.el.section.vue';
import XText from './els/page-editor.el.text.vue';
import XImage from './els/page-editor.el.image.vue';
@ -32,9 +70,8 @@ function getComponent(type: string) {
}
}
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
const props = defineProps<{
scrollContainer?: HTMLElement | null;
modelValue: Misskey.entities.Page['content'];
}>();
@ -42,7 +79,151 @@ const emit = defineEmits<{
(ev: 'update:modelValue', value: Misskey.entities.Page['content']): void;
}>();
function updateItem(v) {
const isDragging = ref(false);
const draggingOverAfterId = ref<string | null>(null);
const draggingBlockId = ref<string | null>(null);
function dragStart(ev: DragEvent) {
if (ev.target instanceof HTMLElement) {
const blockId = ev.target.dataset.blockId;
if (blockId != null) {
console.log('dragStart', blockId);
isDragging.value = true;
draggingBlockId.value = blockId;
document.addEventListener('dragover', watchForMouseMove);
}
}
}
function dragEnd() {
isDragging.value = false;
draggingBlockId.value = null;
document.removeEventListener('dragover', watchForMouseMove);
}
function watchForMouseMove(ev: DragEvent) {
if (isDragging.value) {
// 1/4
const scrollContainer = getScrollContainer(props.scrollContainer ?? null) ?? document.scrollingElement;
if (scrollContainer != null) {
const rect = scrollContainer.getBoundingClientRect();
const y = ev.clientY - rect.top;
const h = rect.height;
const scrollSpeed = 30;
if (y < h / 4) {
const acceralation = Math.max(0, 1 - (y / (h / 4)));
scrollContainer.scrollBy({
top: -scrollSpeed * acceralation,
});
} else if (y > (h / 4 * 3)) {
const acceralation = Math.max(0, 1 - ((h - y) / (h / 4)));
scrollContainer.scrollBy({
top: scrollSpeed * acceralation,
});
}
}
}
}
function insertBetweenDragOver(ev: DragEvent, id: string, nextId?: string) {
if (
draggingBlockId.value === id ||
draggingBlockId.value === nextId ||
![...(ev.dataTransfer?.types ?? [])].includes('application/x-misskey-pageblock-id')
) return;
ev.preventDefault();
if (ev.target instanceof HTMLElement) {
const afterId = ev.target.dataset.afterId;
if (afterId != null) {
draggingOverAfterId.value = afterId;
}
}
}
function insertBetweenDragLeave() {
draggingOverAfterId.value = null;
}
function insertBetweenDrop(ev: DragEvent, id: string, nextId?: string) {
if (
draggingBlockId.value === id ||
draggingBlockId.value === nextId ||
![...(ev.dataTransfer?.types ?? [])].includes('application/x-misskey-pageblock-id')
) return;
ev.preventDefault();
if (ev.target instanceof HTMLElement) {
const afterId = ev.target.dataset.afterId; // insert after this
const moveId = ev.dataTransfer?.getData('application/x-misskey-pageblock-id');
if (afterId != null && moveId != null) {
const oldValue = props.modelValue.filter((x) => x.id !== moveId);
const afterIdAt = afterId === '__FIRST__' ? 0 : oldValue.findIndex((x) => x.id === afterId);
const movingBlock = props.modelValue.find((x) => x.id === moveId);
if (afterId === '__FIRST__' && movingBlock != null) {
const newValue = [
movingBlock,
...oldValue,
];
emit('update:modelValue', newValue);
} else if (afterIdAt >= 0 && movingBlock != null) {
const newValue = [
...oldValue.slice(0, afterIdAt + 1),
movingBlock,
...oldValue.slice(afterIdAt + 1),
];
emit('update:modelValue', newValue);
}
}
}
isDragging.value = false;
draggingOverAfterId.value = null;
}
async function insertNewBlock(id: string) {
const { canceled, result: type } = await os.select({
title: i18n.ts._pages.chooseBlock,
items: getPageBlockList(),
});
if (canceled || type == null) return;
const blockId = uuid();
let newValue: Misskey.entities.Page['content'];
if (id === '__FIRST__') {
newValue = [
{ id: blockId, type },
...props.modelValue,
];
} else {
const afterIdAt = props.modelValue.findIndex((x) => x.id === id);
newValue = [
...props.modelValue.slice(0, afterIdAt + 1),
{ id: blockId, type },
...props.modelValue.slice(afterIdAt + 1),
];
}
emit('update:modelValue', newValue);
}
function moveItem(id: string, direction: 'up' | 'down') {
const i = props.modelValue.findIndex(x => x.id === id);
if (i === -1) return;
const newValue = [...props.modelValue];
const [removed] = newValue.splice(i, 1);
if (direction === 'up') {
newValue.splice(i - 1, 0, removed);
} else {
newValue.splice(i + 1, 0, removed);
}
emit('update:modelValue', newValue);
}
function updateItem(v: Misskey.entities.PageBlock) {
const i = props.modelValue.findIndex(x => x.id === v.id);
const newValue = [
...props.modelValue.slice(0, i),
@ -52,8 +233,8 @@ function updateItem(v) {
emit('update:modelValue', newValue);
}
function removeItem(el) {
const i = props.modelValue.findIndex(x => x.id === el.id);
function removeItem(v: Misskey.entities.PageBlock) {
const i = props.modelValue.findIndex(x => x.id === v.id);
const newValue = [
...props.modelValue.slice(0, i),
...props.modelValue.slice(i + 1),
@ -63,9 +244,62 @@ function removeItem(el) {
</script>
<style lang="scss" module>
.item {
& + .item {
margin-top: 16px;
.insertBetweenRoot {
height: calc(var(--MI-margin) * 2);
width: 100%;
padding: 8px 0;
border-radius: 2px;
position: relative;
&:hover {
cursor: pointer;
.insertBetweenBorder {
display: block;
}
.insertBetweenText {
display: inline-block;
}
}
}
.insertBetweenBorder {
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
height: 4px;
width: 100%;
border-radius: 2px;
background-color: var(--MI_THEME-accent);
display: none;
}
.insertBetweenText {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--MI_THEME-fgOnAccent);
padding: 0 14px;
font-size: 13px;
line-height: 24px;
border-radius: 999px;
display: none;
background-color: var(--MI_THEME-accent);
}
.insertBetweenBorder,
.insertBetweenText {
pointer-events: none;
}
.insertBetweenDraggingOver {
.insertBetweenBorder {
display: block;
}
.insertBetweenText {
display: inline-block;
}
}
</style>

View File

@ -4,33 +4,67 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="cpjygsrt">
<header>
<div class="title"><slot name="header"></slot></div>
<div class="buttons">
<slot name="func"></slot>
<button v-if="removable" class="_button" @click="remove()">
<div
ref="containerRootEl"
:class="[$style.blockContainerRoot, {
[$style.dragging]: isDragging,
[$style.draggingOver]: isDraggingOver,
}]"
@focus.capture="toggleFocus"
@blur.capture="toggleFocus"
@dragover="dragOver"
@dragleave="dragLeave"
@drop="drop"
>
<header :class="$style.blockContainerHeader" tabindex="1">
<div :class="$style.title"><slot name="header"></slot></div>
<div :class="$style.buttons">
<div v-if="$slots.actions != null"><slot name="actions"></slot></div>
<button v-if="removable" :class="$style.blockContainerActionButton" class="_button" @click="remove()">
<i class="ti ti-trash"></i>
</button>
<button v-if="draggable" class="drag-handle _button">
<template v-if="draggable">
<div :class="$style.divider"></div>
<button
:class="$style.blockContainerActionButton"
class="_button"
@click="() => emit('move', 'up')"
>
<i class="ti ti-arrow-up"></i>
</button>
<button
:class="$style.blockContainerActionButton"
class="_button"
@click="() => emit('move', 'down')"
>
<i class="ti ti-arrow-down"></i>
</button>
<button
draggable="true"
:class="$style.blockContainerActionButton"
class="_button"
:data-block-id="blockId"
@dragstart="dragStart"
@dragend="dragEnd"
>
<i class="ti ti-menu-2"></i>
</button>
<button class="_button" @click="toggleContent(!showBody)">
<template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
<template v-else><i class="ti ti-chevron-down"></i></template>
</button>
</template>
</div>
</header>
<div v-show="showBody" class="body">
<slot></slot>
<div :class="$style.blockContainerBody" tabindex="0">
<slot :focus="focus"></slot>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { ref, useTemplateRef } from 'vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n';
const props = withDefaults(defineProps<{
blockId: string;
expanded?: boolean;
removable?: boolean;
draggable?: boolean;
@ -40,24 +74,103 @@ const props = withDefaults(defineProps<{
});
const emit = defineEmits<{
(ev: 'toggle', show: boolean): void;
(ev: 'remove'): void;
(ev: 'move', direction: 'up' | 'down'): void;
}>();
const showBody = ref(props.expanded);
async function remove() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts._pages.blockDeleteAreYouSure,
});
if (canceled) return;
function toggleContent(show: boolean) {
showBody.value = show;
emit('toggle', show);
emit('remove');
}
function remove() {
emit('remove');
const containerRootEl = useTemplateRef('containerRootEl');
const focus = ref(false);
function toggleFocus() {
focus.value = containerRootEl.value?.contains(document.activeElement) ?? false;
}
const isDragging = ref(false);
function dragStart(ev: DragEvent) {
ev.dataTransfer?.setData('application/x-misskey-pageblock-id', props.blockId);
isDragging.value = true;
}
function dragEnd() {
isDragging.value = false;
}
const isDraggingOver = ref(false);
function dragOver(ev: DragEvent) {
if (isDragging.value) {
//
ev.preventDefault();
isDraggingOver.value = true;
}
}
function dragLeave() {
isDraggingOver.value = false;
}
function drop() {
//
isDraggingOver.value = false;
}
</script>
<style lang="scss" scoped>
.cpjygsrt {
<style lang="scss" module>
.blockContainerRoot {
position: relative;
}
.blockContainerHeader {
position: absolute;
box-sizing: border-box;
top: 0;
right: 0;
transform: translateY(-100%);
z-index: 1;
display: none;
gap: 8px;
height: 42px;
padding: 6px 8px;
background-color: var(--MI_THEME-panel);
border: 2px solid var(--MI_THEME-accent);
border-bottom: none;
border-radius: 8px 8px 0 0;
> .title {
line-height: 26px;
padding-left: 2px;
padding-right: 8px;
border-right: 0.5px solid var(--MI_THEME-divider);
}
> .buttons {
display: flex;
align-items: center;
gap: 8px;
> .divider {
width: 0.5px;
height: 26px;
background-color: var(--MI_THEME-divider);
}
}
}
.blockContainerActionButton {
display: block;
width: 26px;
height: 26px;
line-height: 26px;
text-align: center;
}
.blockContainerBody {
position: relative;
overflow: hidden;
background: var(--MI_THEME-panel);
@ -67,62 +180,45 @@ function remove() {
&:hover {
border: solid 2px var(--MI_THEME-X13);
}
}
&.warn {
border: solid 2px #dec44c;
}
&.error {
border: solid 2px #f00;
}
> header {
> .title {
z-index: 1;
margin: 0;
padding: 0 16px;
line-height: 42px;
font-size: 0.9em;
font-weight: bold;
box-shadow: 0 1px rgba(#000, 0.07);
> i {
margin-right: 6px;
}
&:empty {
display: none;
}
}
> .buttons {
.blockContainerRoot.dragging {
&::after {
content: '';
position: absolute;
z-index: 2;
top: 0;
left: 0;
right: 0;
> button {
padding: 0;
width: 42px;
font-size: 0.9em;
line-height: 42px;
bottom: 0;
background: var(--MI_THEME-bg);
z-index: 1;
border-radius: 8px 0 8px 8px;
}
.drag-handle {
cursor: move;
}
}
&.draggingOver::after {
outline: dashed 2px var(--MI_THEME-accent);
outline-offset: -2px;
}
> .body {
::v-deep(.juejbjww), ::v-deep(.eiipwacr) {
&:not(.inline):first-child {
margin-top: 28px;
.blockContainerHeader {
display: flex;
}
&:not(.inline):last-child {
margin-bottom: 20px;
.blockContainerBody {
border: solid 2px var(--MI_THEME-accent);
border-top-right-radius: 0;
}
}
@container (min-width: 700px) {
.blockContainerRoot:focus-within {
.blockContainerHeader {
display: flex;
}
.blockContainerBody {
border: solid 2px var(--MI_THEME-accent);
border-top-right-radius: 0;
}
}
}

View File

@ -4,64 +4,59 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700">
<div class="jqqmcavi">
<MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="ti ti-external-link"></i> {{ i18n.ts._pages.viewPage }}</MkButton>
<MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
<MkButton v-if="pageId" inline class="button" @click="duplicate"><i class="ti ti-copy"></i> {{ i18n.ts.duplicate }}</MkButton>
<MkButton v-if="pageId && !readonly" inline class="button" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
<MkStickyContainer ref="containerEl">
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="800">
<div v-if="fetchStatus === 'loading'">
<MkLoading/>
</div>
<div v-if="tab === 'settings'">
<div class="_gaps_m">
<MkInput v-model="title">
<template #label>{{ i18n.ts._pages.title }}</template>
</MkInput>
<MkInput v-model="summary">
<template #label>{{ i18n.ts._pages.summary }}</template>
</MkInput>
<MkInput v-model="name">
<template #prefix>{{ url }}/@{{ author.username }}/pages/</template>
<template #label>{{ i18n.ts._pages.url }}</template>
</MkInput>
<MkSwitch v-model="alignCenter">{{ i18n.ts._pages.alignCenter }}</MkSwitch>
<MkSelect v-model="font">
<template #label>{{ i18n.ts._pages.font }}</template>
<option value="serif">{{ i18n.ts._pages.fontSerif }}</option>
<option value="sans-serif">{{ i18n.ts._pages.fontSansSerif }}</option>
</MkSelect>
<MkSwitch v-model="hideTitleWhenPinned">{{ i18n.ts._pages.hideTitleWhenPinned }}</MkSwitch>
<div class="eyeCatch">
<MkButton v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage"><i class="ti ti-plus"></i> {{ i18n.ts._pages.eyeCatchingImageSet }}</MkButton>
<div v-else-if="eyeCatchingImage">
<img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name" style="max-width: 100%;"/>
<MkButton v-if="!readonly" @click="removeEyeCatchingImage()"><i class="ti ti-trash"></i> {{ i18n.ts._pages.eyeCatchingImageRemove }}</MkButton>
<div v-else-if="fetchStatus === 'done' && page != null" class="_gaps" :class="$style.pageMain">
<div :class="$style.pageBanner">
<div v-if="page?.eyeCatchingImageId" :class="$style.pageBannerImage">
<MkMediaImage
:image="page.eyeCatchingImage!"
:cover="true"
:disableImageLink="true"
:class="$style.thumbnail"
/>
</div>
</div>
<div :class="$style.pageBannerTitle" class="_gaps_s">
<input v-model="title" :class="$style.titleForm" :placeholder="i18n.ts._pages.inputTitleHere"/>
<div :class="$style.pageBannerTitleSub">
<div v-if="page?.user" :class="$style.pageBannerTitleUser">
<MkAvatar :user="page.user" :class="$style.avatar" indicator/> <MkUserName :user="page.user" :nowrap="false"/>
</div>
<div :class="$style.pageBannerTitleSubActions">
</div>
</div>
</div>
<div>
<XPage v-if="enableGlobalPreview" key="preview" :page="page" />
<XBlocks v-else key="editor" v-model="content" :scrollContainer="containerEl?.rootEl"/>
</div>
<div v-else-if="tab === 'contents'">
<div :class="$style.contents">
<XBlocks v-model="content" class="content"/>
<MkButton v-if="!readonly" rounded class="add" @click="add()"><i class="ti ti-plus"></i></MkButton>
</div>
<div v-else-if="fetchStatus === 'notMe'" class="_fullInfo">
This page is not yours
</div>
</MkSpacer>
<template #footer>
<div :class="$style.footer">
<div :class="$style.footerInner">
<div :class="$style.footerActionSwitchWrapper">
<MkSwitch v-model="enableGlobalPreview">{{ i18n.ts.preview }}</MkSwitch>
</div>
<div :class="$style.footerActionButtons" class="_buttons">
<MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</div>
</div>
</div>
</template>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, provide, watch, ref } from 'vue';
import { computed, ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import { v4 as uuid } from 'uuid';
import XBlocks from './page-editor.blocks.vue';
@ -69,189 +64,85 @@ import MkButton from '@/components/MkButton.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkInput from '@/components/MkInput.vue';
import MkMediaImage from '@/components/MkMediaImage.vue';
import XPage from '@/components/page/page.vue';
import { url } from '@@/js/config.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { selectFile } from '@/scripts/select-file.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { $i } from '@/account.js';
import { signinRequired } from '@/account.js';
import { mainRouter } from '@/router/main.js';
import { getPageBlockList } from '@/pages/page-editor/common.js';
import type { SlimPage } from '@/types/page.js';
const props = defineProps<{
initPageId?: string;
initPageName?: string;
initUser?: string;
}>();
const tab = ref('settings');
const author = ref($i);
const readonly = ref(false);
const page = ref<Misskey.entities.Page | null>(null);
const pageId = ref<string | null>(null);
const currentName = ref<string | null>(null);
const title = ref('');
const summary = ref<string | null>(null);
const name = ref(Date.now().toString());
const eyeCatchingImage = ref<Misskey.entities.DriveFile | null>(null);
const eyeCatchingImageId = ref<string | null>(null);
const font = ref('sans-serif');
const content = ref<Misskey.entities.Page['content']>([]);
const alignCenter = ref(false);
const hideTitleWhenPinned = ref(false);
const $i = signinRequired();
provide('readonly', readonly.value);
watch(eyeCatchingImageId, async () => {
if (eyeCatchingImageId.value == null) {
eyeCatchingImage.value = null;
const fetchStatus = ref<'loading' | 'done' | 'notMe'>('loading');
const page = ref<Partial<SlimPage> | null>(null);
const title = computed({
get: () => page.value?.title ?? '',
set: (value) => {
if (page.value) {
page.value.title = value;
} else {
eyeCatchingImage.value = await misskeyApi('drive/files/show', {
fileId: eyeCatchingImageId.value,
});
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() {
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,
};
const enableGlobalPreview = ref(false);
const containerEl = useTemplateRef('containerEl');
function onTitleUpdated(ev: Event) {
title.value = (ev.target as HTMLDivElement).innerText;
}
function save() {
const options = getSaveOptions();
async function save() {
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');
});
});
async function show() {
}
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 del() {
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', {
const _page = await misskeyApi('pages/show', {
pageId: props.initPageId,
});
} else if (props.initPageName && props.initUser) {
page.value = await misskeyApi('pages/show', {
name: props.initPageName,
username: props.initUser,
});
readonly.value = true;
if (_page.user.id !== $i.id) {
fetchStatus.value = 'notMe';
return;
}
page.value = _page;
}
if (page.value) {
author.value = page.value.user;
pageId.value = page.value.id;
title.value = page.value.title;
name.value = page.value.name;
currentName.value = page.value.name;
summary.value = page.value.summary;
font.value = page.value.font;
hideTitleWhenPinned.value = page.value.hideTitleWhenPinned;
alignCenter.value = page.value.alignCenter;
content.value = page.value.content;
eyeCatchingImageId.value = page.value.eyeCatchingImageId;
} else {
if (page.value === null) {
const id = uuid();
content.value = [{
id,
@ -259,126 +150,152 @@ async function init() {
text: 'Hello World!',
}];
}
fetchStatus.value = 'done';
}
init();
const headerActions = computed(() => []);
const headerTabs = computed(() => [{
key: 'settings',
title: i18n.ts._pages.pageSetting,
icon: 'ti ti-settings',
}, {
key: 'contents',
title: i18n.ts._pages.contents,
icon: 'ti ti-note',
}]);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
title: props.initPageId ? i18n.ts._pages.editPage
: props.initPageName && props.initUser ? i18n.ts._pages.readPage
: i18n.ts._pages.newPage,
title: props.initPageId ? i18n.ts._pages.editPage : i18n.ts._pages.newPage,
icon: 'ti ti-pencil',
}));
</script>
<style lang="scss" module>
.contents {
&:global {
> .add {
margin: 16px auto 0 auto;
}
}
.pageMain {
border-radius: var(--MI-radius);
padding: 2rem;
background: var(--MI_THEME-panel);
box-sizing: border-box;
}
</style>
<style lang="scss" scoped>
.jqqmcavi {
margin-bottom: 16px;
.pageBanner {
width: calc(100% + 4rem);
margin: -2rem -2rem 0.5rem;
border-radius: var(--MI-radius) var(--MI-radius) 0 0;
overflow: hidden;
position: relative;
}
> .button {
& + .button {
margin-left: 8px;
}
.pageBannerImage {
position: relative;
padding-top: 56.25%;
> .thumbnail {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}
.gwbmwxkm {
.pageBannerTitle {
position: relative;
> header {
> .title {
z-index: 1;
.titleForm {
appearance: none;
-webkit-appearance: none;
box-sizing: border-box;
display: block;
padding: 6px 12px;
font: inherit;
font-size: 2rem;
font-weight: 700;
color: var(--MI_THEME-fg);
margin: 0;
padding: 0 16px;
line-height: 42px;
font-size: 0.9em;
font-weight: bold;
box-shadow: 0 1px rgba(#000, 0.07);
border: none;
border-bottom: 2px solid var(--MI_THEME-divider);
transition: border-color 0.1s ease-out;
background-color: var(--MI_THEME-bg);
border-radius: var(--MI-radius) var(--MI-radius) 0 0;
> i {
margin-right: 6px;
&:hover {
border-color: var(--MI_THEME-inputBorderHover);
}
&:empty {
display: none;
&:focus {
outline: none;
border-color: var(--MI_THEME-accent);
}
&:focus-visible {
outline: 2px solid var(--MI_THEME-focus);
outline-offset: -2px;
}
}
> .buttons {
position: absolute;
z-index: 2;
top: 0;
right: 0;
> button {
padding: 0;
width: 42px;
font-size: 0.9em;
line-height: 42px;
.pageBannerTitleSub {
display: flex;
align-items: center;
width: 100%;
}
.pageBannerTitleUser {
--height: 32px;
flex-shrink: 0;
line-height: var(--height);
.avatar {
height: var(--height);
width: var(--height);
}
}
> 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%;
}
}
}
.pageBannerTitleSubActions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--MI-marginHalf);
margin-left: auto;
}
}
.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;
margin: 0 auto;
padding: 2rem;
}
> .variables {
margin-bottom: 16px;
}
.footer {
backdrop-filter: var(--MI-blur, blur(15px));
background: var(--MI_THEME-acrylicBg);
border-top: solid .5px var(--MI_THEME-divider);
}
> .add {
margin-bottom: 16px;
}
.footerInner {
padding: 16px;
margin: 0 auto;
max-width: 800px;
display: flex;
gap: 8px;
align-items: center;
}
.footerActionSwitchWrapper {
flex-shrink: 0;
}
.footerActionButtons {
margin-left: auto;
flex-shrink: 0;
}
</style>

View File

@ -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>

View File

@ -285,10 +285,6 @@ function showMenu(ev: MouseEvent) {
}
} else if ($i && $i.id !== page.value.userId) {
menuItems.push({
icon: 'ti ti-code',
text: i18n.ts._pages.viewSource,
action: () => router.push(`/@${props.username}/pages/${props.pageName}/view-source`),
}, {
icon: 'ti ti-exclamation-circle',
text: i18n.ts.reportAbuse,
action: reportAbuse,

View File

@ -53,7 +53,8 @@ const tab = ref('featured');
const featuredPagesPagination = {
endpoint: 'pages/featured' as const,
noPaging: true,
limit: 5,
offsetMode: true,
};
const myPagesPagination = {
endpoint: 'i/pages' as const,

View File

@ -17,9 +17,6 @@ export const page = (loader: AsyncComponentLoader) => defineAsyncComponent({
});
const routes: RouteDef[] = [{
path: '/@:initUser/pages/:initPageName/view-source',
component: page(() => import('@/pages/page-editor/page-editor.vue')),
}, {
path: '/@:username/pages/:pageName',
component: page(() => import('@/pages/page.vue')),
}, {

View File

@ -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'
>;

View File

@ -1686,6 +1686,7 @@ declare namespace entities {
PagesCreateRequest,
PagesCreateResponse,
PagesDeleteRequest,
PagesFeaturedRequest,
PagesFeaturedResponse,
PagesLikeRequest,
PagesShowRequest,
@ -2849,6 +2850,9 @@ type PagesCreateResponse = operations['pages___create']['responses']['200']['con
// @public (undocumented)
type PagesDeleteRequest = operations['pages___delete']['requestBody']['content']['application/json'];
// @public (undocumented)
type PagesFeaturedRequest = operations['pages___featured']['requestBody']['content']['application/json'];
// @public (undocumented)
type PagesFeaturedResponse = operations['pages___featured']['responses']['200']['content']['application/json'];

View File

@ -461,6 +461,7 @@ import type {
PagesCreateRequest,
PagesCreateResponse,
PagesDeleteRequest,
PagesFeaturedRequest,
PagesFeaturedResponse,
PagesLikeRequest,
PagesShowRequest,
@ -891,7 +892,7 @@ export type Endpoints = {
'page-push': { req: PagePushRequest; res: EmptyResponse };
'pages/create': { req: PagesCreateRequest; res: PagesCreateResponse };
'pages/delete': { req: PagesDeleteRequest; res: EmptyResponse };
'pages/featured': { req: EmptyRequest; res: PagesFeaturedResponse };
'pages/featured': { req: PagesFeaturedRequest; res: PagesFeaturedResponse };
'pages/like': { req: PagesLikeRequest; res: EmptyResponse };
'pages/show': { req: PagesShowRequest; res: PagesShowResponse };
'pages/unlike': { req: PagesUnlikeRequest; res: EmptyResponse };

View File

@ -464,6 +464,7 @@ export type PagePushRequest = operations['page-push']['requestBody']['content'][
export type PagesCreateRequest = operations['pages___create']['requestBody']['content']['application/json'];
export type PagesCreateResponse = operations['pages___create']['responses']['200']['content']['application/json'];
export type PagesDeleteRequest = operations['pages___delete']['requestBody']['content']['application/json'];
export type PagesFeaturedRequest = operations['pages___featured']['requestBody']['content']['application/json'];
export type PagesFeaturedResponse = operations['pages___featured']['responses']['200']['content']['application/json'];
export type PagesLikeRequest = operations['pages___like']['requestBody']['content']['application/json'];
export type PagesShowRequest = operations['pages___show']['requestBody']['content']['application/json'];

View File

@ -4554,6 +4554,12 @@ export type components = {
type: 'section';
title: string;
children: components['schemas']['PageBlock'][];
}, {
id: string;
/** @enum {string} */
type: 'heading';
level: number;
text: string;
}, {
id: string;
/** @enum {string} */
@ -23501,10 +23507,10 @@ export type operations = {
content: {
[key: string]: unknown;
}[];
variables: {
variables?: {
[key: string]: unknown;
}[];
script: string;
script?: string;
/** Format: misskey:id */
eyeCatchingImageId?: string | null;
/**
@ -23623,6 +23629,16 @@ export type operations = {
* **Credential required**: *No*
*/
pages___featured: {
requestBody: {
content: {
'application/json': {
/** @default 0 */
offset?: number;
/** @default 10 */
limit?: number;
};
};
};
responses: {
/** @description OK (with results) */
200: {