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 Sidebar from '~/ui/components/Sidebar.vue'
|
||||||
import ShortcutsModal from '~/ui/modals/Shortcuts.vue'
|
import ShortcutsModal from '~/ui/modals/Shortcuts.vue'
|
||||||
import LanguagesModal from '~/ui/modals/Languages.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
|
// Fake content
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
@ -90,6 +91,7 @@ store.dispatch('auth/fetchUser')
|
||||||
<FilterModal v-if="store.state.auth.authenticated" />
|
<FilterModal v-if="store.state.auth.authenticated" />
|
||||||
<ReportModal />
|
<ReportModal />
|
||||||
<UploadModal v-if="store.state.auth.authenticated" />
|
<UploadModal v-if="store.state.auth.authenticated" />
|
||||||
|
<SearchModal />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { ref, onMounted, watch, computed } from 'vue'
|
||||||
import { useUploadsStore } from '../stores/upload'
|
import { useUploadsStore } from '../stores/upload'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
import { useModal } from '~/ui/composables/useModal.ts'
|
||||||
|
|
||||||
import Logo from '~/components/Logo.vue'
|
import Logo from '~/components/Logo.vue'
|
||||||
import Input from '~/components/ui/Input.vue'
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
@ -18,14 +19,13 @@ const isCollapsed = ref(true)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
watch(() => route.path, () => isCollapsed.value = true)
|
watch(() => route.path, () => isCollapsed.value = true)
|
||||||
|
|
||||||
const searchQuery = ref('')
|
|
||||||
|
|
||||||
// Hide the fake app when the real one is loaded
|
// Hide the fake app when the real one is loaded
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.getElementById('fake-app')?.remove()
|
document.getElementById('fake-app')?.remove()
|
||||||
})
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const { value: searchParameter } = useModal('search')
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const uploads = useUploadsStore()
|
const uploads = useUploadsStore()
|
||||||
|
@ -91,17 +91,14 @@ const logoUrl = computed(() => store.state.auth.authenticated ? 'library.index'
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
<Layout no-gap stack :class="[$style['menu-links'], isCollapsed && 'hide-on-mobile']">
|
<Layout no-gap stack :class="[$style['menu-links'], isCollapsed && 'hide-on-mobile']">
|
||||||
<form :class="$style.search">
|
|
||||||
<Input
|
<Input
|
||||||
name="search"
|
|
||||||
v-model="searchQuery"
|
|
||||||
raised
|
raised
|
||||||
|
v-model="searchParameter"
|
||||||
autocomplete="search"
|
autocomplete="search"
|
||||||
type="search"
|
type="search"
|
||||||
icon="bi-search"
|
icon="bi-search"
|
||||||
:placeholder="t('components.audio.SearchBar.placeholder.search')"
|
:placeholder="t('components.audio.SearchBar.placeholder.search')"
|
||||||
/>
|
/>
|
||||||
</form>
|
|
||||||
|
|
||||||
<Spacer />
|
<Spacer />
|
||||||
|
|
||||||
|
|
|
@ -91,7 +91,7 @@ const labels = computed(() => ({
|
||||||
{{ labels.settings }}
|
{{ labels.settings }}
|
||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
<hr v-if="store.state.auth.authenticated"/>
|
<hr v-if="store.state.auth.authenticated"/>
|
||||||
<PopoverItem :to="useModal('languages').to">
|
<PopoverItem :to="useModal('language').to">
|
||||||
<i class="bi bi-translate" />
|
<i class="bi bi-translate" />
|
||||||
{{ labels.language }}...
|
{{ labels.language }}...
|
||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
|
|
|
@ -1,16 +1,25 @@
|
||||||
import { computed } from 'vue'
|
import { computed } from "vue";
|
||||||
import { useRouter, type RouteLocationRaw } from 'vue-router'
|
import { useRouter, type RouteLocationRaw, type LocationQuery } from "vue-router";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bind a modal to a single query parameter in the URL (and vice versa)
|
* 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 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.
|
* This functionality completely independent from the `router` modules.
|
||||||
*/
|
*/
|
||||||
export function useModal(flag: string) {
|
export const useModal = <T> (
|
||||||
const router = useRouter()
|
flag: string,
|
||||||
const query = computed(() => router?.currentRoute.value ? router.currentRoute.value.query : {})
|
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
|
* Visibility of this modal
|
||||||
|
@ -37,17 +46,46 @@ export function useModal(flag: string) {
|
||||||
*/
|
*/
|
||||||
const isOpen = computed({
|
const isOpen = computed({
|
||||||
get() {
|
get() {
|
||||||
const newValue = flag in query.value && query.value[flag] === null
|
return flag in query.value && assignment.isOn(query.value[flag]);
|
||||||
// console.log("GET isOpen", flag, "in", query, newValue)
|
|
||||||
return newValue
|
|
||||||
},
|
},
|
||||||
set(newValue: boolean) {
|
set(newValue: boolean) {
|
||||||
// console.log("SET isOpen", query, "+", newValue, ", was", isOpen.value)
|
router
|
||||||
router.push({ query: { ...query.value, [flag]: newValue ? null : undefined }}).catch((err) => {
|
.push({
|
||||||
|
query: {
|
||||||
|
...query.value,
|
||||||
|
[flag]: newValue ? assignment.on(null) : undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
throw new Error(`Problem pushing route query: ${err}.`);
|
throw new Error(`Problem pushing route query: ${err}.`);
|
||||||
});
|
});
|
||||||
// console.log("DONE SET isOpen", query)
|
},
|
||||||
}
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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}.`);
|
||||||
|
});
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -56,8 +94,8 @@ export function useModal(flag: string) {
|
||||||
* @returns a `RouteLocationRaw` object to use in the `to` prop of a RouterLink component
|
* @returns a `RouteLocationRaw` object to use in the `to` prop of a RouterLink component
|
||||||
*/
|
*/
|
||||||
const to: RouteLocationRaw = {
|
const to: RouteLocationRaw = {
|
||||||
query: { ...query.value, [flag]: null }
|
query: { ...query.value, [flag]: null },
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use this function to bind a modal to an on/off attribute such as `aria-expanded` or `aria-pressed`
|
* Use this function to bind a modal to an on/off attribute such as `aria-expanded` or `aria-pressed`
|
||||||
|
@ -65,9 +103,7 @@ export function useModal(flag: string) {
|
||||||
* @param flag Identifier for the modal
|
* @param flag Identifier for the modal
|
||||||
* @returns a `ref`<true | undefined>
|
* @returns a `ref`<true | undefined>
|
||||||
*/
|
*/
|
||||||
const asAttribute = computed(() =>
|
const asAttribute = computed(() => isOpen.value || undefined);
|
||||||
isOpen.value || undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle the visibility of this modal
|
* Toggle the visibility of this modal
|
||||||
|
@ -75,8 +111,7 @@ export function useModal(flag: string) {
|
||||||
*
|
*
|
||||||
* @param flag Identifier for the modal
|
* @param flag Identifier for the modal
|
||||||
*/
|
*/
|
||||||
const toggle = () =>
|
const toggle = () => (isOpen.value = !isOpen.value);
|
||||||
isOpen.value = !isOpen.value
|
|
||||||
|
|
||||||
return { isOpen, to, asAttribute, toggle }
|
return { value, isOpen, to, asAttribute, toggle };
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import Layout from '~/components/ui/Layout.vue'
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
const isOpen = useModal('languages').isOpen
|
const isOpen = useModal('language').isOpen
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 { useStore } from '~/store'
|
||||||
import onKeyboardShortcut from '~/composables/onKeyboardShortcut';
|
import onKeyboardShortcut from '~/composables/onKeyboardShortcut';
|
||||||
|
|
||||||
|
import { useModal } from '~/ui/composables/useModal.ts'
|
||||||
|
|
||||||
import Modal from '~/components/ui/Modal.vue'
|
import Modal from '~/components/ui/Modal.vue'
|
||||||
import Button from '~/components/ui/Button.vue'
|
import Button from '~/components/ui/Button.vue'
|
||||||
import Input from '~/components/ui/Input.vue'
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
@ -23,21 +25,9 @@ import LibraryWidget from '~/components/federation/LibraryWidget.vue'
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
|
||||||
const modalName = 'upload'
|
onKeyboardShortcut('u', () => useModal('upload').toggle())
|
||||||
|
|
||||||
const isOpen = computed({
|
const isOpen = useModal('upload').isOpen
|
||||||
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))
|
|
||||||
|
|
||||||
type UploadDestination = 'channel' | 'library' | 'podcast'
|
type UploadDestination = 'channel' | 'library' | 'podcast'
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue