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
ae6ac1f624
commit
a728e48110
|
@ -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