Merge pull request 'feat: channel search' (#10048) from naskya/calckey:feat/search-channels into develop

Reviewed-on: https://codeberg.org/calckey/calckey/pulls/10048
This commit is contained in:
Kainoa Kanter 2023-05-07 03:06:01 +00:00
commit 62f20383e9
9 changed files with 210 additions and 3 deletions

View file

@ -1283,6 +1283,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

@ -1070,6 +1070,8 @@ _channel:
following: "正在关注" following: "正在关注"
usersCount: "有{n}人参与" usersCount: "有{n}人参与"
notesCount: "有{n}个帖子" notesCount: "有{n}个帖子"
nameAndDescription: "名称与描述"
nameOnly: "仅名称"
_menuDisplay: _menuDisplay:
sideFull: "横向" sideFull: "横向"
sideIcon: "横向(图标)" sideIcon: "横向(图标)"

View file

@ -1073,6 +1073,8 @@ _channel:
following: "關注中" following: "關注中"
usersCount: "有{n}人參與" usersCount: "有{n}人參與"
notesCount: "有{n}個貼文" notesCount: "有{n}個貼文"
nameAndDescription: "名稱與說明"
nameOnly: "僅名稱"
_menuDisplay: _menuDisplay:
sideFull: "側向" sideFull: "側向"
sideIcon: "側向(圖示)" sideIcon: "側向(圖示)"

View file

@ -0,0 +1,3 @@
export function sqlLikeEscape(s: string) {
return s.replace(/([%_])/g, "\\$1");
}

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,69 @@
import define from "../../define.js";
import { Brackets } from "typeorm";
import { Endpoint } from "@/server/api/endpoint-base.js";
import { makePaginationQuery } from "@/server/api/common/make-pagination-query.js";
import { Channels } from "@/models/index.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;
export default define(meta, paramDef, async (ps, me) => {
const query = makePaginationQuery(
Channels.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) => Channels.pack(x, me)));
});

View file

@ -0,0 +1,42 @@
<template>
<MkPagination :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img
src="/static-assets/badges/info.png"
class="_ghost"
:alt="i18n.ts.notFound"
/>
<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,52 @@
@swiper="setSwiperRef" @swiper="setSwiperRef"
@slide-change="onSlideChange" @slide-change="onSlideChange"
> >
<swiper-slide>
<div class="_content grwlizim search">
<MkInput
v-model="searchQuery"
:large="true"
:autofocus="true"
type="search"
>
<template #prefix
><i
class="ph-magnifying-glass ph-bold ph-lg"
></i
></template>
</MkInput>
<MkRadios
v-model="searchType"
@update:model-value="search()"
class="_gap"
>
<option value="nameAndDescription">
{{ i18n.ts._channel.nameAndDescription }}
</option>
<option value="nameOnly">
{{ i18n.ts._channel.nameOnly }}
</option>
</MkRadios>
<MkButton
large
primary
gradate
rounded
@click="search"
class="_gap"
>{{ i18n.ts.search }}</MkButton
>
<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 +120,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/form/input.vue";
import MkRadios from "@/components/form/radios.vue";
import MkButton from "@/components/MkButton.vue"; import MkButton from "@/components/MkButton.vue";
import MkFolder from "@/components/MkFolder.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 +140,23 @@ 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 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 +171,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 +199,11 @@ const headerActions = $computed(() => [
]); ]);
const headerTabs = $computed(() => [ const headerTabs = $computed(() => [
{
key: "search",
title: i18n.ts.search,
icon: "ph-magnifying-glass ph-bold ph-lg",
},
{ {
key: "featured", key: "featured",
title: i18n.ts._channel.featured, title: i18n.ts._channel.featured,