refactor(front): consolidate modals into ui/modals/* and bind them to query flags
closes #2403
This commit is contained in:
parent
f247cb7b2b
commit
74f6f1d8d1
|
@ -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