feat: チャンネルの検索用ページとAPIの追加

* 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>
This commit is contained in:
たーびん 2023-04-11 07:42:27 +09:00 committed by naskya
parent 96b60f731c
commit 61df9d4752
6 changed files with 163 additions and 3 deletions

View File

@ -1282,6 +1282,8 @@ _channel:
following: "Followed" following: "Followed"
usersCount: "{n} Participants" usersCount: "{n} Participants"
notesCount: "{n} Posts" notesCount: "{n} Posts"
nameAndDescription: "Name and description"
nameOnly: "Name only"
_messaging: _messaging:
dms: "Private" dms: "Private"
groups: "Groups" groups: "Groups"

View File

@ -1147,6 +1147,8 @@ _channel:
following: "フォロー中" following: "フォロー中"
usersCount: "{n}人が参加中" usersCount: "{n}人が参加中"
notesCount: "{n}投稿があります" notesCount: "{n}投稿があります"
nameAndDescription: "名前と説明"
nameOnly: "名前のみ"
_messaging: _messaging:
dms: "プライベート" dms: "プライベート"
groups: "グループ" groups: "グループ"

View File

@ -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_follow from "./endpoints/channels/follow.js";
import * as ep___channels_followed from "./endpoints/channels/followed.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_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_show from "./endpoints/channels/show.js";
import * as ep___channels_timeline from "./endpoints/channels/timeline.js"; import * as ep___channels_timeline from "./endpoints/channels/timeline.js";
import * as ep___channels_unfollow from "./endpoints/channels/unfollow.js"; import * as ep___channels_unfollow from "./endpoints/channels/unfollow.js";
@ -438,6 +439,7 @@ const eps = [
["channels/follow", ep___channels_follow], ["channels/follow", ep___channels_follow],
["channels/followed", ep___channels_followed], ["channels/followed", ep___channels_followed],
["channels/owned", ep___channels_owned], ["channels/owned", ep___channels_owned],
['channels/search', ep___channels_search],
["channels/show", ep___channels_show], ["channels/show", ep___channels_show],
["channels/timeline", ep___channels_timeline], ["channels/timeline", ep___channels_timeline],
["channels/unfollow", ep___channels_unfollow], ["channels/unfollow", ep___channels_unfollow],

View File

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

View File

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

View File

@ -20,6 +20,24 @@
@swiper="setSwiperRef" @swiper="setSwiperRef"
@slide-change="onSlideChange" @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> <swiper-slide>
<div class="_content grwlizim featured"> <div class="_content grwlizim featured">
<MkPagination <MkPagination
@ -74,12 +92,16 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, defineComponent, inject, watch } from "vue"; import { computed, onMounted, defineComponent, inject, watch } from "vue";
import { Virtual } from "swiper"; import { Virtual } from "swiper";
import { Swiper, SwiperSlide } from "swiper/vue"; import { Swiper, SwiperSlide } from "swiper/vue";
import MkChannelPreview from "@/components/MkChannelPreview.vue"; import MkChannelPreview from "@/components/MkChannelPreview.vue";
import MkChannelList from '@/components/MkChannelList.vue';
import MkPagination from "@/components/MkPagination.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 MkButton from "@/components/MkButton.vue";
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { useRouter } from "@/router"; import { useRouter } from "@/router";
import { definePageMetadata } from "@/scripts/page-metadata"; import { definePageMetadata } from "@/scripts/page-metadata";
import { deviceKind } from "@/scripts/device-kind"; import { deviceKind } from "@/scripts/device-kind";
@ -90,10 +112,24 @@ import "swiper/scss/virtual";
const router = useRouter(); const router = useRouter();
const tabs = ["featured", "following", "owned"]; const tabs = ["search", "featured", "following", "owned"];
let tab = $ref(tabs[0]); let tab = $ref(tabs[1]);
watch($$(tab), () => syncSlide(tabs.indexOf(tab))); 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 = { const featuredPagination = {
endpoint: "channels/featured" as const, endpoint: "channels/featured" as const,
limit: 10, limit: 10,
@ -108,6 +144,21 @@ const ownedPagination = {
limit: 10, 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() { function create() {
router.push("/channels/new"); router.push("/channels/new");
} }
@ -121,6 +172,11 @@ const headerActions = $computed(() => [
]); ]);
const headerTabs = $computed(() => [ const headerTabs = $computed(() => [
{
key: 'search',
title: i18n.ts.search,
icon: 'ti ti-search',
},
{ {
key: "featured", key: "featured",
title: i18n.ts._channel.featured, title: i18n.ts._channel.featured,