refactor(front): consolidate modals into ui/modals/* and bind them to query flags

closes #2403
This commit is contained in:
jon r 2025-04-17 12:45:18 +02:00
parent f247cb7b2b
commit 74f6f1d8d1
3 changed files with 362 additions and 0 deletions

View File

@ -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))
: []

View File

@ -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>

View File

@ -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>