diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-07-04 10:20:00 +0900 |
|---|---|---|
| committer | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-07-04 10:20:00 +0900 |
| commit | dd87d26bdc14d9639b626e3967ca0e3107cdceba (patch) | |
| tree | 14f10c56f40d60cb7d4c1aa736cf594ae05a8f66 | |
| parent | fix(frontend): プラグインのアンインストール時にローカル... (diff) | |
| download | misskey-dd87d26bdc14d9639b626e3967ca0e3107cdceba.tar.gz misskey-dd87d26bdc14d9639b626e3967ca0e3107cdceba.tar.bz2 misskey-dd87d26bdc14d9639b626e3967ca0e3107cdceba.zip | |
feat: Playを検索できるように
#13115
| -rw-r--r-- | CHANGELOG.md | 1 | ||||
| -rw-r--r-- | packages/backend/src/core/FlashService.ts | 49 | ||||
| -rw-r--r-- | packages/backend/src/server/api/endpoint-list.ts | 1 | ||||
| -rw-r--r-- | packages/backend/src/server/api/endpoints/clips/notes.ts | 2 | ||||
| -rw-r--r-- | packages/backend/src/server/api/endpoints/flash/my-likes.ts | 24 | ||||
| -rw-r--r-- | packages/backend/src/server/api/endpoints/flash/search.ts | 59 | ||||
| -rw-r--r-- | packages/frontend/src/pages/flash/flash-index.vue | 46 | ||||
| -rw-r--r-- | packages/misskey-js/etc/misskey-js.api.md | 8 | ||||
| -rw-r--r-- | packages/misskey-js/src/autogen/apiClientJSDoc.ts | 11 | ||||
| -rw-r--r-- | packages/misskey-js/src/autogen/endpoint.ts | 3 | ||||
| -rw-r--r-- | packages/misskey-js/src/autogen/entities.ts | 2 | ||||
| -rw-r--r-- | packages/misskey-js/src/autogen/types.ts | 83 |
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: { |