feat: post search filters
This commit is contained in:
parent
1a0589c19c
commit
0d931225bb
16 changed files with 557 additions and 176 deletions
13
README.md
13
README.md
|
@ -12,10 +12,15 @@
|
|||
|
||||
- 非ログインユーザーにもローカルタイムラインとグローバルタイムラインを公開できるように変更
|
||||
- コントロールパネルから設定すると `https://server.example.com/timeline` で公開されます
|
||||
- 検索フィルターを強化中
|
||||
- `from:me` を検索クエリの末尾につけると自分の投稿のみを検索できるように変更
|
||||
- 検索クエリの例: `予定 from:me`
|
||||
- ホーム・フォロワー限定・ダイレクト・秘密の投稿を含む自分の全ての投稿から検索します
|
||||
- 検索フィルターを強化
|
||||
- 以下の機能があります
|
||||
- AND 検索
|
||||
- OR 検索
|
||||
- 自分の(未収載・フォロワー限定・ダイレクト・秘密を含む)全ての投稿からの検索
|
||||
- 特定のユーザーの投稿の検索
|
||||
- 特定のサーバーの投稿の検索
|
||||
- 特定の期間の投稿の検索
|
||||
- 添付ファイル付きの投稿の検索
|
||||
- 全文検索のエンジンを [PGroonga](https://pgroonga.github.io/) に変更
|
||||
- PGroonga のインストールが必要になります!詳しくは[この投稿](https://post.naskya.net/notes/9ldi29amfanomef5)をご覧ください
|
||||
- Meilisearch, Elasticsearch, Sonic は使えません
|
||||
|
|
|
@ -2205,3 +2205,10 @@ enablePullToRefresh: "Enable \"Pull down to refresh\""
|
|||
pullToRefreshThreshold: "Pull distance for reloading"
|
||||
publishTimelines: "Publish timelines for visitors"
|
||||
publishTimelinesDescription: "If enabled, the Local and Global timeline will be shown on {url} even when signed out."
|
||||
searchWords: "Words to search / ID or URL to lookup"
|
||||
searchWordsDescription: "To search for posts, enter the search term. Separate words with a space for an AND search, or 'OR' (without quotes) between words for an OR search.\nFor example, 'morning night' will find posts that contain both 'morning' and 'night', and 'morning OR night' will find posts that contain either 'morning' or 'night' (or both).\n\nIf you want to go to a specific user page or post page, enter the ID or URL in this field and click the 'Lookup' button. Clicking 'Search' will search for posts that literally contain the ID/URL."
|
||||
searchUsers: "Posted from (optional)"
|
||||
searchUsersDescription: "To search for posts by a specific user/server, enter the ID (@user@example.com, or @user for a local user) or domain name (example.com).\n\nIf you enter 'me' (without quotes), all of your posts (including unlisted, followers-only, direct, and secret posts) will be searched.\n\nIf you enter 'local' (without quotes), the results will be filtered to include only posts from this server."
|
||||
searchRange: "Posted within (optional)"
|
||||
searchRangeDescription: "If you want to filter the time period, enter it in this format: 20220615-20231031\n\nIf you leave out the year (like 0105-0106 or 20231105-0110), it's interpreted as the current year.\n\nYou can also omit either the start or end date. For example, -0102 will filter the search results to show only posts made before 2 January this year, and 20231026- will filter the results to show only posts made after 26 October 2023."
|
||||
searchPostsWithFiles: "Only posts with files"
|
||||
|
|
|
@ -2047,3 +2047,10 @@ enablePullToRefresh: "「下に引っ張って再読み込み」を有効にす
|
|||
pullToRefreshThreshold: "再読み込みするために引っ張る距離"
|
||||
publishTimelines: "非ログインユーザーにもタイムラインを公開する"
|
||||
publishTimelinesDescription: "有効にすると、{url} でローカルタイムラインとグローバルタイムラインが公開されます。"
|
||||
searchWords: "検索語句・照会するIDやURL"
|
||||
searchWordsDescription: "投稿を検索するには、ここに検索語句を入力してください。空白区切りでAND検索になり、ORを挟むとOR検索になります。\n例えば「朝 夜」と入力すると「朝」と「夜」が両方含まれた投稿を検索し、「朝 OR 夜」と入力すると「朝」または「夜」(または両方)が含まれた投稿を検索します。\n\n特定のユーザーや投稿のページに飛びたい場合には、この欄にID (@user@example.com) や投稿のURLを入力し「照会」を押してください。「検索」を押すとそのIDやURLが文字通り含まれる投稿を検索します。"
|
||||
searchUsers: "投稿元(オプション)"
|
||||
searchUsersDescription: "投稿検索で投稿者を絞りたい場合、@user@example.com(ローカルユーザーなら @user)の形式で投稿者のIDを入力してください。ユーザーIDではなくドメイン名 (example.com) を指定すると、そのサーバーの投稿を検索します。\n\nme とだけ入力すると、自分の投稿を検索します。この検索結果には未収載・フォロワー限定・ダイレクト・秘密を含む全ての投稿が含まれます。\n\nlocal とだけ入力すると、ローカルサーバーの投稿を検索します。"
|
||||
searchRange: "投稿期間(オプション)"
|
||||
searchRangeDescription: "投稿検索で投稿期間を絞りたい場合、20220615-20231031 のような形式で投稿期間を入力してください。今年の日付を指定する場合には年の指定を省略できます(0105-0106 や 20231105-0110 のように)。\n\n開始日と終了日のどちらか一方は省略可能です。例えば -0102 とすると今年1月2日までの投稿のみを、20231026- とすると2023年10月26日以降の投稿のみを検索します。"
|
||||
searchPostsWithFiles: "添付ファイルのある投稿のみ"
|
||||
|
|
|
@ -755,6 +755,9 @@ importers:
|
|||
mfm-js:
|
||||
specifier: 0.24.0
|
||||
version: 0.24.0
|
||||
moment:
|
||||
specifier: ^2.30.1
|
||||
version: 2.30.1
|
||||
photoswipe:
|
||||
specifier: 5.4.3
|
||||
version: 5.4.3
|
||||
|
@ -12595,9 +12598,8 @@ packages:
|
|||
ufo: 1.3.2
|
||||
dev: true
|
||||
|
||||
/moment@2.29.4:
|
||||
resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
|
||||
dev: false
|
||||
/moment@2.30.1:
|
||||
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
|
||||
|
||||
/ms@2.0.0:
|
||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||
|
@ -15802,7 +15804,7 @@ packages:
|
|||
resolution: {integrity: sha512-7SNMJKtQBJlwBUp1jxFT7bXya71cnINXPCYJ2AVhlQE4MKL7o2QiPdAXbMdWRiLeykQ2rx+7TNrnoGzvzhO+eA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
dependencies:
|
||||
moment: 2.29.4
|
||||
moment: 2.30.1
|
||||
dev: false
|
||||
|
||||
/systeminformation@5.21.22:
|
||||
|
|
|
@ -514,7 +514,7 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
globalTimeLine: !instance.disableGlobalTimeline,
|
||||
gusstTimeline: instance.enableGuestTimeline,
|
||||
emailRequiredForSignup: instance.emailRequiredForSignup,
|
||||
searchFilters: false, // TODO: implement search filters
|
||||
searchFilters: true,
|
||||
hcaptcha: instance.enableHcaptcha,
|
||||
recaptcha: instance.enableRecaptcha,
|
||||
objectStorage: instance.useObjectStorage,
|
||||
|
|
|
@ -40,6 +40,8 @@ export const paramDef = {
|
|||
query: { type: "string" },
|
||||
sinceId: { type: "string", format: "misskey:id" },
|
||||
untilId: { type: "string", format: "misskey:id" },
|
||||
sinceDate: { type: "number", nullable: true },
|
||||
untilDate: { type: "number", nullable: true },
|
||||
limit: { type: "integer", minimum: 1, maximum: 100, default: 10 },
|
||||
offset: { type: "integer", default: 0 },
|
||||
host: {
|
||||
|
@ -53,6 +55,7 @@ export const paramDef = {
|
|||
nullable: true,
|
||||
default: null,
|
||||
},
|
||||
withFiles: { type: "boolean", nullable: true },
|
||||
channelId: {
|
||||
type: "string",
|
||||
format: "misskey:id",
|
||||
|
@ -78,6 +81,8 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
Notes.createQueryBuilder("note"),
|
||||
ps.sinceId,
|
||||
ps.untilId,
|
||||
ps.sinceDate ?? undefined,
|
||||
ps.untilDate ?? undefined,
|
||||
);
|
||||
|
||||
if (ps.channelId != null) {
|
||||
|
@ -102,6 +107,17 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
query.andWhere("note.userId = :userId", { userId: ps.userId });
|
||||
}
|
||||
|
||||
if (ps.host === null) {
|
||||
query.andWhere("note.userHost IS NULL");
|
||||
}
|
||||
if (ps.host != null) {
|
||||
query.andWhere("note.userHost = :userHost", { userHost: ps.host });
|
||||
}
|
||||
|
||||
if (ps.withFiles === true) {
|
||||
query.andWhere("note.fileIds != '{}'");
|
||||
}
|
||||
|
||||
query
|
||||
.leftJoinAndSelect("user.avatar", "avatar")
|
||||
.leftJoinAndSelect("user.banner", "banner")
|
||||
|
|
|
@ -82,7 +82,7 @@ const nodeinfo2 = async () => {
|
|||
disableRecommendedTimeline: meta.disableRecommendedTimeline,
|
||||
disableGlobalTimeline: meta.disableGlobalTimeline,
|
||||
emailRequiredForSignup: meta.emailRequiredForSignup,
|
||||
searchFilters: false, // TODO: implement search filters
|
||||
searchFilters: true,
|
||||
postEditing: true,
|
||||
postImports: meta.experimentalFeatures?.postImports || false,
|
||||
enableHcaptcha: meta.enableHcaptcha,
|
||||
|
|
|
@ -64,6 +64,7 @@
|
|||
"libopenmpt-wasm": "github:TheEssem/libopenmpt-packaging#build",
|
||||
"matter-js": "0.19.0",
|
||||
"mfm-js": "0.24.0",
|
||||
"moment": "^2.30.1",
|
||||
"photoswipe": "5.4.3",
|
||||
"prettier": "3.1.1",
|
||||
"prismjs": "1.29.0",
|
||||
|
|
|
@ -103,7 +103,7 @@
|
|||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="input.type === 'search'" #suffix>
|
||||
<!-- <template v-if="input.type === 'search'" #suffix>
|
||||
<button
|
||||
v-tooltip.noDelay="i18n.ts.filter"
|
||||
class="_buttonIcon"
|
||||
|
@ -111,7 +111,7 @@
|
|||
>
|
||||
<i :class="iconClass('ph-funnel', false)"></i>
|
||||
</button>
|
||||
</template>
|
||||
</template> -->
|
||||
</MkInput>
|
||||
<MkTextarea
|
||||
v-if="input && input.type === 'paragraph'"
|
||||
|
@ -380,116 +380,116 @@ function appendFilter(value: string) {
|
|||
);
|
||||
}
|
||||
|
||||
async function openSearchFilters(ev) {
|
||||
await os.popupMenu(
|
||||
[
|
||||
{
|
||||
icon: `${iconClass("ph-user")}`,
|
||||
text: i18n.ts._filters.fromUser,
|
||||
action: () => {
|
||||
os.selectUser().then((user) => {
|
||||
inputValue.value = appendFilter(
|
||||
"from:@" + Acct.toString(user),
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "parent",
|
||||
text: i18n.ts._filters.withFile,
|
||||
icon: `${iconClass("ph-paperclip")}`,
|
||||
children: [
|
||||
{
|
||||
text: i18n.ts.image,
|
||||
icon: `${iconClass("ph-image-square")}`,
|
||||
action: () => {
|
||||
inputValue.value = appendFilter("has:image");
|
||||
},
|
||||
},
|
||||
{
|
||||
text: i18n.ts.video,
|
||||
icon: `${iconClass("ph-video-camera")}`,
|
||||
action: () => {
|
||||
inputValue.value = appendFilter("has:video");
|
||||
},
|
||||
},
|
||||
{
|
||||
text: i18n.ts.audio,
|
||||
icon: `${iconClass("ph-music-note")}`,
|
||||
action: () => {
|
||||
inputValue.value = appendFilter("has:audio");
|
||||
},
|
||||
},
|
||||
{
|
||||
text: i18n.ts.file,
|
||||
icon: `${iconClass("ph-file")}`,
|
||||
action: () => {
|
||||
inputValue.value = appendFilter("has:file");
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: `${iconClass("ph-link")}`,
|
||||
text: i18n.ts._filters.fromDomain,
|
||||
action: () => {
|
||||
inputValue.value = appendFilter("domain:");
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: `${iconClass("ph-calendar-blank")}`,
|
||||
text: i18n.ts._filters.notesBefore,
|
||||
action: () => {
|
||||
os.inputDate({
|
||||
title: i18n.ts._filters.notesBefore,
|
||||
}).then((res) => {
|
||||
if (res.canceled) return;
|
||||
inputValue.value = appendFilter(
|
||||
"before:" + formatDateToYYYYMMDD(res.result),
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: `${iconClass("ph-calendar-blank")}`,
|
||||
text: i18n.ts._filters.notesAfter,
|
||||
action: () => {
|
||||
os.inputDate({
|
||||
title: i18n.ts._filters.notesAfter,
|
||||
}).then((res) => {
|
||||
if (res.canceled) return;
|
||||
inputValue.value = appendFilter(
|
||||
"after:" + formatDateToYYYYMMDD(res.result),
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: `${iconClass("ph-eye")}`,
|
||||
text: i18n.ts._filters.followingOnly,
|
||||
action: () => {
|
||||
inputValue.value = appendFilter("filter:following");
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: `${iconClass("ph-users-three")}`,
|
||||
text: i18n.ts._filters.followersOnly,
|
||||
action: () => {
|
||||
inputValue.value = appendFilter("filter:followers");
|
||||
},
|
||||
},
|
||||
],
|
||||
ev.target,
|
||||
{ noReturnFocus: true },
|
||||
);
|
||||
inputEl.value?.focus();
|
||||
if (typeof inputValue.value === "string") {
|
||||
inputEl.value?.selectRange(
|
||||
inputValue.value.length,
|
||||
inputValue.value.length,
|
||||
); // cursor at end
|
||||
}
|
||||
}
|
||||
// async function openSearchFilters(ev) {
|
||||
// await os.popupMenu(
|
||||
// [
|
||||
// {
|
||||
// icon: `${iconClass("ph-user")}`,
|
||||
// text: i18n.ts._filters.fromUser,
|
||||
// action: () => {
|
||||
// os.selectUser().then((user) => {
|
||||
// inputValue.value = appendFilter(
|
||||
// "from:@" + Acct.toString(user),
|
||||
// );
|
||||
// });
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// type: "parent",
|
||||
// text: i18n.ts._filters.withFile,
|
||||
// icon: `${iconClass("ph-paperclip")}`,
|
||||
// children: [
|
||||
// {
|
||||
// text: i18n.ts.image,
|
||||
// icon: `${iconClass("ph-image-square")}`,
|
||||
// action: () => {
|
||||
// inputValue.value = appendFilter("has:image");
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// text: i18n.ts.video,
|
||||
// icon: `${iconClass("ph-video-camera")}`,
|
||||
// action: () => {
|
||||
// inputValue.value = appendFilter("has:video");
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// text: i18n.ts.audio,
|
||||
// icon: `${iconClass("ph-music-note")}`,
|
||||
// action: () => {
|
||||
// inputValue.value = appendFilter("has:audio");
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// text: i18n.ts.file,
|
||||
// icon: `${iconClass("ph-file")}`,
|
||||
// action: () => {
|
||||
// inputValue.value = appendFilter("has:file");
|
||||
// },
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// icon: `${iconClass("ph-link")}`,
|
||||
// text: i18n.ts._filters.fromDomain,
|
||||
// action: () => {
|
||||
// inputValue.value = appendFilter("domain:");
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// icon: `${iconClass("ph-calendar-blank")}`,
|
||||
// text: i18n.ts._filters.notesBefore,
|
||||
// action: () => {
|
||||
// os.inputDate({
|
||||
// title: i18n.ts._filters.notesBefore,
|
||||
// }).then((res) => {
|
||||
// if (res.canceled) return;
|
||||
// inputValue.value = appendFilter(
|
||||
// "before:" + formatDateToYYYYMMDD(res.result),
|
||||
// );
|
||||
// });
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// icon: `${iconClass("ph-calendar-blank")}`,
|
||||
// text: i18n.ts._filters.notesAfter,
|
||||
// action: () => {
|
||||
// os.inputDate({
|
||||
// title: i18n.ts._filters.notesAfter,
|
||||
// }).then((res) => {
|
||||
// if (res.canceled) return;
|
||||
// inputValue.value = appendFilter(
|
||||
// "after:" + formatDateToYYYYMMDD(res.result),
|
||||
// );
|
||||
// });
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// icon: `${iconClass("ph-eye")}`,
|
||||
// text: i18n.ts._filters.followingOnly,
|
||||
// action: () => {
|
||||
// inputValue.value = appendFilter("filter:following");
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// icon: `${iconClass("ph-users-three")}`,
|
||||
// text: i18n.ts._filters.followersOnly,
|
||||
// action: () => {
|
||||
// inputValue.value = appendFilter("filter:followers");
|
||||
// },
|
||||
// },
|
||||
// ],
|
||||
// ev.target,
|
||||
// { noReturnFocus: true },
|
||||
// );
|
||||
// inputEl.value?.focus();
|
||||
// if (typeof inputValue.value === "string") {
|
||||
// inputEl.value?.selectRange(
|
||||
// inputValue.value.length,
|
||||
// inputValue.value.length,
|
||||
// ); // cursor at end
|
||||
// }
|
||||
// }
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("keydown", onKeydown);
|
||||
|
|
236
packages/client/src/components/MkSearchBox.vue
Normal file
236
packages/client/src/components/MkSearchBox.vue
Normal file
|
@ -0,0 +1,236 @@
|
|||
<template>
|
||||
<MkModal
|
||||
ref="modal"
|
||||
:prefer-type="'dialog'"
|
||||
@click="done(true)"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<div :class="$style.root">
|
||||
<header :class="$style.title">
|
||||
<i :class="icon('ph-magnifying-glass', false)"></i>
|
||||
{{ i18n.ts.search }}
|
||||
</header>
|
||||
<MkInput
|
||||
v-model="searchWords"
|
||||
autofocus
|
||||
type="search"
|
||||
:placeholder="i18n.ts.searchWords"
|
||||
:class="$style.input"
|
||||
@keydown="onInputKeydown"
|
||||
>
|
||||
<template #suffix>
|
||||
<button
|
||||
v-tooltip.noDelay="i18n.ts.help"
|
||||
class="_buttonIcon"
|
||||
@click.stop="openDescription('words')"
|
||||
>
|
||||
<i :class="icon('ph-question', false)"></i>
|
||||
</button>
|
||||
</template>
|
||||
</MkInput>
|
||||
<MkInput
|
||||
v-model="searchUsers"
|
||||
type="search"
|
||||
:placeholder="i18n.ts.searchUsers"
|
||||
:class="$style.input"
|
||||
@keydown="onInputKeydown"
|
||||
>
|
||||
<template #suffix>
|
||||
<button
|
||||
v-tooltip.noDelay="i18n.ts.help"
|
||||
class="_buttonIcon"
|
||||
@click.stop="openDescription('users')"
|
||||
>
|
||||
<i :class="icon('ph-question', false)"></i>
|
||||
</button>
|
||||
</template>
|
||||
</MkInput>
|
||||
<MkInput
|
||||
v-model="searchRange"
|
||||
type="search"
|
||||
:placeholder="i18n.ts.searchRange"
|
||||
:class="$style.input"
|
||||
@keydown="onInputKeydown"
|
||||
>
|
||||
<template #suffix>
|
||||
<button
|
||||
v-tooltip.noDelay="i18n.ts.help"
|
||||
class="_buttonIcon"
|
||||
@click.stop="openDescription('range')"
|
||||
>
|
||||
<i :class="icon('ph-question', false)"></i>
|
||||
</button>
|
||||
</template>
|
||||
</MkInput>
|
||||
<FormSwitch
|
||||
v-model="searchPostsWithFiles"
|
||||
class="form-switch"
|
||||
:class="$style.input"
|
||||
>{{ i18n.ts.searchPostsWithFiles }}</FormSwitch
|
||||
>
|
||||
<div :class="$style.buttons">
|
||||
<MkButton inline primary @click="search"
|
||||
>{{ i18n.ts.search }}
|
||||
</MkButton>
|
||||
<MkButton inline @click="lookup">{{ i18n.ts.lookup }}</MkButton>
|
||||
<MkButton inline @click="cancel">{{ i18n.ts.cancel }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
defineAsyncComponent,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
} from "vue";
|
||||
import MkModal from "@/components/MkModal.vue";
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
import MkInput from "@/components/form/input.vue";
|
||||
import FormSwitch from "@/components/form/switch.vue";
|
||||
import { i18n } from "@/i18n";
|
||||
import icon from "@/scripts/icon";
|
||||
import { popup } from "@/os";
|
||||
|
||||
type searchQuery =
|
||||
| {
|
||||
action: "lookup";
|
||||
query: string;
|
||||
}
|
||||
| {
|
||||
action: "search";
|
||||
query: string;
|
||||
from?: string;
|
||||
range?: string;
|
||||
withFiles: boolean;
|
||||
};
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "done", v: { canceled: boolean; result?: searchQuery }): void;
|
||||
(ev: "closed"): void;
|
||||
}>();
|
||||
|
||||
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||
|
||||
const searchWords = ref("");
|
||||
const searchUsers = ref("");
|
||||
const searchRange = ref("");
|
||||
const searchPostsWithFiles = ref(false);
|
||||
|
||||
function done(canceled: boolean, result?: searchQuery) {
|
||||
emit("done", { canceled, result });
|
||||
modal.value?.close(null);
|
||||
}
|
||||
|
||||
function search() {
|
||||
done(false, {
|
||||
action: "search",
|
||||
query: searchWords.value,
|
||||
from: searchUsers.value === "" ? undefined : searchUsers.value,
|
||||
range: searchRange.value === "" ? undefined : searchRange.value,
|
||||
withFiles: searchPostsWithFiles.value,
|
||||
});
|
||||
}
|
||||
|
||||
function lookup() {
|
||||
done(false, {
|
||||
action: "lookup",
|
||||
query: searchWords.value,
|
||||
});
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
done(true);
|
||||
}
|
||||
/*
|
||||
function onBgClick() {
|
||||
if (props.cancelableByBgClick) cancel();
|
||||
}
|
||||
*/
|
||||
function onKeydown(evt: KeyboardEvent) {
|
||||
if (evt.key === "Escape") cancel();
|
||||
}
|
||||
|
||||
function onInputKeydown(evt: KeyboardEvent) {
|
||||
if (evt.key === "Enter") {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
search();
|
||||
}
|
||||
}
|
||||
|
||||
function openDescription(kind: "words" | "users" | "range"): void {
|
||||
const descriptions = {
|
||||
words: i18n.ts.searchWordsDescription,
|
||||
users: i18n.ts.searchUsersDescription,
|
||||
range: i18n.ts.searchRangeDescription,
|
||||
};
|
||||
|
||||
popup(
|
||||
defineAsyncComponent(
|
||||
() => import("@/components/MkSimpleTextWindow.vue"),
|
||||
),
|
||||
{
|
||||
title: i18n.ts.help,
|
||||
description: descriptions[kind],
|
||||
},
|
||||
{},
|
||||
"closed",
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("keydown", onKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("keydown", onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
margin: auto;
|
||||
padding: 32px;
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.input {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.icon {
|
||||
font-size: 24px;
|
||||
|
||||
& + .title {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.iconInner {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 25px 0;
|
||||
font-weight: bold;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
43
packages/client/src/components/MkSimpleTextWindow.vue
Normal file
43
packages/client/src/components/MkSimpleTextWindow.vue
Normal file
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<XWindow
|
||||
:initial-width="800"
|
||||
:can-resize="true"
|
||||
:front="true"
|
||||
@closed="emit('closed')"
|
||||
class="thppypvi"
|
||||
>
|
||||
<template #header>
|
||||
{{ title }}
|
||||
</template>
|
||||
<div class="zrgnubda">
|
||||
{{ description }}
|
||||
</div>
|
||||
</XWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import XWindow from "@/components/MkWindow.vue";
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "closed"): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.thppypvi {
|
||||
max-height: 70%;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.zrgnubda {
|
||||
white-space: pre-wrap;
|
||||
font-size: 1.2em;
|
||||
padding: 5px 20px 10px;
|
||||
margin: 5px;
|
||||
}
|
||||
</style>
|
|
@ -53,20 +53,33 @@ import icon from "@/scripts/icon";
|
|||
import { $i } from "@/reactiveAccount";
|
||||
import "swiper/scss";
|
||||
import "swiper/scss/virtual";
|
||||
import moment from "moment";
|
||||
import { api } from "@/os";
|
||||
|
||||
const props = defineProps<{
|
||||
query: string;
|
||||
user?: string;
|
||||
host?: string;
|
||||
since?: string;
|
||||
until?: string;
|
||||
channel?: string;
|
||||
withFiles: "false" | "true";
|
||||
}>();
|
||||
|
||||
const userId = props.user == null ? undefined : await getUserId(props.user);
|
||||
|
||||
const notesPagination = {
|
||||
endpoint: "notes/search" as const,
|
||||
limit: 10,
|
||||
params: computed(() => ({
|
||||
query: props.query.endsWith("from:me")
|
||||
? props.query.slice(0, -7).trim()
|
||||
: props.query,
|
||||
userId: props.query.endsWith("from:me") ? $i.id : null,
|
||||
query: props.query,
|
||||
userId,
|
||||
host: props.host == null ? undefined : getHost(props.host),
|
||||
sinceDate:
|
||||
props.since == null ? undefined : getUnixTime(props.since, false),
|
||||
untilDate:
|
||||
props.until == null ? undefined : getUnixTime(props.until, true),
|
||||
withFiles: props.withFiles === "true",
|
||||
channelId: props.channel,
|
||||
})),
|
||||
};
|
||||
|
@ -80,6 +93,27 @@ const usersPagination = {
|
|||
})),
|
||||
};
|
||||
|
||||
async function getUserId(user: string): Promise<string> {
|
||||
if (user === "me") return $i!.id;
|
||||
|
||||
const split = (user.startsWith("@") ? user.slice(1) : user).split("@");
|
||||
const username = split[0];
|
||||
const host = split.length === 1 ? undefined : split[1];
|
||||
|
||||
return (await api("users/show", { username, host })).id;
|
||||
}
|
||||
|
||||
function getHost(host: string): string | null {
|
||||
if (host === "local") return null;
|
||||
return host;
|
||||
}
|
||||
|
||||
function getUnixTime(date: string, nextDay: boolean): number {
|
||||
return moment(date, date.length === 4 ? "MMDD" : "YYYYMMDD")
|
||||
.add(nextDay ? 1 : 0, "days")
|
||||
.valueOf();
|
||||
}
|
||||
|
||||
const tabs = ["notes", "users"];
|
||||
const tab = ref(tabs[0]);
|
||||
watch(tab, () => syncSlide(tabs.indexOf(tab.value)));
|
||||
|
|
|
@ -305,6 +305,11 @@ export const routes = [
|
|||
: page(() => import("./pages/not-found.vue")),
|
||||
query: {
|
||||
q: "query",
|
||||
user: "user",
|
||||
host: "host",
|
||||
since: "since",
|
||||
until: "until",
|
||||
withFiles: "withFiles",
|
||||
channel: "channel",
|
||||
},
|
||||
},
|
||||
|
|
|
@ -1,65 +1,90 @@
|
|||
import { i18n } from "@/i18n";
|
||||
import { instance } from "@/instance";
|
||||
import * as os from "@/os";
|
||||
import { api, popup, promiseDialog } from "@/os";
|
||||
import { mainRouter } from "@/router";
|
||||
import MkSearchBox from "@/components/MkSearchBox.vue";
|
||||
|
||||
export async function search() {
|
||||
const { canceled, result: query } = await os.inputText({
|
||||
type: instance.features.searchFilters ? "search" : "text",
|
||||
title: i18n.ts.search,
|
||||
placeholder: i18n.ts.searchPlaceholder,
|
||||
const { canceled, result } = await new Promise<
|
||||
| { canceled: true; result: undefined }
|
||||
| {
|
||||
canceled: false;
|
||||
result: {
|
||||
action: "lookup";
|
||||
query: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
canceled: false;
|
||||
result: {
|
||||
action: "search";
|
||||
query: string;
|
||||
from?: string;
|
||||
range?: string;
|
||||
withFiles: boolean;
|
||||
};
|
||||
}
|
||||
>((resolve, _) => {
|
||||
popup(
|
||||
MkSearchBox,
|
||||
{},
|
||||
{
|
||||
done: (result) => {
|
||||
resolve(result ?? { canceled: true });
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
);
|
||||
});
|
||||
if (canceled || query == null || query === "") return;
|
||||
|
||||
const q = query.trim();
|
||||
if (canceled || result == null || result.query === "") return;
|
||||
|
||||
if (q.startsWith("@") && !q.includes(" ")) {
|
||||
mainRouter.push(`/${q}`);
|
||||
return;
|
||||
}
|
||||
if (result.action === "lookup") {
|
||||
if (result.query.startsWith("#")) {
|
||||
mainRouter.push(`/tags/${encodeURIComponent(result.query.slice(1))}`);
|
||||
return;
|
||||
}
|
||||
if (result.query.startsWith("@")) {
|
||||
mainRouter.push(`/${result.query}`);
|
||||
return;
|
||||
}
|
||||
if (result.query.startsWith("https://")) {
|
||||
const promise = api("ap/show", {
|
||||
uri: result.query,
|
||||
});
|
||||
|
||||
if (q.startsWith("#")) {
|
||||
mainRouter.push(`/tags/${encodeURIComponent(q.slice(1))}`);
|
||||
return;
|
||||
}
|
||||
promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
|
||||
const res = await promise;
|
||||
|
||||
// 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, "/"));
|
||||
if (res.type === "User") {
|
||||
mainRouter.push(`/@${res.object.username}@${res.object.host}`);
|
||||
} else if (res.type === "Note") {
|
||||
mainRouter.push(`/notes/${res.object.id}`);
|
||||
}
|
||||
|
||||
// 日付しか指定されてない場合、例えば 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);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO
|
||||
// v.$root.$emit('warp', date);
|
||||
os.alert({
|
||||
type: "waiting",
|
||||
});
|
||||
return;
|
||||
// fallback
|
||||
mainRouter.push(`/search?q=${encodeURIComponent(result.query)}`);
|
||||
}
|
||||
|
||||
if (q.startsWith("https://")) {
|
||||
const promise = os.api("ap/show", {
|
||||
uri: q,
|
||||
});
|
||||
if (result.action === "search") {
|
||||
let paramString = `q=${encodeURIComponent(result.query)}`;
|
||||
|
||||
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}`);
|
||||
if (result.from != null) {
|
||||
if (result.from === "me" || result.from.includes("@"))
|
||||
paramString += `&user=${encodeURIComponent(result.from)}`;
|
||||
else paramString += `&host=${encodeURIComponent(result.from)}`;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
if (result.range != null) {
|
||||
const split = result.range.split("-");
|
||||
if (split[0] !== "") paramString += `&since=${split[0]}`;
|
||||
if (split[1] !== "") paramString += `&until=${split[1]}`;
|
||||
}
|
||||
|
||||
mainRouter.push(`/search?q=${encodeURIComponent(q)}`);
|
||||
paramString += `&withFiles=${result.withFiles ? "true" : "false"}`;
|
||||
|
||||
mainRouter.push(`/search?${paramString}`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -77,7 +77,7 @@
|
|||
import { defineComponent } from "vue";
|
||||
import XHeader from "./header.vue";
|
||||
import { host, instanceName } from "@/config";
|
||||
import { search } from "@/scripts/search";
|
||||
// import { search } from "@/scripts/search";
|
||||
import * as os from "@/os";
|
||||
import MkPagination from "@/components/MkPagination.vue";
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
|
|
|
@ -57,14 +57,14 @@
|
|||
><i :class="icon('ph-image-square icon')"></i
|
||||
>{{ i18n.ts.gallery }}</MkA
|
||||
>
|
||||
<button
|
||||
<!-- <button
|
||||
class="_button link"
|
||||
active-class="active"
|
||||
@click="search()"
|
||||
>
|
||||
<i :class="icon('ph-magnifying-glass icon')"></i
|
||||
><span>{{ i18n.ts.search }}</span>
|
||||
</button>
|
||||
</button> -->
|
||||
<div class="action">
|
||||
<button class="_buttonPrimary" @click="signup()">
|
||||
{{ i18n.ts.signup }}
|
||||
|
@ -84,7 +84,7 @@ import { computed, onMounted, provide, ref } from "vue";
|
|||
import XHeader from "./header.vue";
|
||||
import XKanban from "./kanban.vue";
|
||||
import { host, instanceName } from "@/config";
|
||||
import { search } from "@/scripts/search";
|
||||
// import { search } from "@/scripts/search";
|
||||
import * as os from "@/os";
|
||||
import XSigninDialog from "@/components/MkSigninDialog.vue";
|
||||
import XSignupDialog from "@/components/MkSignupDialog.vue";
|
||||
|
|
Loading…
Reference in a new issue