Refactor(front): consolidate modals into ui/modals/* and bind them to query flags
closes #2403 Co-Authored-By: ArneBo <arne@ecobasa.org> Co-Authored-By: Flupsi <upsiflu@gmail.com> Co-Authored-By: jon r <jon@allmende.io>
This commit is contained in:
		
							parent
							
								
									cbb0d37ee0
								
							
						
					
					
						commit
						4a367eeb4c
					
				| 
						 | 
				
			
			@ -0,0 +1,125 @@
 | 
			
		|||
import { computed } from 'vue'
 | 
			
		||||
import { useRouter, type RouteLocationRaw, type LocationQuery } from 'vue-router'
 | 
			
		||||
 | 
			
		||||
type Assignment<T> = { on: (value : T | null) => string | null, isOn: (value: LocationQuery[string]) => boolean }
 | 
			
		||||
 | 
			
		||||
export const exactlyNull:Assignment<unknown> = ({ on: (_) => null, isOn: (value) => value === null })
 | 
			
		||||
export const notUndefined:Assignment<unknown> = ({ on: (_) => null, isOn: (value) => value !== undefined })
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 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 const useModal = <T> (
 | 
			
		||||
  flag: string,
 | 
			
		||||
  assignment: Assignment<T> = exactlyNull
 | 
			
		||||
) => {
 | 
			
		||||
  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 () {
 | 
			
		||||
      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}.`)
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * 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}.`)
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   *
 | 
			
		||||
   * @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 }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * 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)
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Toggle the visibility of this modal
 | 
			
		||||
   *
 | 
			
		||||
   *
 | 
			
		||||
   * @param flag Identifier for the modal
 | 
			
		||||
   */
 | 
			
		||||
  const toggle = () => (isOpen.value = !isOpen.value)
 | 
			
		||||
 | 
			
		||||
  return { value, isOpen, to, asAttribute, toggle }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* All possible useModals that produce a given `RouterLink` destination */
 | 
			
		||||
export const fromProps = <T>({ to } : { to?: RouteLocationRaw }, assignment: Assignment<T> = exactlyNull): ReturnType<typeof useModal>[] =>
 | 
			
		||||
  to && typeof to !== 'string' && 'query' in to && to.query
 | 
			
		||||
    ? Object.keys(to.query).map(k => useModal(k, assignment))
 | 
			
		||||
    : []
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,46 @@
 | 
			
		|||
<script setup lang="ts">
 | 
			
		||||
import { SUPPORTED_LOCALES, setI18nLanguage } from '~/init/locale'
 | 
			
		||||
import { useI18n } from 'vue-i18n'
 | 
			
		||||
 | 
			
		||||
import { useModal } from '~/ui/composables/useModal.ts'
 | 
			
		||||
 | 
			
		||||
import Modal from '~/components/ui/Modal.vue'
 | 
			
		||||
import Button from '~/components/ui/Button.vue'
 | 
			
		||||
import Layout from '~/components/ui/Layout.vue'
 | 
			
		||||
 | 
			
		||||
const { t, locale } = useI18n()
 | 
			
		||||
 | 
			
		||||
const isOpen = useModal('language').isOpen
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <Modal
 | 
			
		||||
    v-model="isOpen"
 | 
			
		||||
    over-popover
 | 
			
		||||
    :title="t('components.common.UserMenu.label.language')"
 | 
			
		||||
  >
 | 
			
		||||
    <Layout
 | 
			
		||||
      columns
 | 
			
		||||
      column-width="200px"
 | 
			
		||||
    >
 | 
			
		||||
      <Button
 | 
			
		||||
        v-for="(language, key) in SUPPORTED_LOCALES"
 | 
			
		||||
        :key="key"
 | 
			
		||||
        ghost
 | 
			
		||||
        thin-font
 | 
			
		||||
        small
 | 
			
		||||
        align-text="left"
 | 
			
		||||
        :aria-pressed="key===locale || undefined"
 | 
			
		||||
        @click="setI18nLanguage(key)"
 | 
			
		||||
      >
 | 
			
		||||
        {{ language }}
 | 
			
		||||
      </Button>
 | 
			
		||||
    </Layout>
 | 
			
		||||
  </Modal>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<style module>
 | 
			
		||||
  .description {
 | 
			
		||||
    font-size: 0.875em;
 | 
			
		||||
  }
 | 
			
		||||
</style>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,191 @@
 | 
			
		|||
<script setup lang="ts">
 | 
			
		||||
import { computed } from 'vue'
 | 
			
		||||
import { useI18n } from 'vue-i18n'
 | 
			
		||||
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 Layout from '~/components/ui/Layout.vue'
 | 
			
		||||
import Spacer from '~/components/ui/Spacer.vue'
 | 
			
		||||
 | 
			
		||||
const { t } = useI18n()
 | 
			
		||||
 | 
			
		||||
const isOpen = useModal('shortcuts').isOpen
 | 
			
		||||
 | 
			
		||||
onKeyboardShortcut('h', (() => { isOpen.value = !isOpen.value }))
 | 
			
		||||
 | 
			
		||||
const general = computed(() => [
 | 
			
		||||
  {
 | 
			
		||||
    title: t('components.ShortcutsModal.shortcut.general.label'),
 | 
			
		||||
    shortcuts: [
 | 
			
		||||
      {
 | 
			
		||||
        key: 'h',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.general.show')
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'shift + f',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.general.focus')
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: '/',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.general.focus')
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'esc',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.general.unfocus')
 | 
			
		||||
      }
 | 
			
		||||
    ]
 | 
			
		||||
  }
 | 
			
		||||
])
 | 
			
		||||
 | 
			
		||||
const player = computed(() => [
 | 
			
		||||
  {
 | 
			
		||||
    title: t('components.ShortcutsModal.shortcut.audio.label'),
 | 
			
		||||
    shortcuts: [
 | 
			
		||||
      {
 | 
			
		||||
        key: 'p',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.audio.playPause')
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'left',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.audio.seekBack5')
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'right',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.audio.seekForward5')
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'shift + left',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.audio.seekBack30')
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'shift + right',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.audio.seekForward30')
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'ctrl + shift + left',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.audio.playPrevious')
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'ctrl + shift + right',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.audio.playNext')
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'shift + up',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.audio.increaseVolume')
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'shift + down',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.audio.decreaseVolume')
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'm',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.audio.toggleMute')
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'e',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.audio.expandQueue')
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'l',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.audio.toggleLoop')
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 's',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.audio.shuffleQueue')
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'q',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.audio.clearQueue')
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'f',
 | 
			
		||||
        summary: t('components.ShortcutsModal.shortcut.audio.toggleFavorite')
 | 
			
		||||
      }
 | 
			
		||||
    ]
 | 
			
		||||
  }
 | 
			
		||||
])
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <Modal
 | 
			
		||||
    v-model="isOpen"
 | 
			
		||||
    over-popover
 | 
			
		||||
    :title="t('components.ShortcutsModal.header.modal')"
 | 
			
		||||
  >
 | 
			
		||||
    <Layout grid="auto / repeat(auto-fit, minmax(min-content, max(calc(50% - 16px), 367px)))">
 | 
			
		||||
      <div
 | 
			
		||||
        v-for="section in player"
 | 
			
		||||
        :key="section.title"
 | 
			
		||||
      >
 | 
			
		||||
        <h3 style="margin-top: 0px;">
 | 
			
		||||
          {{ section.title }}
 | 
			
		||||
        </h3>
 | 
			
		||||
        <layout
 | 
			
		||||
          stack
 | 
			
		||||
          no-gap
 | 
			
		||||
        >
 | 
			
		||||
          <template
 | 
			
		||||
            v-for="shortcut in section.shortcuts"
 | 
			
		||||
            :key="shortcut.summary"
 | 
			
		||||
          >
 | 
			
		||||
            <layout flex>
 | 
			
		||||
              <span
 | 
			
		||||
                :class="$style.description"
 | 
			
		||||
                style="align-self: center;"
 | 
			
		||||
              >{{ shortcut.summary }}</span>
 | 
			
		||||
              <Spacer grow />
 | 
			
		||||
              <Button
 | 
			
		||||
                style="pointer-events: none;"
 | 
			
		||||
                min-content
 | 
			
		||||
              >
 | 
			
		||||
                {{ shortcut.key }}
 | 
			
		||||
              </Button>
 | 
			
		||||
            </layout>
 | 
			
		||||
            <hr>
 | 
			
		||||
          </template>
 | 
			
		||||
        </layout>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div
 | 
			
		||||
        v-for="section in general"
 | 
			
		||||
        :key="section.title"
 | 
			
		||||
      >
 | 
			
		||||
        <h3 style="margin-top: 0px;">
 | 
			
		||||
          {{ section.title }}
 | 
			
		||||
        </h3>
 | 
			
		||||
        <layout
 | 
			
		||||
          stack
 | 
			
		||||
          no-gap
 | 
			
		||||
        >
 | 
			
		||||
          <div
 | 
			
		||||
            v-for="shortcut in section.shortcuts"
 | 
			
		||||
            :key="shortcut.summary"
 | 
			
		||||
          >
 | 
			
		||||
            <layout flex>
 | 
			
		||||
              <span
 | 
			
		||||
                :class="$style.description"
 | 
			
		||||
                style="align-self: center;"
 | 
			
		||||
              >{{ shortcut.summary }}</span>
 | 
			
		||||
              <Spacer grow />
 | 
			
		||||
              <Button
 | 
			
		||||
                style="pointer-events:none;"
 | 
			
		||||
                min-content
 | 
			
		||||
              >
 | 
			
		||||
                {{ shortcut.key }}
 | 
			
		||||
              </Button>
 | 
			
		||||
            </layout>
 | 
			
		||||
            <hr>
 | 
			
		||||
          </div>
 | 
			
		||||
        </layout>
 | 
			
		||||
      </div>
 | 
			
		||||
    </Layout>
 | 
			
		||||
  </Modal>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<style module>
 | 
			
		||||
  .description {
 | 
			
		||||
    font-size: 0.875em;
 | 
			
		||||
  }
 | 
			
		||||
</style>
 | 
			
		||||
		Loading…
	
		Reference in New Issue