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