[client] Add proper search page

This commit is contained in:
Laura Hausmann 2023-11-19 00:22:46 +01:00
parent 855409332b
commit ee4b58dee8
Signed by: zotan
GPG key ID: D044E84C5BE01605
10 changed files with 332 additions and 276 deletions

View file

@ -1131,6 +1131,7 @@ verifiedLink: "Verified link"
openInMainColumn: "Open in main column"
searchNotLoggedIn_1: "You have to be authenticated in order to use full text search."
searchNotLoggedIn_2: "However, you can search using hashtags, and search users."
searchEmptyQuery: "Please enter a serch term."
_sensitiveMediaDetection:
description: "Reduces the effort of server moderation through automatically recognizing

View file

@ -98,15 +98,6 @@
"
/>
</template>
<template v-if="input.type === 'search'" #suffix>
<button
v-tooltip.noDelay="i18n.ts.filter"
class="_buttonIcon"
@click.stop="openSearchFilters"
>
<i class="ph-funnel ph-bold"></i>
</button>
</template>
</MkInput>
<MkTextarea
v-if="input && input.type === 'paragraph'"
@ -200,15 +191,12 @@
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, shallowRef } from "vue";
import * as Acct from "iceshrimp-js/built/acct";
import MkModal from "@/components/MkModal.vue";
import MkButton from "@/components/MkButton.vue";
import MkInput from "@/components/form/input.vue";
import MkTextarea from "@/components/form/textarea.vue";
import MkSelect from "@/components/form/select.vue";
import * as os from "@/os";
import { i18n } from "@/i18n";
import XSearchFilterDialog from "@/components/MkSearchFilterDialog.vue";
interface Input {
type: HTMLInputElement["type"];
@ -347,179 +335,6 @@ function onInputKeydown(evt: KeyboardEvent) {
}
}
function formatDateToYYYYMMDD(date) {
const year = date.getFullYear();
const month = ("0" + (date.getMonth() + 1)).slice(-2);
const day = ("0" + (date.getDate() + 1)).slice(-2);
return `${year}-${month}-${day}`;
}
function appendSearchFilter(filter: string, trailingSpace: boolean = true) {
if (typeof inputValue.value !== "string") inputValue.value = "";
if (inputValue.value.length > 0 && inputValue.value.at(inputValue.value.length - 1) !== " ") inputValue.value += " ";
inputValue.value += filter;
if (trailingSpace) inputValue.value += " ";
}
async function openSearchFilters(ev) {
await os.popupMenu(
[
{
icon: "ph-user ph-bold ph-lg",
text: i18n.ts._filters.fromUser,
action: () => {
os.selectUser().then((user) => {
appendSearchFilter(`from:${Acct.toString(user)}`);
});
},
},
{
icon: "ph-at ph-bold ph-lg",
text: i18n.ts._filters.mentioning,
action: () => {
os.selectUser().then((user) => {
appendSearchFilter(`mention:${Acct.toString(user)}`);
});
},
},
{
icon: "ph-arrow-u-up-left ph-bold ph-lg",
text: i18n.ts._filters.replyTo,
action: () => {
os.selectUser().then((user) => {
appendSearchFilter(`reply:${Acct.toString(user)}`);
});
},
},
null,
{
icon: "ph-eye ph-bold ph-lg",
text: i18n.ts._filters.followingOnly,
action: () => {
appendSearchFilter("filter:following");
},
},
{
icon: "ph-users-three ph-bold ph-lg",
text: i18n.ts._filters.followersOnly,
action: () => {
appendSearchFilter("filter:followers");
},
},
{
icon: "ph-link ph-bold ph-lg",
text: i18n.ts._filters.fromDomain,
action: () => {
appendSearchFilter("instance:", false);
},
},
null,
{
type: "parent",
text: i18n.ts._filters.withFile,
icon: "ph-paperclip ph-bold ph-lg",
children: [
{
text: i18n.ts.image,
icon: "ph-image-square ph-bold ph-lg",
action: () => {
appendSearchFilter("has:image");
},
},
{
text: i18n.ts.video,
icon: "ph-video-camera ph-bold ph-lg",
action: () => {
appendSearchFilter("has:video");
},
},
{
text: i18n.ts.audio,
icon: "ph-music-note ph-bold ph-lg",
action: () => {
appendSearchFilter("has:audio");
},
},
{
text: i18n.ts.file,
icon: "ph-file ph-bold ph-lg",
action: () => {
appendSearchFilter("has:file");
},
},
],
},
null,
{
icon: "ph-calendar-blank ph-bold ph-lg",
text: i18n.ts._filters.notesBefore,
action: () => {
os.inputDate({
title: i18n.ts._filters.notesBefore,
}).then((res) => {
if (res.canceled) return;
appendSearchFilter("before:" + formatDateToYYYYMMDD(res.result));
});
},
},
{
icon: "ph-calendar-blank ph-bold ph-lg",
text: i18n.ts._filters.notesAfter,
action: () => {
os.inputDate({
title: i18n.ts._filters.notesAfter,
}).then((res) => {
if (res.canceled) return;
appendSearchFilter("after:" + formatDateToYYYYMMDD(res.result));
});
},
},
null,
{
icon: "ph-arrow-u-up-left ph-bold ph-lg",
text: i18n.ts._filters.excludeReplies,
action: () => {
appendSearchFilter("-filter:replies");
},
},
{
icon: "ph-repeat ph-bold ph-lg",
text: i18n.ts._filters.excludeRenotes,
action: () => {
appendSearchFilter("-filter:renotes");
},
},
null,
{
icon: "ph-text-aa ph-bold ph-lg",
text: i18n.ts._filters.caseSensitive,
action: () => {
appendSearchFilter("case:sensitive");
},
},
{
icon: "ph-brackets-angle ph-bold ph-lg",
text: i18n.ts._filters.matchWords,
action: () => {
appendSearchFilter("match:words");
},
},
null,
{
icon: "ph-question ph-bold ph-lg",
text: i18n.ts._filters._dialog.learnMore,
action: () => {
os.popup(XSearchFilterDialog, {}, {}, "closed");
},
},
],
ev.target,
{ noReturnFocus: true },
);
inputEl.value!.focus();
inputEl.value!.selectRange((inputValue.value as string).length, (inputValue.value as string).length); // cursor at end
}
onMounted(() => {
document.addEventListener("keydown", onKeydown);
});

View file

@ -0,0 +1,239 @@
<script setup lang="ts">
import { i18n } from "@/i18n.js";
import MkInput from "@/components/form/input.vue";
import * as os from "@/os.js";
import XSearchFilterDialog from "@/components/MkSearchFilterDialog.vue";
import { onActivated, onMounted, onUnmounted, ref, toRefs } from "vue";
import * as Acct from "iceshrimp-js/built/acct";
const props = defineProps<{
query: string;
}>();
const emit = defineEmits<{
(ev: "query", v: string): void;
}>();
const query = ref(props.query);
const input = ref<typeof MkInput>();
onActivated(() => {
query.value = props.query;
input.value!.focus();
});
function formatDateToYYYYMMDD(date) {
const year = date.getFullYear();
const month = ("0" + (date.getMonth() + 1)).slice(-2);
const day = ("0" + (date.getDate() + 1)).slice(-2);
return `${year}-${month}-${day}`;
}
function appendSearchFilter(filter: string, trailingSpace: boolean = true) {
if (query.value.length > 0 && query.value.at(query.value.length - 1) !== " ") query.value += " ";
query.value += filter;
if (trailingSpace) query.value += " ";
}
async function openSearchFilters(ev) {
await os.popupMenu(
[
{
icon: "ph-user ph-bold ph-lg",
text: i18n.ts._filters.fromUser,
action: () => {
os.selectUser().then((user) => {
appendSearchFilter(`from:${Acct.toString(user)}`);
});
},
},
{
icon: "ph-at ph-bold ph-lg",
text: i18n.ts._filters.mentioning,
action: () => {
os.selectUser().then((user) => {
appendSearchFilter(`mention:${Acct.toString(user)}`);
});
},
},
{
icon: "ph-arrow-u-up-left ph-bold ph-lg",
text: i18n.ts._filters.replyTo,
action: () => {
os.selectUser().then((user) => {
appendSearchFilter(`reply:${Acct.toString(user)}`);
});
},
},
null,
{
icon: "ph-eye ph-bold ph-lg",
text: i18n.ts._filters.followingOnly,
action: () => {
appendSearchFilter("filter:following");
},
},
{
icon: "ph-users-three ph-bold ph-lg",
text: i18n.ts._filters.followersOnly,
action: () => {
appendSearchFilter("filter:followers");
},
},
{
icon: "ph-link ph-bold ph-lg",
text: i18n.ts._filters.fromDomain,
action: () => {
appendSearchFilter("instance:", false);
},
},
null,
{
type: "parent",
text: i18n.ts._filters.withFile,
icon: "ph-paperclip ph-bold ph-lg",
children: [
{
text: i18n.ts.image,
icon: "ph-image-square ph-bold ph-lg",
action: () => {
appendSearchFilter("has:image");
},
},
{
text: i18n.ts.video,
icon: "ph-video-camera ph-bold ph-lg",
action: () => {
appendSearchFilter("has:video");
},
},
{
text: i18n.ts.audio,
icon: "ph-music-note ph-bold ph-lg",
action: () => {
appendSearchFilter("has:audio");
},
},
{
text: i18n.ts.file,
icon: "ph-file ph-bold ph-lg",
action: () => {
appendSearchFilter("has:file");
},
},
],
},
null,
{
icon: "ph-calendar-blank ph-bold ph-lg",
text: i18n.ts._filters.notesBefore,
action: () => {
os.inputDate({
title: i18n.ts._filters.notesBefore,
}).then((res) => {
if (res.canceled) return;
appendSearchFilter("before:" + formatDateToYYYYMMDD(res.result));
});
},
},
{
icon: "ph-calendar-blank ph-bold ph-lg",
text: i18n.ts._filters.notesAfter,
action: () => {
os.inputDate({
title: i18n.ts._filters.notesAfter,
}).then((res) => {
if (res.canceled) return;
appendSearchFilter("after:" + formatDateToYYYYMMDD(res.result));
});
},
},
null,
{
icon: "ph-arrow-u-up-left ph-bold ph-lg",
text: i18n.ts._filters.excludeReplies,
action: () => {
appendSearchFilter("-filter:replies");
},
},
{
icon: "ph-repeat ph-bold ph-lg",
text: i18n.ts._filters.excludeRenotes,
action: () => {
appendSearchFilter("-filter:renotes");
},
},
null,
{
icon: "ph-text-aa ph-bold ph-lg",
text: i18n.ts._filters.caseSensitive,
action: () => {
appendSearchFilter("case:sensitive");
},
},
{
icon: "ph-brackets-angle ph-bold ph-lg",
text: i18n.ts._filters.matchWords,
action: () => {
appendSearchFilter("match:words");
},
},
null,
{
icon: "ph-question ph-bold ph-lg",
text: i18n.ts._filters._dialog.learnMore,
action: () => {
os.popup(XSearchFilterDialog, {}, {}, "closed");
},
},
],
ev.target,
{ noReturnFocus: true },
);
input.value!.focus();
input.value!.selectRange((query.value as string).length, (query.value as string).length); // cursor at end
}
function onInputKeydown(evt: KeyboardEvent) {
if (evt.key === "Enter") {
evt.preventDefault();
evt.stopPropagation();
emit('query', query.value);
}
}
</script>
<template>
<MkInput
class="search"
ref="input"
v-model="query"
style="flex: 1"
type="text"
autofocus
:placeholder="i18n.ts.searchPlaceholder"
:spellcheck="false"
@keydown="onInputKeydown"
>
<template #prefix>
<div>
<i class="ph-magnifying-glass ph-bold"></i>
</div>
</template>
<template #suffix>
<button
v-tooltip.noDelay="i18n.ts.filter"
class="_buttonIcon"
@click.stop="openSearchFilters"
>
<i class="ph-funnel ph-bold"></i>
</button>
</template>
</MkInput>
</template>
<style scoped lang="scss">
.search {
margin-bottom: 16px;
}
</style>

View file

@ -43,7 +43,6 @@ import { $i, refreshAccount, login, updateAccount, signout } from "@/account";
import { defaultStore, ColdDeviceStorage } from "@/store";
import { fetchInstance, instance } from "@/instance";
import { makeHotkey } from "@/scripts/hotkey";
import { search } from "@/scripts/search";
import { deviceKind } from "@/scripts/device-kind";
import { initializeSw } from "@/scripts/initialize-sw";
import { reloadChannel } from "@/scripts/unison-reload";
@ -428,7 +427,6 @@ function checkForSplash() {
d: (): void => {
defaultStore.set("darkMode", !defaultStore.state.darkMode);
},
s: search,
};
if ($i) {

View file

@ -1,6 +1,5 @@
import { computed, ref, reactive } from "vue";
import { $i } from "./account";
import { search } from "@/scripts/search";
import * as os from "@/os";
import { i18n } from "@/i18n";
import { ui } from "@/config";
@ -53,7 +52,7 @@ export const navbarItemDef = reactive({
search: {
title: "search",
icon: "ph-magnifying-glass ph-bold ph-lg",
action: () => search(),
to: "/search",
},
lists: {
title: "lists",

View file

@ -8,6 +8,7 @@
:display-back-button="true"
/></template>
<MkSpacer :content-max="800">
<MkSearch :query="query" @query="search"/>
<swiper
:round-lengths="true"
:touch-angle="25"
@ -26,7 +27,23 @@
>
<swiper-slide>
<template v-if="$i">
<XNotes ref="notes" :pagination="notesPagination" />
<template v-if="query == null || query.trim().length < 1">
<transition :name="$store.state.animation ? 'zoom' : ''" appear>
<div class="_fullinfo" ref="notes">
<img
:src="instance.images.info"
class="_ghost"
alt="Info"
/>
<div>
{{ i18n.ts.searchEmptyQuery }}
</div>
</div>
</transition>
</template>
<template v-else>
<XNotes ref="notes" :pagination="notesPagination" />
</template>
</template>
<template v-else>
<transition :name="$store.state.animation ? 'zoom' : ''" appear>
@ -45,11 +62,27 @@
</template>
</swiper-slide>
<swiper-slide>
<XUserList
ref="users"
class="_gap"
:pagination="usersPagination"
/>
<template v-if="query == null || query.trim().length < 1">
<transition :name="$store.state.animation ? 'zoom' : ''" appear>
<div class="_fullinfo" ref="notes">
<img
:src="instance.images.info"
class="_ghost"
alt="Info"
/>
<div>
{{ i18n.ts.searchEmptyQuery }}
</div>
</div>
</transition>
</template>
<template v-else>
<XUserList
ref="users"
class="_gap"
:pagination="usersPagination"
/>
</template>
</swiper-slide>
</swiper>
</MkSpacer>
@ -70,11 +103,19 @@ import { $i } from "@/account";
import "swiper/scss";
import "swiper/scss/virtual";
import {instance} from "@/instance";
import MkSearch from "@/components/MkSearch.vue";
import { mainRouter } from "@/router.js";
import * as os from "@/os.js";
const props = defineProps<{
query: string;
channel?: string;
}>();
const props = withDefaults(
defineProps<{
query: string;
channel?: string;
}>(),
{
query: ""
}
);
const notesPagination = {
endpoint: "notes/search" as const,
@ -134,8 +175,42 @@ onMounted(() => {
definePageMetadata(
computed(() => ({
title: i18n.t("searchWith", { q: props.query }),
title: i18n.ts.search,
icon: "ph-magnifying-glass ph-bold ph-lg",
})),
);
async function search(query: string) {
const q = query.trim();
if (q.startsWith("@") && !q.includes(" ")) {
mainRouter.push(`/${q}`);
return;
}
if (q.startsWith("#")) {
mainRouter.push(`/tags/${encodeURIComponent(q.slice(1))}`);
return;
}
if (q.startsWith("https://")) {
const promise = os.api("ap/show", {
uri: q,
});
os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
const res = await promise;
if (res.type === "User") {
mainRouter.push(`/@${res.object.username}@${res.object.host}`);
} else if (res.type === "Note") {
mainRouter.push(`/notes/${res.object.id}`);
}
return;
}
mainRouter.push(`/search?q=${encodeURIComponent(q)}`);
}
</script>

View file

@ -1,64 +0,0 @@
import * as os from "@/os";
import { i18n } from "@/i18n";
import { mainRouter } from "@/router";
export async function search() {
const { canceled, result: query } = await os.inputText({
type: "search",
title: i18n.ts.search,
placeholder: i18n.ts.searchPlaceholder,
});
if (canceled || query == null || query === "") return;
const q = query.trim();
if (q.startsWith("@") && !q.includes(" ")) {
mainRouter.push(`/${q}`);
return;
}
if (q.startsWith("#")) {
mainRouter.push(`/tags/${encodeURIComponent(q.slice(1))}`);
return;
}
// like 2018/03/12
if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(q.replace(/-/g, "/"))) {
const date = new Date(q.replace(/-/g, "/"));
// 日付しか指定されてない場合、例えば 2018/03/12 ならユーザーは
// 2018/03/12 のコンテンツを「含む」結果になることを期待するはずなので
// 23時間59分進める(そのままだと 2018/03/12 00:00:00 「まで」の
// 結果になってしまい、2018/03/12 のコンテンツは含まれない)
if (q.replace(/-/g, "/").match(/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$/)) {
date.setHours(23, 59, 59, 999);
}
// TODO
//v.$root.$emit('warp', date);
os.alert({
type: "waiting",
});
return;
}
if (q.startsWith("https://")) {
const promise = os.api("ap/show", {
uri: q,
});
os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
const res = await promise;
if (res.type === "User") {
mainRouter.push(`/@${res.object.username}@${res.object.host}`);
} else if (res.type === "Note") {
mainRouter.push(`/notes/${res.object.id}`);
}
return;
}
mainRouter.push(`/search?q=${encodeURIComponent(q)}`);
}

View file

@ -77,7 +77,6 @@
import { defineAsyncComponent, defineComponent } from "vue";
import XHeader from "./header.vue";
import { host, instanceName } from "@/config";
import { search } from "@/scripts/search";
import * as os from "@/os";
import MkPagination from "@/components/MkPagination.vue";
import MkButton from "@/components/MkButton.vue";
@ -118,7 +117,6 @@ export default defineComponent({
if (ColdDeviceStorage.get("syncDeviceDarkMode")) return;
this.$store.set("darkMode", !this.$store.state.darkMode);
},
s: search,
"h|/": this.help,
};
},
@ -168,7 +166,7 @@ export default defineComponent({
help() {
// TODO(thatonecalculator): popup with keybinds
// window.open('https://misskey-hub.net/docs/keyboard-shortcut.md', '_blank');
console.log("d = dark/light mode, s = search, p = post :3");
console.log("d = dark/light mode, p = post :3");
},
},
});

View file

@ -53,10 +53,10 @@
><i class="ph-image-square ph-bold ph-lg icon"></i
>{{ i18n.ts.gallery }}</MkA
>
<button class="_button link" active-class="active" @click="search()">
<MkA to="/search" class="link" active-class="active">
<i class="ph-magnifying-glass ph-bold ph-lg icon"></i
><span>{{ i18n.ts.search }}</span>
</button>
</MkA>
<div class="action">
<button class="_buttonPrimary" @click="signup()">
{{ i18n.ts.signup }}
@ -76,7 +76,6 @@ import { onMounted, provide } from "vue";
import XHeader from "./header.vue";
import XKanban from "./kanban.vue";
import { host, instanceName } from "@/config";
import { search } from "@/scripts/search";
import * as os from "@/os";
import { instance } from "@/instance";
import MkPagination from "@/components/MkPagination.vue";
@ -123,7 +122,6 @@ const keymap = $computed(() => {
if (ColdDeviceStorage.get("syncDeviceDarkMode")) return;
defaultStore.set("darkMode", !defaultStore.state.darkMode);
},
s: search,
};
});

View file

@ -26,10 +26,10 @@
><i class="ph-image-square ph-bold ph-lg icon"></i
>{{ i18n.ts.gallery }}</MkA
>
<button class="_button link" active-class="active" @click="search()">
<MkA to="/search" class="link" active-class="active">
<i class="ph-magnifying-glass ph-bold ph-lg icon"></i
><span>{{ i18n.ts.search }}</span>
</button>
</MkA>
<div v-if="info" class="page active link">
<div class="title">
<i v-if="info.icon" class="icon" :class="info.icon"></i>
@ -106,7 +106,6 @@ import XSigninDialog from "@/components/MkSigninDialog.vue";
import XSignupDialog from "@/components/MkSignupDialog.vue";
import * as os from "@/os";
import { instance } from "@/instance";
import { search } from "@/scripts/search";
import { i18n } from "@/i18n";
export default defineComponent({
@ -154,8 +153,6 @@ export default defineComponent({
"closed",
);
},
search,
},
});
</script>