From 61df9d475202107a81f94eb2fd72ff009d5f1b65 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=9F=E3=83=BC=E3=81=B3=E3=82=93?=
 <tar.bin.master@gmail.com>
Date: Tue, 11 Apr 2023 07:42:27 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20=E3=83=81=E3=83=A3=E3=83=B3=E3=83=8D?=
 =?UTF-8?q?=E3=83=AB=E3=81=AE=E6=A4=9C=E7=B4=A2=E7=94=A8=E3=83=9A=E3=83=BC?=
 =?UTF-8?q?=E3=82=B8=E3=81=A8API=E3=81=AE=E8=BF=BD=E5=8A=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* add channel search

* move  channel search to channel list page

---------

Co-authored-by: tamaina <tamaina@hotmail.co.jp>
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
Co-authored-by: atsuchan <83960488+atsu1125@users.noreply.github.com>
Co-authored-by: Masaya Suzuki <15100604+massongit@users.noreply.github.com>
Co-authored-by: Kagami Sascha Rosylight <saschanaz@outlook.com>
Co-authored-by: taiy <53635909+taiyme@users.noreply.github.com>
Co-authored-by: xianon <xianon@hotmail.co.jp>
Co-authored-by: kabo2468 <28654659+kabo2468@users.noreply.github.com>
Co-authored-by: YS <47836716+yszkst@users.noreply.github.com>
Co-authored-by: Khsmty <me@khsmty.com>
Co-authored-by: Soni L <EnderMoneyMod@gmail.com>
Co-authored-by: mei23 <m@m544.net>
Co-authored-by: daima3629 <52790780+daima3629@users.noreply.github.com>
Co-authored-by: Windymelt <1113940+windymelt@users.noreply.github.com>
---
 locales/en-US.yml                             |  2 +
 locales/ja-JP.yml                             |  2 +
 packages/backend/src/server/api/endpoints.ts  |  2 +
 .../server/api/endpoints/channels/search.ts   | 67 +++++++++++++++++++
 .../client/src/components/MkChannelList.vue   | 31 +++++++++
 packages/client/src/pages/channels.vue        | 62 ++++++++++++++++-
 6 files changed, 163 insertions(+), 3 deletions(-)
 create mode 100644 packages/backend/src/server/api/endpoints/channels/search.ts
 create mode 100644 packages/client/src/components/MkChannelList.vue

diff --git a/locales/en-US.yml b/locales/en-US.yml
index 8da0af981..7a23d2c99 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -1282,6 +1282,8 @@ _channel:
   following: "Followed"
   usersCount: "{n} Participants"
   notesCount: "{n} Posts"
+  nameAndDescription: "Name and description"
+  nameOnly: "Name only"
 _messaging:
   dms: "Private"
   groups: "Groups"
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index cb01fb564..0eb17e5af 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1147,6 +1147,8 @@ _channel:
   following: "フォロー中"
   usersCount: "{n}人が参加中"
   notesCount: "{n}投稿があります"
+  nameAndDescription: "名前と説明"
+  nameOnly: "名前のみ"
 _messaging:
   dms: "プライベート"
   groups: "グループ"
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 3f82eb7a7..3baa560a1 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -89,6 +89,7 @@ import * as ep___channels_featured from "./endpoints/channels/featured.js";
 import * as ep___channels_follow from "./endpoints/channels/follow.js";
 import * as ep___channels_followed from "./endpoints/channels/followed.js";
 import * as ep___channels_owned from "./endpoints/channels/owned.js";
+import * as ep___channels_search from './endpoints/channels/search.js';
 import * as ep___channels_show from "./endpoints/channels/show.js";
 import * as ep___channels_timeline from "./endpoints/channels/timeline.js";
 import * as ep___channels_unfollow from "./endpoints/channels/unfollow.js";
@@ -438,6 +439,7 @@ const eps = [
 	["channels/follow", ep___channels_follow],
 	["channels/followed", ep___channels_followed],
 	["channels/owned", ep___channels_owned],
+	['channels/search', ep___channels_search],
 	["channels/show", ep___channels_show],
 	["channels/timeline", ep___channels_timeline],
 	["channels/unfollow", ep___channels_unfollow],
diff --git a/packages/backend/src/server/api/endpoints/channels/search.ts b/packages/backend/src/server/api/endpoints/channels/search.ts
new file mode 100644
index 000000000..a954ba224
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/channels/search.ts
@@ -0,0 +1,67 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Brackets } from 'typeorm';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { QueryService } from '@/core/QueryService.js';
+import type { ChannelsRepository } from '@/models/index.js';
+import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
+import { DI } from '@/di-symbols.js';
+import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
+
+export const meta = {
+	tags: ['channels'],
+
+	requireCredential: false,
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'Channel',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		query: { type: 'string' },
+		type: { type: 'string', enum: ['nameAndDescription', 'nameOnly'], default: 'nameAndDescription' },
+		sinceId: { type: 'string', format: 'misskey:id' },
+		untilId: { type: 'string', format: 'misskey:id' },
+		limit: { type: 'integer', minimum: 1, maximum: 100, default: 5 },
+	},
+	required: ['query'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.channelsRepository)
+		private channelsRepository: ChannelsRepository,
+
+		private channelEntityService: ChannelEntityService,
+		private queryService: QueryService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId);
+
+			if (ps.type === 'nameAndDescription') {
+				query.andWhere(new Brackets(qb => { qb
+					.where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
+					.orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
+				}));
+			} else {
+				query.andWhere('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
+			}
+
+			const channels = await query
+				.take(ps.limit)
+				.getMany();
+
+			return await Promise.all(channels.map(x => this.channelEntityService.pack(x, me)));
+		});
+	}
+}
diff --git a/packages/client/src/components/MkChannelList.vue b/packages/client/src/components/MkChannelList.vue
new file mode 100644
index 000000000..408eab739
--- /dev/null
+++ b/packages/client/src/components/MkChannelList.vue
@@ -0,0 +1,31 @@
+<template>
+<MkPagination :pagination="pagination">
+	<template #empty>
+		<div class="_fullinfo">
+			<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+			<div>{{ i18n.ts.notFound }}</div>
+		</div>
+	</template>
+
+	<template #default="{ items }">
+		<MkChannelPreview v-for="item in items" :key="item.id" class="_margin" :channel="extractor(item)"/>
+	</template>
+</MkPagination>
+</template>
+
+<script lang="ts" setup>
+import MkChannelPreview from '@/components/MkChannelPreview.vue';
+import MkPagination, { Paging } from '@/components/MkPagination.vue';
+import { i18n } from '@/i18n';
+
+const props = withDefaults(defineProps<{
+	pagination: Paging;
+	noGap?: boolean;
+	extractor?: (item: any) => any;
+}>(), {
+	extractor: (item) => item,
+});
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/client/src/pages/channels.vue b/packages/client/src/pages/channels.vue
index 2d8eeefcf..f243f9345 100644
--- a/packages/client/src/pages/channels.vue
+++ b/packages/client/src/pages/channels.vue
@@ -20,6 +20,24 @@
 				@swiper="setSwiperRef"
 				@slide-change="onSlideChange"
 			>
+				<swiper-slide>
+					<div class="_content grwlizim search">
+						<div class="gaps">
+							<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search">
+								<template #prefix><i class="ti ti-search"></i></template>
+							</MkInput>
+							<MkRadios v-model="searchType" @update:model-value="search()">
+								<option value="nameAndDescription">{{ i18n.ts._channel.nameAndDescription }}</option>
+								<option value="nameOnly">{{ i18n.ts._channel.nameOnly }}</option>
+							</MkRadios>
+							<MkButton large primary gradate rounded @click="search">{{ i18n.ts.search }}</MkButton>
+						</div>
+						<MkFoldableSection v-if="channelPagination">
+							<template #header>{{ i18n.ts.searchResult }}</template>
+							<MkChannelList :key="key" :pagination="channelPagination"/>
+						</MkFoldableSection>
+					</div>
+				</swiper-slide>
 				<swiper-slide>
 					<div class="_content grwlizim featured">
 						<MkPagination
@@ -74,12 +92,16 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, defineComponent, inject, watch } from "vue";
+import { computed, onMounted, defineComponent, inject, watch } from "vue";
 import { Virtual } from "swiper";
 import { Swiper, SwiperSlide } from "swiper/vue";
 import MkChannelPreview from "@/components/MkChannelPreview.vue";
+import MkChannelList from '@/components/MkChannelList.vue';
 import MkPagination from "@/components/MkPagination.vue";
+import MkInput from '@/components/MkInput.vue';
+import MkRadios from '@/components/MkRadios.vue';
 import MkButton from "@/components/MkButton.vue";
+import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import { useRouter } from "@/router";
 import { definePageMetadata } from "@/scripts/page-metadata";
 import { deviceKind } from "@/scripts/device-kind";
@@ -90,10 +112,24 @@ import "swiper/scss/virtual";
 
 const router = useRouter();
 
-const tabs = ["featured", "following", "owned"];
-let tab = $ref(tabs[0]);
+const tabs = ["search", "featured", "following", "owned"];
+let tab = $ref(tabs[1]);
 watch($$(tab), () => syncSlide(tabs.indexOf(tab)));
 
+const props = defineProps<{
+	query: string;
+	type?: string;
+}>();
+let key = $ref('');
+let tab = $ref('search');
+let searchQuery = $ref('');
+let searchType = $ref('nameAndDescription');
+let channelPagination = $ref();
+onMounted(() => {
+	searchQuery = props.query ?? '';
+	searchType = props.type ?? 'nameAndDescription';
+});
+
 const featuredPagination = {
 	endpoint: "channels/featured" as const,
 	limit: 10,
@@ -108,6 +144,21 @@ const ownedPagination = {
 	limit: 10,
 };
 
+async function search() {
+	const query = searchQuery.toString().trim();
+	if (query == null || query === '') return;
+	const type = searchType.toString().trim();
+	channelPagination = {
+		endpoint: 'channels/search',
+		limit: 10,
+		params: {
+			query: searchQuery,
+			type: type,
+		},
+	};
+	key = query + type;
+}
+
 function create() {
 	router.push("/channels/new");
 }
@@ -121,6 +172,11 @@ const headerActions = $computed(() => [
 ]);
 
 const headerTabs = $computed(() => [
+	{
+		key: 'search',
+		title: i18n.ts.search,
+		icon: 'ti ti-search',
+	},
 	{
 		key: "featured",
 		title: i18n.ts._channel.featured,