feat: Playを検索できるように

#13115
This commit is contained in:
syuilo 2025-07-04 10:20:00 +09:00
parent b7a6301c2e
commit dd87d26bdc
12 changed files with 271 additions and 18 deletions

View File

@ -3,6 +3,7 @@
### General
- Feat: ノートの下書き機能
- Feat: クリップ内でノートを検索できるように
- Feat: Playを検索できるように
### Client
- Feat: モデログを検索できるように

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1805,6 +1805,8 @@ declare namespace entities {
FlashMyResponse,
FlashMyLikesRequest,
FlashMyLikesResponse,
FlashSearchRequest,
FlashSearchResponse,
FlashShowRequest,
FlashShowResponse,
FlashUnlikeRequest,
@ -2286,6 +2288,12 @@ type FlashMyRequest = operations['flash___my']['requestBody']['content']['applic
// @public (undocumented)
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'];

View File

@ -2438,6 +2438,17 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **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.
*

View File

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

View File

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

View File

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