mirror of
https://iceshrimp.dev/limepotato/jormungandr-bite.git
synced 2024-11-21 17:37:29 -07:00
[client] Improve search filter menu
This commit is contained in:
parent
b4616d3f36
commit
1b4fedc59f
9 changed files with 246 additions and 38 deletions
|
@ -5,7 +5,7 @@ introIceshrimp: "Welcome! Iceshrimp is an open source, decentralized social medi
|
|||
platform that's free forever! 🚀"
|
||||
monthAndDay: "{month}/{day}"
|
||||
search: "Search"
|
||||
searchPlaceholder: "Search Iceshrimp"
|
||||
searchPlaceholder: "Search the Fediverse"
|
||||
notifications: "Notifications"
|
||||
username: "Username"
|
||||
password: "Password"
|
||||
|
@ -1488,13 +1488,32 @@ _time:
|
|||
hour: "Hour(s)"
|
||||
day: "Day(s)"
|
||||
_filters:
|
||||
_dialog:
|
||||
title: "Advanced search filters"
|
||||
learnMore: "View advanced filters"
|
||||
wordFilters: "Filter by post text"
|
||||
miscFilters: "Filter by following relationship and/or note type"
|
||||
userDomain: "Filter by author, mentioned users, reply user or instance domain"
|
||||
postDate: "Filter by post date"
|
||||
exclusivity: "Note that the before: filter is exclusive, while the after: filter is inclusive."
|
||||
word: "word"
|
||||
phrase: "literal phrase that contains (arbitrary) characters"
|
||||
attachmentType: "Filter by attachment type(s)"
|
||||
info: "Nomenclature"
|
||||
info1: "Text in brackets signifies available optional filter parameters. Filter aliases or parameter options are signified by a pipe character."
|
||||
info2: "A dash enclosed in brackets denotes the ability to invert/negate a filter with the dash character."
|
||||
fromUser: "From user"
|
||||
replyTo: "Replying to"
|
||||
mentioning: "Mentioning"
|
||||
withFile: "With file"
|
||||
fromDomain: "From domain"
|
||||
notesBefore: "Posts before"
|
||||
notesAfter: "Posts after"
|
||||
followingOnly: "Following only"
|
||||
followersOnly: "Followers only"
|
||||
repliesOnly: "Replies only"
|
||||
excludeReplies: "Exclude replies"
|
||||
excludeRenotes: "Exclude boosts"
|
||||
_tutorial:
|
||||
title: "How to use Iceshrimp"
|
||||
step1_1: "Welcome!"
|
||||
|
|
|
@ -10,8 +10,6 @@ const filters = {
|
|||
"-mention": mentionFilterInverse,
|
||||
"reply": replyFilter,
|
||||
"-reply": replyFilterInverse,
|
||||
"replyto": replyFilter,
|
||||
"-replyto": replyFilterInverse,
|
||||
"to": replyFilter,
|
||||
"-to": replyFilterInverse,
|
||||
"before": beforeFilter,
|
||||
|
@ -19,14 +17,15 @@ const filters = {
|
|||
"after": afterFilter,
|
||||
"since": afterFilter,
|
||||
"domain": domainFilter,
|
||||
"-domain": domainFilterInverse,
|
||||
"host": domainFilter,
|
||||
"-host": domainFilterInverse,
|
||||
"filter": miscFilter,
|
||||
"-filter": miscFilterInverse,
|
||||
"has": attachmentFilter,
|
||||
} as Record<string, (query: SelectQueryBuilder<any>, search: string, id: number) => any>
|
||||
|
||||
//TODO: editing the query should be possible, clicking search again resets it (it should be a twitter-like top of the page kind of deal)
|
||||
//TODO: new filters are missing from the filter dropdown, and said dropdown should always show (remove the searchFilters meta prop), also we should fix the null bug
|
||||
|
||||
export function generateFtsQuery(query: SelectQueryBuilder<any>, q: string): void {
|
||||
const components = q.trim().split(" ");
|
||||
|
@ -132,6 +131,10 @@ function domainFilter(query: SelectQueryBuilder<any>, filter: string) {
|
|||
query.andWhere('note.userHost = :domain', { domain: filter });
|
||||
}
|
||||
|
||||
function domainFilterInverse(query: SelectQueryBuilder<any>, filter: string) {
|
||||
query.andWhere('note.userHost <> :domain', { domain: filter });
|
||||
}
|
||||
|
||||
function miscFilter(query: SelectQueryBuilder<any>, filter: string) {
|
||||
let subQuery: SelectQueryBuilder<any> | null = null;
|
||||
if (filter === 'followers') {
|
||||
|
@ -144,7 +147,7 @@ function miscFilter(query: SelectQueryBuilder<any>, filter: string) {
|
|||
.where('following.followerId = :meId')
|
||||
} else if (filter === 'replies') {
|
||||
query.andWhere('note.replyId IS NOT NULL');
|
||||
} else if (filter === 'boosts') {
|
||||
} else if (filter === 'boosts' || filter === 'renotes') {
|
||||
query.andWhere('note.renoteId IS NOT NULL');
|
||||
}
|
||||
|
||||
|
|
|
@ -305,11 +305,6 @@ export const meta = {
|
|||
optional: false,
|
||||
nullable: false,
|
||||
},
|
||||
searchFilters: {
|
||||
type: "boolean",
|
||||
optional: false,
|
||||
nullable: false,
|
||||
},
|
||||
hcaptcha: {
|
||||
type: "boolean",
|
||||
optional: false,
|
||||
|
@ -489,7 +484,6 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
recommendedTimeline: !instance.disableRecommendedTimeline,
|
||||
globalTimeLine: !instance.disableGlobalTimeline,
|
||||
emailRequiredForSignup: instance.emailRequiredForSignup,
|
||||
searchFilters: true,
|
||||
hcaptcha: instance.enableHcaptcha,
|
||||
recaptcha: instance.enableRecaptcha,
|
||||
objectStorage: instance.useObjectStorage,
|
||||
|
|
|
@ -78,7 +78,6 @@ const nodeinfo2 = async () => {
|
|||
disableRecommendedTimeline: meta.disableRecommendedTimeline,
|
||||
disableGlobalTimeline: meta.disableGlobalTimeline,
|
||||
emailRequiredForSignup: meta.emailRequiredForSignup,
|
||||
searchFilters: true,
|
||||
postEditing: true,
|
||||
postImports: meta.experimentalFeatures?.postImports || false,
|
||||
enableHcaptcha: meta.enableHcaptcha,
|
||||
|
|
|
@ -62,7 +62,7 @@
|
|||
v-model="inputValue"
|
||||
autofocus
|
||||
:autocomplete="input.autocomplete"
|
||||
:type="input.type == 'search' ? 'search' : input.type || 'text'"
|
||||
:type="(input.type || 'text') as 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | undefined"
|
||||
:placeholder="input.placeholder || undefined"
|
||||
:style="{
|
||||
width: input.type === 'search' ? '300px' : null,
|
||||
|
@ -208,6 +208,7 @@ 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"];
|
||||
|
@ -278,7 +279,7 @@ const emit = defineEmits<{
|
|||
|
||||
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||
|
||||
const inputValue = ref<string | number | null>(props.input?.default ?? null);
|
||||
const inputValue = ref<string | number | null>(props.input?.default ?? "");
|
||||
const selectedValue = ref(props.select?.default ?? null);
|
||||
|
||||
let disabledReason = $ref<null | "charactersExceeded" | "charactersBelow">(
|
||||
|
@ -353,6 +354,13 @@ function formatDateToYYYYMMDD(date) {
|
|||
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(
|
||||
[
|
||||
|
@ -361,10 +369,35 @@ async function openSearchFilters(ev) {
|
|||
text: i18n.ts._filters.fromUser,
|
||||
action: () => {
|
||||
os.selectUser().then((user) => {
|
||||
inputValue.value += " from:@" + Acct.toString(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)}`);
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "ph-link ph-bold ph-lg",
|
||||
text: i18n.ts._filters.fromDomain,
|
||||
action: () => {
|
||||
appendSearchFilter("domain:", false);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "parent",
|
||||
text: i18n.ts._filters.withFile,
|
||||
|
@ -374,39 +407,33 @@ async function openSearchFilters(ev) {
|
|||
text: i18n.ts.image,
|
||||
icon: "ph-image-square ph-bold ph-lg",
|
||||
action: () => {
|
||||
inputValue.value += " has:image";
|
||||
appendSearchFilter("has:image");
|
||||
},
|
||||
},
|
||||
{
|
||||
text: i18n.ts.video,
|
||||
icon: "ph-video-camera ph-bold ph-lg",
|
||||
action: () => {
|
||||
inputValue.value += " has:video";
|
||||
appendSearchFilter("has:video");
|
||||
},
|
||||
},
|
||||
{
|
||||
text: i18n.ts.audio,
|
||||
icon: "ph-music-note ph-bold ph-lg",
|
||||
action: () => {
|
||||
inputValue.value += " has:audio";
|
||||
appendSearchFilter("has:audio");
|
||||
},
|
||||
},
|
||||
{
|
||||
text: i18n.ts.file,
|
||||
icon: "ph-file ph-bold ph-lg",
|
||||
action: () => {
|
||||
inputValue.value += " has:file";
|
||||
appendSearchFilter("has:file");
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "ph-link ph-bold ph-lg",
|
||||
text: i18n.ts._filters.fromDomain,
|
||||
action: () => {
|
||||
inputValue.value += " domain:";
|
||||
},
|
||||
},
|
||||
null,
|
||||
{
|
||||
icon: "ph-calendar-blank ph-bold ph-lg",
|
||||
text: i18n.ts._filters.notesBefore,
|
||||
|
@ -415,8 +442,7 @@ async function openSearchFilters(ev) {
|
|||
title: i18n.ts._filters.notesBefore,
|
||||
}).then((res) => {
|
||||
if (res.canceled) return;
|
||||
inputValue.value +=
|
||||
" before:" + formatDateToYYYYMMDD(res.result);
|
||||
appendSearchFilter("before:" + formatDateToYYYYMMDD(res.result));
|
||||
});
|
||||
},
|
||||
},
|
||||
|
@ -428,31 +454,61 @@ async function openSearchFilters(ev) {
|
|||
title: i18n.ts._filters.notesAfter,
|
||||
}).then((res) => {
|
||||
if (res.canceled) return;
|
||||
inputValue.value +=
|
||||
" after:" + formatDateToYYYYMMDD(res.result);
|
||||
appendSearchFilter("after:" + formatDateToYYYYMMDD(res.result));
|
||||
});
|
||||
},
|
||||
},
|
||||
null,
|
||||
{
|
||||
icon: "ph-eye ph-bold ph-lg",
|
||||
text: i18n.ts._filters.followingOnly,
|
||||
action: () => {
|
||||
inputValue.value += " filter:following ";
|
||||
appendSearchFilter("filter:following");
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "ph-users-three ph-bold ph-lg",
|
||||
text: i18n.ts._filters.followersOnly,
|
||||
action: () => {
|
||||
inputValue.value += " filter:followers ";
|
||||
appendSearchFilter("filter:followers");
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "ph-arrow-u-up-left ph-bold ph-lg",
|
||||
text: i18n.ts._filters.repliesOnly,
|
||||
action: () => {
|
||||
appendSearchFilter("filter:replies");
|
||||
},
|
||||
},
|
||||
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-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.length, inputValue.value.length); // cursor at end
|
||||
inputEl.value!.focus();
|
||||
inputEl.value!.selectRange((inputValue.value as string).length, (inputValue.value as string).length); // cursor at end
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
|
39
packages/client/src/components/MkSearchFilterDialog.vue
Normal file
39
packages/client/src/components/MkSearchFilterDialog.vue
Normal file
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<XModalWindow
|
||||
ref="dialog"
|
||||
:width="600"
|
||||
@close="dialog?.close()"
|
||||
@closed="$emit('closed')"
|
||||
>
|
||||
<template #header>{{ i18n.ts._filters._dialog.title }}</template>
|
||||
<XSearchFilters :popup="true" style="background: var(--bg)" />
|
||||
</XModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import XModalWindow from "@/components/MkModalWindow.vue";
|
||||
import XSearchFilters from "@/pages/search-filters.vue";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "done"): void;
|
||||
(ev: "closed"): void;
|
||||
}>();
|
||||
|
||||
const dialog = $ref<InstanceType<typeof XModalWindow>>();
|
||||
|
||||
function close(res) {
|
||||
dialog.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
100
packages/client/src/pages/search-filters.vue
Normal file
100
packages/client/src/pages/search-filters.vue
Normal file
|
@ -0,0 +1,100 @@
|
|||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader v-if="!popup" /></template>
|
||||
<MkSpacer :content-max="800">
|
||||
<div class="search-filters">
|
||||
<div class="section _block">
|
||||
<div class="title">{{ i18n.ts._filters._dialog.info }}</div>
|
||||
<div class="content">
|
||||
<p>{{ i18n.ts._filters._dialog.info1 }}</p>
|
||||
<p>{{ i18n.ts._filters._dialog.info2 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section _block">
|
||||
<div class="title">{{ i18n.ts._filters._dialog.wordFilters }}</div>
|
||||
<div class="content">
|
||||
<p><code>[-]{{ i18n.ts._filters._dialog.word }}</code></p>
|
||||
<p><code>"{{ i18n.ts._filters._dialog.phrase }}"</code></p>
|
||||
<p><code>(one OR of OR multiple OR words)</code></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section _block">
|
||||
<div class="title">{{ i18n.ts._filters._dialog.userDomain }}</div>
|
||||
<div class="content">
|
||||
<p><code>[-]from:[@]user[@host.tld]</code></p>
|
||||
<p><code>[-]mention:[@]user[@host.tld]</code></p>
|
||||
<p><code>[-]reply|to:[@]user[@host.tld]</code></p>
|
||||
<p><code>[-]domain|host:host.tld</code></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section _block">
|
||||
<div class="title">{{ i18n.ts._filters._dialog.miscFilters }}</div>
|
||||
<div class="content">
|
||||
<p><code>[-]filter:followers|following|replies|renotes|boosts</code></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section _block">
|
||||
<div class="title">{{ i18n.ts._filters._dialog.attachmentType }}</div>
|
||||
<div class="content">
|
||||
<p><code>has:image|video|audio|file</code></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section _block">
|
||||
<div class="title">{{ i18n.ts._filters._dialog.postDate }}</div>
|
||||
<div class="content">
|
||||
<p>{{ i18n.ts._filters._dialog.exclusivity }}</p>
|
||||
<p><code>before|until:yyyy-mm-dd</code></p>
|
||||
<p><code>after|since:yyyy-mm-dd</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineComponent } from "vue";
|
||||
import MkTextarea from "@/components/form/textarea.vue";
|
||||
import { definePageMetadata } from "@/scripts/page-metadata";
|
||||
import { i18n } from "@/i18n";
|
||||
import { instance } from "@/instance";
|
||||
|
||||
defineProps<{
|
||||
popup?: boolean;
|
||||
}>();
|
||||
|
||||
definePageMetadata({
|
||||
title: i18n.ts._filters._dialog.title,
|
||||
icon: "ph-funnel ph-bold ph-lg",
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-filters {
|
||||
> .section {
|
||||
> .title {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
top: var(--stickyTop, 0px);
|
||||
padding: 16px;
|
||||
font-weight: bold;
|
||||
-webkit-backdrop-filter: var(--blur, blur(10px));
|
||||
backdrop-filter: var(--blur, blur(10px));
|
||||
background-color: var(--panelTransparent);
|
||||
}
|
||||
|
||||
> .content {
|
||||
> p {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
> .preview {
|
||||
border-top: solid 0.5px var(--divider);
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,11 +1,10 @@
|
|||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
import { mainRouter } from "@/router";
|
||||
import { instance } from "@/instance";
|
||||
|
||||
export async function search() {
|
||||
const { canceled, result: query } = await os.inputText({
|
||||
type: instance.features.searchFilters ? "search" : "text",
|
||||
type: "search",
|
||||
title: i18n.ts.search,
|
||||
placeholder: i18n.ts.searchPlaceholder,
|
||||
});
|
||||
|
|
|
@ -103,8 +103,7 @@ os.apiGet("server-info", {}).then((res) => {
|
|||
|
||||
const toggleView = () => {
|
||||
if (
|
||||
(widgetProps.view === 5 && instance.features.searchFilters) ||
|
||||
(widgetProps.view === 4 && !instance.features.searchFilters)
|
||||
(widgetProps.view === 5)
|
||||
) {
|
||||
widgetProps.view = 0;
|
||||
} else {
|
||||
|
|
Loading…
Reference in a new issue