summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsyuilo <4439005+syuilo@users.noreply.github.com>2025-07-04 10:20:00 +0900
committersyuilo <4439005+syuilo@users.noreply.github.com>2025-07-04 10:20:00 +0900
commitdd87d26bdc14d9639b626e3967ca0e3107cdceba (patch)
tree14f10c56f40d60cb7d4c1aa736cf594ae05a8f66
parentfix(frontend): プラグインのアンインストール時にローカル... (diff)
downloadmisskey-dd87d26bdc14d9639b626e3967ca0e3107cdceba.tar.gz
misskey-dd87d26bdc14d9639b626e3967ca0e3107cdceba.tar.bz2
misskey-dd87d26bdc14d9639b626e3967ca0e3107cdceba.zip
feat: Playを検索できるように
#13115
-rw-r--r--CHANGELOG.md1
-rw-r--r--packages/backend/src/core/FlashService.ts49
-rw-r--r--packages/backend/src/server/api/endpoint-list.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/clips/notes.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/flash/my-likes.ts24
-rw-r--r--packages/backend/src/server/api/endpoints/flash/search.ts59
-rw-r--r--packages/frontend/src/pages/flash/flash-index.vue46
-rw-r--r--packages/misskey-js/etc/misskey-js.api.md8
-rw-r--r--packages/misskey-js/src/autogen/apiClientJSDoc.ts11
-rw-r--r--packages/misskey-js/src/autogen/endpoint.ts3
-rw-r--r--packages/misskey-js/src/autogen/entities.ts2
-rw-r--r--packages/misskey-js/src/autogen/types.ts83
12 files changed, 271 insertions, 18 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7ae5e789d..90c4ce48a6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
### General
- Feat: ノートの下書き機能
- Feat: クリップ内でノートを検索できるように
+- Feat: Playを検索できるように
### Client
- Feat: モデログを検索できるように
diff --git a/packages/backend/src/core/FlashService.ts b/packages/backend/src/core/FlashService.ts
index 2a98225382..8caffe9e45 100644
--- a/packages/backend/src/core/FlashService.ts
+++ b/packages/backend/src/core/FlashService.ts
@@ -4,8 +4,11 @@
*/
import { Inject, Injectable } from '@nestjs/common';
+import { Brackets } from 'typeorm';
import { DI } from '@/di-symbols.js';
-import { type FlashsRepository } from '@/models/_.js';
+import { type FlashLikesRepository, MiUser, type FlashsRepository } from '@/models/_.js';
+import { QueryService } from '@/core/QueryService.js';
+import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
/**
* MisskeyPlay関係のService
@@ -15,6 +18,11 @@ export class FlashService {
constructor(
@Inject(DI.flashsRepository)
private flashRepository: FlashsRepository,
+
+ @Inject(DI.flashLikesRepository)
+ private flashLikesRepository: FlashLikesRepository,
+
+ private queryService: QueryService,
) {
}
@@ -37,4 +45,43 @@ export class FlashService {
return await builder.getMany();
}
+
+ public async myLikes(meId: MiUser['id'], opts: { sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number, limit?: number, search?: string | null }) {
+ const query = this.queryService.makePaginationQuery(this.flashLikesRepository.createQueryBuilder('like'), opts.sinceId, opts.untilId, opts.sinceDate, opts.untilDate)
+ .andWhere('like.userId = :meId', { meId })
+ .leftJoinAndSelect('like.flash', 'flash');
+
+ if (opts.search != null) {
+ for (const word of opts.search.trim().split(' ')) {
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('flash.title ILIKE :search', { search: `%${sqlLikeEscape(word)}%` });
+ qb.orWhere('flash.summary ILIKE :search', { search: `%${sqlLikeEscape(word)}%` });
+ }));
+ }
+ }
+
+ const likes = await query
+ .limit(opts.limit)
+ .getMany();
+
+ return likes;
+ }
+
+ public async search(searchQuery: string, opts: { sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number, limit?: number }) {
+ const query = this.queryService.makePaginationQuery(this.flashRepository.createQueryBuilder('flash'), opts.sinceId, opts.untilId, opts.sinceDate, opts.untilDate)
+ .andWhere('flash.visibility = \'public\'');
+
+ for (const word of searchQuery.trim().split(' ')) {
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('flash.title ILIKE :search', { search: `%${sqlLikeEscape(word)}%` });
+ qb.orWhere('flash.summary ILIKE :search', { search: `%${sqlLikeEscape(word)}%` });
+ }));
+ }
+
+ const result = await query
+ .limit(opts.limit)
+ .getMany();
+
+ return result;
+ }
}
diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts
index f7b2fad341..eb83c11b39 100644
--- a/packages/backend/src/server/api/endpoint-list.ts
+++ b/packages/backend/src/server/api/endpoint-list.ts
@@ -208,6 +208,7 @@ export * as 'flash/my-likes' from './endpoints/flash/my-likes.js';
export * as 'flash/show' from './endpoints/flash/show.js';
export * as 'flash/unlike' from './endpoints/flash/unlike.js';
export * as 'flash/update' from './endpoints/flash/update.js';
+export * as 'flash/search' from './endpoints/flash/search.js';
export * as 'following/create' from './endpoints/following/create.js';
export * as 'following/delete' from './endpoints/following/delete.js';
export * as 'following/invalidate' from './endpoints/following/invalidate.js';
diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts
index ecd0afc386..c4260fd87c 100644
--- a/packages/backend/src/server/api/endpoints/clips/notes.ts
+++ b/packages/backend/src/server/api/endpoints/clips/notes.ts
@@ -101,7 +101,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
if (ps.search != null) {
- for (const word of ps.search!.trim().split(' ')) {
+ for (const word of ps.search.trim().split(' ')) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.text ILIKE :search', { search: `%${sqlLikeEscape(word)}%` });
qb.orWhere('note.cw ILIKE :search', { search: `%${sqlLikeEscape(word)}%` });
diff --git a/packages/backend/src/server/api/endpoints/flash/my-likes.ts b/packages/backend/src/server/api/endpoints/flash/my-likes.ts
index c1a197214c..ff9d6c3264 100644
--- a/packages/backend/src/server/api/endpoints/flash/my-likes.ts
+++ b/packages/backend/src/server/api/endpoints/flash/my-likes.ts
@@ -5,10 +5,9 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { FlashLikesRepository } from '@/models/_.js';
-import { QueryService } from '@/core/QueryService.js';
import { FlashLikeEntityService } from '@/core/entities/FlashLikeEntityService.js';
import { DI } from '@/di-symbols.js';
+import { FlashService } from '@/core/FlashService.js';
export const meta = {
tags: ['account', 'flash'],
@@ -46,6 +45,7 @@ export const paramDef = {
untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' },
untilDate: { type: 'integer' },
+ search: { type: 'string', minLength: 1, maxLength: 100, nullable: true },
},
required: [],
} as const;
@@ -53,20 +53,18 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
- @Inject(DI.flashLikesRepository)
- private flashLikesRepository: FlashLikesRepository,
-
private flashLikeEntityService: FlashLikeEntityService,
- private queryService: QueryService,
+ private flashService: FlashService,
) {
super(meta, paramDef, async (ps, me) => {
- const query = this.queryService.makePaginationQuery(this.flashLikesRepository.createQueryBuilder('like'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
- .andWhere('like.userId = :meId', { meId: me.id })
- .leftJoinAndSelect('like.flash', 'flash');
-
- const likes = await query
- .limit(ps.limit)
- .getMany();
+ const likes = await this.flashService.myLikes(me.id, {
+ sinceId: ps.sinceId,
+ untilId: ps.untilId,
+ sinceDate: ps.sinceDate,
+ untilDate: ps.untilDate,
+ limit: ps.limit,
+ search: ps.search,
+ });
return this.flashLikeEntityService.packMany(likes, me);
});
diff --git a/packages/backend/src/server/api/endpoints/flash/search.ts b/packages/backend/src/server/api/endpoints/flash/search.ts
new file mode 100644
index 0000000000..36948bb7b4
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/flash/search.ts
@@ -0,0 +1,59 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
+import { DI } from '@/di-symbols.js';
+import { FlashService } from '@/core/FlashService.js';
+
+export const meta = {
+ tags: ['flash'],
+
+ requireCredential: false,
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'Flash',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ query: { type: 'string', minLength: 1, maxLength: 100 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ sinceDate: { type: 'integer' },
+ untilDate: { type: 'integer' },
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 5 },
+ },
+ required: ['query'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private flashService: FlashService,
+ private flashEntityService: FlashEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const result = await this.flashService.search(ps.query, {
+ sinceId: ps.sinceId,
+ untilId: ps.untilId,
+ sinceDate: ps.sinceDate,
+ untilDate: ps.untilDate,
+ limit: ps.limit,
+ });
+
+ return await this.flashEntityService.packMany(result, me);
+ });
+ }
+}
diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue
index 6e25df2df8..43632f55ca 100644
--- a/packages/frontend/src/pages/flash/flash-index.vue
+++ b/packages/frontend/src/pages/flash/flash-index.vue
@@ -6,7 +6,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true">
<div class="_spacer" style="--MI_SPACER-w: 700px;">
- <div v-if="tab === 'featured'">
+ <div v-if="tab === 'search'">
+ <div class="_gaps">
+ <MkInput v-model="searchQuery" :large="true" type="search">
+ <template #prefix><i class="ti ti-search"></i></template>
+ </MkInput>
+ <MkButton large primary gradate rounded style="margin: 0 auto;" @click="search">{{ i18n.ts.search }}</MkButton>
+ <MkPagination v-if="searchPaginator" v-slot="{items}" :key="searchKey" :paginator="searchPaginator">
+ <div class="_gaps_s">
+ <MkFlashPreview v-for="flash in items" :key="flash.id" :flash="flash"/>
+ </div>
+ </MkPagination>
+ </div>
+ </div>
+
+ <div v-else-if="tab === 'featured'">
<MkPagination v-slot="{items}" :paginator="featuredFlashsPaginator">
<div class="_gaps_s">
<MkFlashPreview v-for="flash in items" :key="flash.id" :flash="flash"/>
@@ -26,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-else-if="tab === 'liked'">
- <MkPagination v-slot="{items}" :paginator="likedFlashsPaginator">
+ <MkPagination v-slot="{items}" :paginator="likedFlashsPaginator" withControl>
<div class="_gaps_s">
<MkFlashPreview v-for="like in items" :key="like.flash.id" :flash="like.flash"/>
</div>
@@ -37,10 +51,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { computed, markRaw, ref } from 'vue';
+import { computed, markRaw, ref, shallowRef } from 'vue';
+import type { IPaginator } from '@/utility/paginator.js';
import MkFlashPreview from '@/components/MkFlashPreview.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/MkInput.vue';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { useRouter } from '@/router.js';
@@ -50,6 +66,10 @@ const router = useRouter();
const tab = ref('featured');
+const searchQuery = ref('');
+const searchPaginator = shallowRef<IPaginator | null>(null);
+const searchKey = ref(0);
+
const featuredFlashsPaginator = markRaw(new Paginator('flash/featured', {
limit: 5,
offsetMode: true,
@@ -59,12 +79,28 @@ const myFlashsPaginator = markRaw(new Paginator('flash/my', {
}));
const likedFlashsPaginator = markRaw(new Paginator('flash/my-likes', {
limit: 5,
+ canSearch: true,
+ searchParamName: 'search',
}));
function create() {
router.push('/play/new');
}
+function search() {
+ if (searchQuery.value.trim() === '') {
+ return;
+ }
+
+ searchPaginator.value = markRaw(new Paginator('flash/search', {
+ params: {
+ query: searchQuery.value,
+ },
+ }));
+
+ searchKey.value++;
+}
+
const headerActions = computed(() => [{
icon: 'ti ti-plus',
text: i18n.ts.create,
@@ -72,6 +108,10 @@ const headerActions = computed(() => [{
}]);
const headerTabs = computed(() => [{
+ key: 'search',
+ title: i18n.ts.search,
+ icon: 'ti ti-search',
+}, {
key: 'featured',
title: i18n.ts._play.featured,
icon: 'ti ti-flare',
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 027293b210..f38e959fb2 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -1805,6 +1805,8 @@ declare namespace entities {
FlashMyResponse,
FlashMyLikesRequest,
FlashMyLikesResponse,
+ FlashSearchRequest,
+ FlashSearchResponse,
FlashShowRequest,
FlashShowResponse,
FlashUnlikeRequest,
@@ -2287,6 +2289,12 @@ type FlashMyRequest = operations['flash___my']['requestBody']['content']['applic
type FlashMyResponse = operations['flash___my']['responses']['200']['content']['application/json'];
// @public (undocumented)
+type FlashSearchRequest = operations['flash___search']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type FlashSearchResponse = operations['flash___search']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
type FlashShowRequest = operations['flash___show']['requestBody']['content']['application/json'];
// @public (undocumented)
diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
index c638075777..60e238351c 100644
--- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts
+++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
@@ -2443,6 +2443,17 @@ declare module '../api.js' {
*
* **Credential required**: *No*
*/
+ request<E extends 'flash/search', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *No*
+ */
request<E extends 'flash/show', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts
index e6c0525f3b..929cca183f 100644
--- a/packages/misskey-js/src/autogen/endpoint.ts
+++ b/packages/misskey-js/src/autogen/endpoint.ts
@@ -340,6 +340,8 @@ import type {
FlashMyResponse,
FlashMyLikesRequest,
FlashMyLikesResponse,
+ FlashSearchRequest,
+ FlashSearchResponse,
FlashShowRequest,
FlashShowResponse,
FlashUnlikeRequest,
@@ -869,6 +871,7 @@ export type Endpoints = {
'flash/like': { req: FlashLikeRequest; res: EmptyResponse };
'flash/my': { req: FlashMyRequest; res: FlashMyResponse };
'flash/my-likes': { req: FlashMyLikesRequest; res: FlashMyLikesResponse };
+ 'flash/search': { req: FlashSearchRequest; res: FlashSearchResponse };
'flash/show': { req: FlashShowRequest; res: FlashShowResponse };
'flash/unlike': { req: FlashUnlikeRequest; res: EmptyResponse };
'flash/update': { req: FlashUpdateRequest; res: EmptyResponse };
diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts
index 1d92094ddf..002dfaaf30 100644
--- a/packages/misskey-js/src/autogen/entities.ts
+++ b/packages/misskey-js/src/autogen/entities.ts
@@ -343,6 +343,8 @@ export type FlashMyRequest = operations['flash___my']['requestBody']['content'][
export type FlashMyResponse = operations['flash___my']['responses']['200']['content']['application/json'];
export type FlashMyLikesRequest = operations['flash___my-likes']['requestBody']['content']['application/json'];
export type FlashMyLikesResponse = operations['flash___my-likes']['responses']['200']['content']['application/json'];
+export type FlashSearchRequest = operations['flash___search']['requestBody']['content']['application/json'];
+export type FlashSearchResponse = operations['flash___search']['responses']['200']['content']['application/json'];
export type FlashShowRequest = operations['flash___show']['requestBody']['content']['application/json'];
export type FlashShowResponse = operations['flash___show']['responses']['200']['content']['application/json'];
export type FlashUnlikeRequest = operations['flash___unlike']['requestBody']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index cf2ee58621..78d509838b 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -1997,6 +1997,15 @@ export type paths = {
*/
post: operations['flash___my-likes'];
};
+ '/flash/search': {
+ /**
+ * flash/search
+ * @description No description provided.
+ *
+ * **Credential required**: *No*
+ */
+ post: operations['flash___search'];
+ };
'/flash/show': {
/**
* flash/show
@@ -21394,6 +21403,7 @@ export interface operations {
untilId?: string;
sinceDate?: number;
untilDate?: number;
+ search?: string | null;
};
};
};
@@ -21458,6 +21468,79 @@ export interface operations {
};
};
};
+ flash___search: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ query: string;
+ /** Format: misskey:id */
+ sinceId?: string;
+ /** Format: misskey:id */
+ untilId?: string;
+ sinceDate?: number;
+ untilDate?: number;
+ /** @default 5 */
+ limit?: number;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Flash'][];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
flash___show: {
requestBody: {
content: {