feat(front): use Url to store and manage global search query (search modal)
This commit is contained in:
parent
0a56a12e91
commit
4e8081318e
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 -->
|
||||
|
|
|
@ -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 />
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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'
|
||||
|
||||
|
|
Loading…
Reference in New Issue