feat(front): use Url to store and manage global search query (search modal)

This commit is contained in:
upsiflu 2025-02-10 20:17:59 +01:00
parent 0a56a12e91
commit 4e8081318e
8 changed files with 209 additions and 105 deletions

View File

@ -19,7 +19,8 @@ import Queue from '~/components/Queue.vue'
import Sidebar from '~/ui/components/Sidebar.vue'
import ShortcutsModal from '~/ui/modals/Shortcuts.vue'
import LanguagesModal from '~/ui/modals/Languages.vue'
import UploadModal from '~/ui/modals/Upload.vue';
import SearchModal from '~/ui/modals/Search.vue'
import UploadModal from '~/ui/modals/Upload.vue'
// Fake content
onMounted(async () => {
@ -90,6 +91,7 @@ store.dispatch('auth/fetchUser')
<FilterModal v-if="store.state.auth.authenticated" />
<ReportModal />
<UploadModal v-if="store.state.auth.authenticated" />
<SearchModal />
</div>
</template>

View File

@ -62,13 +62,13 @@ const model = defineModel<string|number>({ required: true })
</span>
<input
v-bind="{...$attrs, ...attributes, ...color(props, ['solid', 'default', 'secondary'])()}"
v-model="model"
ref="input"
:autofocus="autofocus || undefined"
:placeholder="placeholder"
@click.stop
@blur="showPassword = false"
v-bind="{...$attrs, ...attributes, ...color(props, ['solid', 'default', 'secondary'])()}"
v-model="model"
ref="input"
:autofocus="autofocus || undefined"
:placeholder="placeholder"
@click.stop
@blur="showPassword = false"
/>
<!-- Left side icon -->

View File

@ -3,6 +3,7 @@ import { ref, onMounted, watch, computed } from 'vue'
import { useUploadsStore } from '../stores/upload'
import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
import { useModal } from '~/ui/composables/useModal.ts'
import Logo from '~/components/Logo.vue'
import Input from '~/components/ui/Input.vue'
@ -18,14 +19,13 @@ const isCollapsed = ref(true)
const route = useRoute()
watch(() => route.path, () => isCollapsed.value = true)
const searchQuery = ref('')
// Hide the fake app when the real one is loaded
onMounted(() => {
document.getElementById('fake-app')?.remove()
})
const { t } = useI18n()
const { value: searchParameter } = useModal('search')
const store = useStore()
const uploads = useUploadsStore()
@ -91,17 +91,14 @@ const logoUrl = computed(() => store.state.auth.authenticated ? 'library.index'
</Layout>
</Layout>
<Layout no-gap stack :class="[$style['menu-links'], isCollapsed && 'hide-on-mobile']">
<form :class="$style.search">
<Input
name="search"
v-model="searchQuery"
raised
autocomplete="search"
type="search"
icon="bi-search"
:placeholder="t('components.audio.SearchBar.placeholder.search')"
/>
</form>
<Input
raised
v-model="searchParameter"
autocomplete="search"
type="search"
icon="bi-search"
:placeholder="t('components.audio.SearchBar.placeholder.search')"
/>
<Spacer />

View File

@ -91,7 +91,7 @@ const labels = computed(() => ({
{{ labels.settings }}
</PopoverItem>
<hr v-if="store.state.auth.authenticated"/>
<PopoverItem :to="useModal('languages').to">
<PopoverItem :to="useModal('language').to">
<i class="bi bi-translate" />
{{ labels.language }}...
</PopoverItem>

View File

@ -1,82 +1,117 @@
import { computed } from 'vue'
import { useRouter, type RouteLocationRaw } from 'vue-router'
import { computed } from "vue";
import { useRouter, type RouteLocationRaw, type LocationQuery } from "vue-router";
/**
* Bind a modal to a single query parameter in the URL (and vice versa)
*
* @param flag query parameter to appear in the `url`, corresponding with `isOpen`
* @param assignment a tuple of two functions:
* - from outside world to flag value (default: (_) => null)
* - from flag value to boolean (default: if flag value is null)
*
* This functionality completely independent from the `router` modules.
*/
export function useModal(flag: string) {
const router = useRouter()
const query = computed(() => router?.currentRoute.value ? router.currentRoute.value.query : {})
export const useModal = <T> (
flag: string,
assignment: { on: (value : T | null) => string | null, isOn: (value: LocationQuery[string]) => boolean } =
{ on: (_) => null, isOn: (value) => value === null }
) => {
const router = useRouter();
const query = computed(() =>
router?.currentRoute.value ? router.currentRoute.value.query : {}
);
/**
* Visibility of this modal
*
* Use this function to bind a modal to a query parameter in the URL.
* Using this helper function will not change the routes.
*
* For example, if the modal flag is 'upload':
*
* ```vue
* <template>
* <Modal :open="isOpen('upload')">...</Modal>
* </template>
* ```
*
* Url: `https://funkwhale.audio/?upload`
*
* More information on query flags: https://router.vuejs.org/api/type-aliases/LocationQueryValue.html
*
* Use `asAttribute` instead to bind a modal to an on/off attribute such as `aria-expanded` or `aria-pressed`
*
* @param flag Identifier for the modal
* @returns a `ref`<boolean>
*/
const isOpen = computed({
get() {
const newValue = flag in query.value && query.value[flag] === null
// console.log("GET isOpen", flag, "in", query, newValue)
return newValue
},
set(newValue: boolean) {
// console.log("SET isOpen", query, "+", newValue, ", was", isOpen.value)
router.push({ query: { ...query.value, [flag]: newValue ? null : undefined }}).catch((err) => {
throw new Error(`Problem pushing route query: ${err}.`);
});
// console.log("DONE SET isOpen", query)
}
})
/**
* Visibility of this modal
*
* Use this function to bind a modal to a query parameter in the URL.
* Using this helper function will not change the routes.
*
* For example, if the modal flag is 'upload':
*
* ```vue
* <template>
* <Modal :open="isOpen('upload')">...</Modal>
* </template>
* ```
*
* Url: `https://funkwhale.audio/?upload`
*
* More information on query flags: https://router.vuejs.org/api/type-aliases/LocationQueryValue.html
*
* Use `asAttribute` instead to bind a modal to an on/off attribute such as `aria-expanded` or `aria-pressed`
*
* @param flag Identifier for the modal
* @returns a `ref`<boolean>
*/
const isOpen = computed({
get() {
return flag in query.value && assignment.isOn(query.value[flag]);
},
set(newValue: boolean) {
router
.push({
query: {
...query.value,
[flag]: newValue ? assignment.on(null) : undefined,
},
})
.catch((err) => {
throw new Error(`Problem pushing route query: ${err}.`);
});
},
});
/**
*
* @param flag Identifier for the modal
* @returns a `RouteLocationRaw` object to use in the `to` prop of a RouterLink component
*/
const to: RouteLocationRaw = {
query: { ...query.value, [flag]: null }
}
/**
* Current value of the flag in the URL, normalized to a string.
*
* Note that both `null` and `undefined` are coerced to an empty string, and arrays are joined with a space.
*
* Use in inputs.
*/
const value = computed({
get() {
const flagValue = flag in query.value ? query.value[flag] : '';
return typeof flagValue === 'string' ? flagValue : flagValue === null ? '' : flagValue.join(' ');
},
set(newValue: string) {
router
.push({
query: {
...query.value,
[flag]: newValue,
},
})
.catch((err) => {
throw new Error(`Problem pushing route query: ${err}.`);
});
},
})
/**
* Use this function to bind a modal to an on/off attribute such as `aria-expanded` or `aria-pressed`
*
* @param flag Identifier for the modal
* @returns a `ref`<true | undefined>
*/
const asAttribute = computed(() =>
isOpen.value || undefined
)
/**
*
* @param flag Identifier for the modal
* @returns a `RouteLocationRaw` object to use in the `to` prop of a RouterLink component
*/
const to: RouteLocationRaw = {
query: { ...query.value, [flag]: null },
};
/**
* Toggle the visibility of this modal
*
*
* @param flag Identifier for the modal
*/
const toggle = () =>
isOpen.value = !isOpen.value
/**
* Use this function to bind a modal to an on/off attribute such as `aria-expanded` or `aria-pressed`
*
* @param flag Identifier for the modal
* @returns a `ref`<true | undefined>
*/
const asAttribute = computed(() => isOpen.value || undefined);
return { isOpen, to, asAttribute, toggle }
/**
* Toggle the visibility of this modal
*
*
* @param flag Identifier for the modal
*/
const toggle = () => (isOpen.value = !isOpen.value);
return { value, isOpen, to, asAttribute, toggle };
}

View File

@ -10,7 +10,7 @@ import Layout from '~/components/ui/Layout.vue'
const { t, locale } = useI18n()
const isOpen = useModal('languages').isOpen
const isOpen = useModal('language').isOpen
</script>
<template>

View File

@ -0,0 +1,80 @@
<script setup lang="ts">
import { type components } from '~/generated/types.ts'
import axios from 'axios'
import { ref, watch } from 'vue';
import { refDebounced } from '@vueuse/core'
import useErrorHandler from '~/composables/useErrorHandler'
import { useI18n } from 'vue-i18n'
import { useModal } from '~/ui/composables/useModal.ts'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut';
import Modal from '~/components/ui/Modal.vue'
import Button from '~/components/ui/Button.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Input from '~/components/ui/Input.vue'
const { t } = useI18n()
const { isOpen, toggle, value:query } = useModal('search', { on: () => '', isOn: (value) => value !== undefined && value !== '' });
onKeyboardShortcut('u', () => toggle())
const startRadio = () => {
// TODO: Start the radio
console.log('start radio')
}
const queryDebounced = refDebounced(query, 500)
const isLoading = ref(false)
const results = ref<null | components['schemas']['SearchResult']>(null)
const search = async () => {
if (queryDebounced.value.length < 1) {
isOpen.value = false
return
}
isLoading.value = true
const params = {
query: queryDebounced.value
}
try {
const response = await axios.get<components['schemas']['SearchResult']>('search/', { params })
results.value = response.data
} catch (error) {
useErrorHandler(error as Error)
results.value = null
}
isLoading.value = false
}
watch(queryDebounced, search, { immediate: true })
</script>
<template>
<Modal overPopover
title=""
v-model="isOpen"
>
<template #topleft>
<Input ghost autofocus
icon="bi-search"
v-model="query"
/>
</template>
<Layout flex>
<Spacer grow />
<Button @click="startRadio()">{{ t('components.audio.podcast.Modal.button.startRadio') }}</Button>
</Layout>
</Modal>
</template>
<style module>
.description {
font-size: 0.875em;
}
</style>

View File

@ -4,6 +4,8 @@ import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut';
import { useModal } from '~/ui/composables/useModal.ts'
import Modal from '~/components/ui/Modal.vue'
import Button from '~/components/ui/Button.vue'
import Input from '~/components/ui/Input.vue'
@ -23,21 +25,9 @@ import LibraryWidget from '~/components/federation/LibraryWidget.vue'
const { t } = useI18n()
const store = useStore()
const modalName = 'upload'
onKeyboardShortcut('u', () => useModal('upload').toggle())
const isOpen = computed({
get() {
return store.state.ui.modalsOpen.has(modalName);
},
set(value) {
if (value===false) {
state.value = init()
}
store.commit('ui/setModal', [modalName, value])
}
})
onKeyboardShortcut('u', () => store.commit('ui/toggleModal', modalName))
const isOpen = useModal('upload').isOpen
type UploadDestination = 'channel' | 'library' | 'podcast'